diff --git a/app/controllers/vips/admin.php b/app/controllers/vips/admin.php new file mode 100644 index 0000000000000000000000000000000000000000..92c6cc7bc13a3e1bcbe6336258a1fc9a52569b99 --- /dev/null +++ b/app/controllers/vips/admin.php @@ -0,0 +1,208 @@ +<?php +/** + * vips/admin.php - course administration controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_AdminController extends AuthenticatedController +{ + /** + * Edit or create a block in the course. + */ + public function edit_block_action() + { + Navigation::activateItem('/course/vips/sheets'); + PageLayout::setHelpKeyword('Basis.Vips'); + + $block_id = Request::int('block_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } else { + $block = new VipsBlock(); + $block->range_id = Context::getId(); + } + + VipsModule::requireStatus('tutor', $block->range_id); + + $this->block = $block; + $this->groups = Statusgruppen::findBySeminar_id($block->range_id); + } + + /** + * Store changes to a block. + */ + public function store_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $block_id = Request::int('block_id'); + $group_id = Request::option('group_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } else { + $block = new VipsBlock(); + $block->range_id = Context::getId(); + } + + VipsModule::requireStatus('tutor', $block->range_id); + + $block->name = Request::get('block_name'); + $block->group_id = $group_id ?: null; + $block->visible = $group_id !== ''; + + if (!Request::int('block_grouped')) { + $block->weight = null; + } else if ($block->weight === null) { + $block->weight = 0; + + if ($block_id) { + // sum up individual assignment weights for total block weight + foreach (VipsAssignment::findByBlock_id($block_id) as $assignment) { + $block->weight += $assignment->weight; + } + } + } + + $block->store(); + + PageLayout::postSuccess(sprintf(_('Der Block „%s“ wurde gespeichert.'), htmlReady($block->name))); + + $this->redirect($this->url_for('vips/sheets', ['group' => 1])); + } + + /** + * Delete a block from the course. + */ + public function delete_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $block_id = Request::int('block_id'); + $block = VipsBlock::find($block_id); + $block_name = $block->name; + + VipsModule::requireStatus('tutor', $block->range_id); + + if ($block->delete()) { + PageLayout::postSuccess(sprintf(_('Der Block „%s“ wurde gelöscht.'), htmlReady($block_name))); + } + + $this->redirect('vips/sheets'); + } + + /** + * Stores the weights of blocks, sheets and exams + */ + public function store_weight_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_weight = Request::floatArray('assignment_weight'); + $block_weight = Request::floatArray('block_weight'); + + foreach ($assignment_weight as $assignment_id => $weight) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $assignment->weight = $weight; + $assignment->store(); + } + + foreach ($block_weight as $block_id => $weight) { + $block = VipsBlock::find($block_id); + VipsModule::requireStatus('tutor', $block->range_id); + + $block->weight = $weight; + $block->store(); + } + + $this->redirect('vips/solutions'); + } + + /** + * Edit the grade distribution settings. + */ + public function edit_grades_action() + { + Navigation::activateItem('/course/vips/solutions'); + PageLayout::setHelpKeyword('Basis.VipsErgebnisse'); + + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $grades = ['1,0', '1,3', '1,7', '2,0', '2,3', '2,7', '3,0', '3,3', '3,7', '4,0']; + $percentages = array_fill(0, count($grades), ''); + $comments = array_fill(0, count($grades), ''); + $settings = CourseConfig::get($course_id); + + foreach ($settings->VIPS_COURSE_GRADES as $value) { + $index = array_search($value['grade'], $grades); + + if ($index !== false) { + $percentages[$index] = $value['percent']; + $comments[$index] = $value['comment']; + } + } + + $this->grades = $grades; + $this->grade_settings = $settings->VIPS_COURSE_GRADES; + $this->percentages = $percentages; + $this->comments = $comments; + } + + /** + * Stores the distribution of grades + */ + public function store_grades_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $grades = ['1,0', '1,3', '1,7', '2,0', '2,3', '2,7', '3,0', '3,3', '3,7', '4,0']; + $percentages = Request::floatArray('percentage'); + $comments = Request::getArray('comment'); + $grade_settings = []; + $percent_last = 101; + $error = false; + + foreach ($percentages as $i => $percent) { + if ($percent) { + $grade_settings[] = [ + 'grade' => $grades[$i], + 'percent' => $percent, + 'comment' => trim($comments[$i]) + ]; + + if ($percent < 0 || $percent > 100) { + PageLayout::postError(_('Die Notenwerte müssen zwischen 0 und 100 liegen!')); + $error = true; + } else if ($percent_last <= $percent) { + PageLayout::postError(sprintf(_('Die Notenwerte müssen monoton absteigen (%s > %s)!'), $percent_last, $percent)); + $error = true; + } + + $percent_last = $percent; + } + } + + if (!$error) { + $settings = CourseConfig::get($course_id); + $settings->store('VIPS_COURSE_GRADES', $grade_settings); + + PageLayout::postSuccess(_('Die Notenwerte wurden eingetragen.')); + } + + $this->redirect('vips/solutions'); + } +} diff --git a/app/controllers/vips/api.php b/app/controllers/vips/api.php new file mode 100644 index 0000000000000000000000000000000000000000..8c2dbe0c75ae16ab82a00cbabbf55f2d36168121 --- /dev/null +++ b/app/controllers/vips/api.php @@ -0,0 +1,256 @@ +<?php +/** + * vips/api.php - API controller for Vips + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ApiController extends AuthenticatedController +{ + public function assignments_action($range_id) + { + if ($range_id !== $GLOBALS['user']->id) { + VipsModule::requireStatus('tutor', $range_id); + } + + $assignments = VipsAssignment::findByRangeId($range_id); + + $data = []; + + foreach ($assignments as $assignment) { + if ($assignment->type !== 'exam') { + $data[] = [ + 'id' => (string) $assignment->id, + 'title' => $assignment->test->title, + 'type' => $assignment->type, + 'icon' => $assignment->getTypeIcon()->getShape(), + 'start' => date('d.m.Y, H:i', $assignment->start), + 'end' => date('d.m.Y, H:i', $assignment->end), + 'active' => $assignment->active, + 'block' => $assignment->block_id ? $assignment->block->name : null + ]; + } + } + + $this->render_json($data); + } + + public function assignment_action($assignment_id) + { + $assignment = VipsAssignment::find($assignment_id); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment); + + $released = $assignment->releaseStatus($user_id); + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ( + !$assignment->checkAccess($user_id) + && $released < VipsAssignment::RELEASE_STATUS_CORRECTIONS + ) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + $data = [ + 'id' => (string) $assignment->id, + 'title' => $assignment->test->title, + 'type' => $assignment->type, + 'icon' => $assignment->getTypeIcon()->getShape(), + 'start' => date('d.m.Y, H:i', $assignment->start), + 'end' => date('d.m.Y, H:i', $assignment->end), + 'active' => $assignment->active, + 'block' => $assignment->block_id ? $assignment->block->name : null, + 'reset_allowed' => $assignment->isRunning($user_id) && $assignment->isResetAllowed(), + 'points' => $assignment->test->getTotalPoints(), + 'release_status' => $released, + 'exercises' => [] + ]; + + foreach ($assignment->getExerciseRefs($user_id) as $exercise_ref) { + $template = $this->courseware_template($assignment, $exercise_ref, $released); + $exercise = $exercise_ref->exercise; + + $data['exercises'][] = [ + 'id' => $exercise->id, + 'type' => $exercise->type, + 'title' => $exercise->title, + 'template' => $template->render(), + 'item_count' => $exercise->itemCount(), + 'show_solution' => $template->show_solution + ]; + } + + $this->render_json($data); + } + + public function exercise_action($assignment_id, $exercise_id) + { + $assignment = VipsAssignment::find($assignment_id); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment, $exercise_id); + + $released = $assignment->releaseStatus($user_id); + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ( + !$assignment->checkAccess($user_id) + && $released < VipsAssignment::RELEASE_STATUS_CORRECTIONS + ) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $template = $this->courseware_template($assignment, $exercise_ref, $released); + $exercise = $exercise_ref->exercise; + + $data = [ + 'id' => $exercise->id, + 'type' => $exercise->type, + 'title' => $exercise->title, + 'template' => $template->render(), + 'item_count' => $exercise->itemCount(), + 'show_solution' => $template->show_solution + ]; + + $this->render_json($data); + } + + private function courseware_template($assignment, $exercise_ref, $released) + { + $user_id = $GLOBALS['user']->id; + $exercise = $exercise_ref->exercise; + $solution = $assignment->getSolution($user_id, $exercise->id); + $max_tries = $assignment->getMaxTries(); + $max_points = $exercise_ref->points; + $sample_solution = false; + $show_solution = false; + $tries_left = null; + + if ($assignment->isRunning($user_id)) { + // if a solution has been submitted during a selftest + if ($max_tries && $solution) { + $tries_left = $max_tries - $solution->countTries(); + + if ( + $solution->points == $max_points + || !$solution->state + || $solution->grader_id + || $tries_left <= 0 + ) { + $show_solution = true; + $sample_solution = true; + } + } + } else { + $show_solution = true; + $sample_solution = $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS; + + if (!$solution) { + $solution = new VipsSolution(); + $solution->assignment = $assignment; + } + } + + $template = $this->get_template_factory()->open('vips/exercises/courseware_block'); + $template->user_id = $user_id; + $template->assignment = $assignment; + $template->exercise = $exercise; + $template->tries_left = $tries_left; + $template->solution = $solution; + $template->max_points = $max_points; + $template->sample_solution = $sample_solution; + $template->show_solution = $show_solution; + + return $template; + } + + public function solution_action($assignment_id, $exercise_id) + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment = VipsAssignment::find($assignment_id); + $block_id = Request::int('block_id'); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment, $exercise_id); + + // check access to courseware block + if ($block_id) { + $block = Courseware\Block::find($block_id); + $payload = $block->type->getPayload(); + + if ($payload['assignment'] != $assignment_id) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diesen Block!')); + } + } + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if (!$assignment->checkAccess($user_id)) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + if (Request::isPost()) { + $request = Request::getInstance(); + $exercise = Exercise::find($exercise_id); + $solution = $exercise->getSolutionFromRequest($request, $_FILES); + $solution->user_id = $user_id; + + if ($solution->isEmpty()) { + $this->set_status(422); + } else { + $assignment->storeSolution($solution); + $this->set_status(201); + } + } + + if (Request::isDelete()) { + if ($assignment->isResetAllowed()) { + $assignment->deleteSolution($user_id, $exercise_id); + $this->set_status(204); + } else { + $this->set_status(403); + } + } + + // update user progress in Courseware + if ($block_id) { + $progress = new Courseware\UserProgress([$user_id, $block_id]); + $progress->grade = $assignment->getUserProgress($user_id); + $progress->store(); + } + + $this->render_nothing(); + } +} diff --git a/app/controllers/vips/config.php b/app/controllers/vips/config.php new file mode 100644 index 0000000000000000000000000000000000000000..d6d4e4857db9ce63150c96e6c93a70bbe0a53159 --- /dev/null +++ b/app/controllers/vips/config.php @@ -0,0 +1,95 @@ +<?php +/** + * vips/config.php - global configuration controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ConfigController extends AuthenticatedController +{ + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $GLOBALS['perm']->check('root'); + + Navigation::activateItem('/admin/config/vips'); + PageLayout::setHelpKeyword('Basis.VipsEinstellungen'); + PageLayout::setTitle(_('Einstellungen für Aufgaben')); + } + + public function index_action() + { + $this->fields = DataField::getDataFields('user'); + $this->config = Config::get(); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Anstehende Klausuren anzeigen'), + $this->pending_assignmentsURL(), + Icon::create('doctoral_cap') + )->asDialog('size=big'); + Sidebar::get()->addWidget($widget); + } + + public function save_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exam_mode = Request::int('exam_mode', 0); + $exam_terms = trim(Request::get('exam_terms')); + $exam_terms = Studip\Markup::purifyHtml($exam_terms); + + $config = Config::get(); + $config->store('VIPS_EXAM_RESTRICTIONS', $exam_mode); + $config->store('VIPS_EXAM_TERMS', $exam_terms); + + $room = Request::getArray('room'); + $ip_range = Request::getArray('ip_range'); + $ip_ranges = []; + + foreach ($room as $i => $name) { + $name = preg_replace('/[ ,]+/', '_', trim($name)); + + if ($name !== '') { + $ip_ranges[$name] = trim($ip_range[$i]); + } + } + + if ($ip_ranges) { + ksort($ip_ranges); + $config->store('VIPS_EXAM_ROOMS', $ip_ranges); + } + + PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); + + $this->redirect('vips/config'); + } + + public function pending_assignments_action() + { + $this->assignments = VipsAssignment::findBySQL( + "range_type = 'course' AND type = 'exam' AND + start BETWEEN UNIX_TIMESTAMP(NOW() - INTERVAL 1 DAY) AND UNIX_TIMESTAMP(NOW() + INTERVAL 14 DAY) AND end > UNIX_TIMESTAMP() + ORDER BY start" + ); + } +} diff --git a/app/controllers/vips/exam_mode.php b/app/controllers/vips/exam_mode.php new file mode 100644 index 0000000000000000000000000000000000000000..914a0e077da593f3f64a423f2364ece2669157c4 --- /dev/null +++ b/app/controllers/vips/exam_mode.php @@ -0,0 +1,29 @@ +<?php +/** + * vips/exam_mode.php - restricted exam mode controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ExamModeController extends AuthenticatedController +{ + /** + * Display a list of courses with currently active tests of type 'exam'. + * Only used when there are multiple courses with running exams. + */ + public function index_action() + { + PageLayout::setTitle(_('Klausurübersicht')); + + Helpbar::get()->addPlainText('', + _('Der normale Betrieb von Stud.IP ist für Sie zur Zeit gesperrt, da Klausuren geschrieben werden.')); + + $this->courses = VipsModule::getCoursesWithRunningExams($GLOBALS['user']->id); + } +} diff --git a/app/controllers/vips/pool.php b/app/controllers/vips/pool.php new file mode 100644 index 0000000000000000000000000000000000000000..bcbe302fdecc616188febd007a57f118e490d77c --- /dev/null +++ b/app/controllers/vips/pool.php @@ -0,0 +1,473 @@ +<?php +/** + * vips/pool.php - assignment pool controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_PoolController extends AuthenticatedController +{ + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + PageLayout::setHelpKeyword('Basis.Vips'); + } + + /** + * Display all exercises that are available for this user. + * Available in this case means the exercise is in a course where the user + * is at least tutor. + * Lecturer/tutor can select which exercise to edit/assign/delete. + */ + public function exercises_action() + { + Navigation::activateItem('/contents/vips/exercises'); + PageLayout::setTitle(_('Meine Aufgaben')); + + Helpbar::get()->addPlainText('', + _('Auf dieser Seite finden Sie eine Ãœbersicht über alle Aufgaben, auf die Sie Zugriff haben.')); + + $range_type = $_SESSION['view_context'] ?? 'user'; + $range_type = Request::option('range_type', $range_type); + $_SESSION['view_context'] = $range_type; + + $widget = new ViewsWidget(); + $widget->addLink( + _('Persönliche Aufgabensammlung'), + $this->url_for('vips/pool/exercises', ['range_type' => 'user']) + )->setActive($range_type === 'user'); + $widget->addLink( + _('Aufgaben in Veranstaltungen'), + $this->url_for('vips/pool/exercises', ['range_type' => 'course']) + )->setActive($range_type === 'course'); + Sidebar::get()->addWidget($widget); + + $sort = Request::option('sort', 'mkdate'); + $desc = Request::int('desc', $sort === 'mkdate'); + $page = Request::int('page', 1); + $size = Config::get()->ENTRIES_PER_PAGE; + + $search_filter = Request::getArray('search_filter') + ['search_string' => '', 'exercise_type' => '']; + $search_filter['search_string'] = Request::get('pool_search_parameter', $search_filter['search_string']); + $search_filter['exercise_type'] = Request::get('exercise_type', $search_filter['exercise_type']); + + if (Request::submitted('start_search') || Request::int('pool_search')) { + $search_filter = [ + 'search_string' => Request::get('pool_search_parameter'), + 'exercise_type' => Request::get('exercise_type') + ]; + } else if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'exercise_type'], ''); + } + + // get exercises of this user and where he/she has permission + if ($range_type === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + // set up the sql query for the quicksearch + $sql = "SELECT etask_tasks.id, etask_tasks.title FROM etask_tasks + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_assignments USING (test_id) + WHERE etask_assignments.range_id IN ('" . implode("','", $course_ids) . "') + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND (etask_tasks.title LIKE :input OR etask_tasks.description LIKE :input) + AND IF(:exercise_type = '', 1, etask_tasks.type = :exercise_type) + ORDER BY title"; + $search = new SQLSearch($sql, _('Titel der Aufgabe')); + + $widget = new VipsSearchWidget($this->url_for('vips/pool/exercises', ['exercise_type' => $search_filter['exercise_type']])); + $widget->addNeedle(_('Suche'), 'pool_search', true, $search, 'function(id, name) { this.value = name; this.form.submit(); }', $search_filter['search_string']); + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Aufgabentyp'), $this->url_for('vips/pool/exercises', ['pool_search_parameter' => $search_filter['search_string']]), 'exercise_type'); + $element = new SelectElement('', _('Alle Aufgabentypen')); + $widget->addElement($element); + Sidebar::get()->addWidget($widget); + + foreach (Exercise::getExerciseTypes() as $type => $entry) { + $element = new SelectElement($type, $entry['name'], $type === $search_filter['exercise_type']); + $widget->addElement($element); + } + + $result = $this->getAllExercises($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->count = count($result); + $this->exercises = array_slice($result, $size * ($page - 1), $size); + $this->search_filter = $search_filter; + } + + /** + * Display all assignments that are available for this user. + * Available in this case means the assignment is in a course where the user + * is at least tutor. + * Lecturer/tutor can select which assignment to edit/delete. + */ + public function assignments_action() + { + Navigation::activateItem('/contents/vips/assignments'); + PageLayout::setTitle(_('Meine Aufgabenblätter')); + + Helpbar::get()->addPlainText('', + _('Auf dieser Seite finden Sie eine Ãœbersicht über alle Aufgabenblätter, auf die Sie Zugriff haben.')); + + $range_type = $_SESSION['view_context'] ?? 'user'; + $range_type = Request::option('range_type', $range_type); + $_SESSION['view_context'] = $range_type; + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt erstellen'), + $this->url_for('vips/sheets/edit_assignment'), + Icon::create('add') + ); + $widget->addLink( + _('Aufgabenblatt kopieren'), + $this->url_for('vips/sheets/copy_assignment_dialog'), + Icon::create('copy') + )->asDialog('size=1200x800'); + $widget->addLink( + _('Aufgabenblatt importieren'), + $this->url_for('vips/sheets/import_assignment_dialog'), + Icon::create('import') + )->asDialog('size=auto'); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Persönliche Aufgabensammlung'), + $this->url_for('vips/pool/assignments', ['range_type' => 'user']) + )->setActive($range_type === 'user'); + $widget->addLink( + _('Aufgaben in Veranstaltungen'), + $this->url_for('vips/pool/assignments', ['range_type' => 'course']) + )->setActive($range_type === 'course'); + Sidebar::get()->addWidget($widget); + + $sort = Request::option('sort', 'mkdate'); + $desc = Request::int('desc', $sort === 'mkdate'); + $page = Request::int('page', 1); + $size = Config::get()->ENTRIES_PER_PAGE; + + $search_filter = Request::getArray('search_filter') + ['search_string' => '', 'assignment_type' => '']; + $search_filter['search_string'] = Request::get('pool_search_parameter', $search_filter['search_string']); + $search_filter['assignment_type'] = Request::get('assignment_type', $search_filter['assignment_type']); + + // get assignments of this user and where he/she has permission + if ($range_type === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + // set up the sql query for the quicksearch + $sql = "SELECT etask_assignments.id, etask_tests.title FROM etask_tests + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + WHERE etask_assignments.range_id IN ('" . implode("','", $course_ids) . "') + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND (etask_tests.title LIKE :input OR etask_tests.description LIKE :input) + AND IF(:assignment_type = '', 1, etask_assignments.type = :assignment_type) + ORDER BY title"; + $search = new SQLSearch($sql, _('Titel des Aufgabenblatts')); + + $widget = new VipsSearchWidget($this->url_for('vips/pool/assignments', ['assignment_type' => $search_filter['assignment_type']])); + $widget->addNeedle(_('Suche'), 'pool_search', true, $search, 'function(id, name) { this.value = name; this.form.submit(); }', $search_filter['search_string']); + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Modus'), $this->url_for('vips/pool/assignments', ['pool_search_parameter' => $search_filter['search_string']]), 'assignment_type'); + $element = new SelectElement('', _('Beliebiger Modus')); + $widget->addElement($element); + Sidebar::get()->addWidget($widget); + + foreach (VipsAssignment::getAssignmentTypes() as $type => $entry) { + $element = new SelectElement($type, $entry['name'], $type === $search_filter['assignment_type']); + $widget->addElement($element); + } + + $result = $this->getAllAssignments($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->count = count($result); + $this->assignments = array_slice($result, $size * ($page - 1), $size); + $this->search_filter = $search_filter; + } + + /** + * Get all matching exercises from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get exercises from + * @param sort sort exercise list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching exercises + */ + public function getAllExercises($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $exercise_type = $search_filter['exercise_type']; + + $sql = "SELECT etask_tasks.*, + auth_user_md5.Nachname, + auth_user_md5.Vorname, + etask_assignments.id AS assignment_id, + etask_assignments.range_id, + etask_assignments.range_type, + etask_tests.title AS test_title + FROM etask_tasks + LEFT JOIN auth_user_md5 USING(user_id) + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_tests ON etask_tests.id = etask_test_tasks.test_id + JOIN etask_assignments USING (test_id) + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN ('exam', 'practice', 'selftest') " . + ($search_string ? 'AND (etask_tasks.title LIKE :input OR + etask_tasks.description LIKE :input) ' : '') . + ($exercise_type ? 'AND etask_tasks.type = :exercise_type ' : '') . + "ORDER BY :sort :desc, title"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':exercise_type', $exercise_type); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Get all matching assignments from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get assignments from + * @param sort sort assignment list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching assignments + */ + public function getAllAssignments($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $assignment_type = $search_filter['assignment_type']; + $types = $assignment_type ? [$assignment_type] : ['exam', 'practice', 'selftest']; + + $sql = "SELECT etask_assignments.*, + etask_tests.title AS test_title, + auth_user_md5.Nachname, + auth_user_md5.Vorname, + seminare.Name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tests + LEFT JOIN auth_user_md5 USING(user_id) + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + LEFT JOIN seminare ON etask_assignments.range_id = seminare.Seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN (:types) " . + ($search_string ? 'AND (etask_tests.title LIKE :input OR + etask_tests.description LIKE :input) ' : '') . + "ORDER BY :sort :desc, title"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':types', $types); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Delete a list of exercises from their respective assignments. + */ + public function delete_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $deleted = 0; + + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment, $exercise_id); + + if (!$assignment->isLocked()) { + $assignment->test->removeExercise($exercise_id); + ++$deleted; + } + } + + if ($deleted > 0) { + PageLayout::postSuccess(sprintf(ngettext('Die Aufgabe wurde gelöscht.', 'Es wurden %s Aufgaben gelöscht.', $deleted), $deleted)); + } + + if ($deleted < count($exercise_ids)) { + PageLayout::postError(_('Einige Aufgaben konnten nicht gelöscht werden, da die Aufgabenblätter gesperrt sind.'), [ + _('Falls Sie diese wirklich löschen möchten, müssen Sie zuerst die Lösungen aller Teilnehmenden zurücksetzen.') + ]); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Dialog for copying a list of exercises into an existing assignment. + */ + public function copy_exercises_dialog_action() + { + PageLayout::setTitle(_('Aufgaben in vorhandenes Aufgabenblatt kopieren')); + + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Copy the selected exercises into the selected assignment. + */ + public function copy_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $target_assignment_id = Request::int('assignment_id'); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->copyIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Dialog for moving a list of exercises into an existing assignment. + */ + public function move_exercises_dialog_action() + { + PageLayout::setTitle(_('Aufgaben in vorhandenes Aufgabenblatt verschieben')); + + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Move the selected exercises into the selected assignment. + */ + public function move_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $target_assignment_id = Request::int('assignment_id'); + $target_assignment = VipsAssignment::find($target_assignment_id); + $moved = 0; + + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->moveIntoTest($target_assignment->test_id); + ++$moved; + } + } + } + + if ($moved > 0) { + PageLayout::postSuccess(sprintf(ngettext('Die Aufgabe wurde verschoben.', 'Es wurden %s Aufgaben verschoben.', $moved), $moved)); + } + + if ($moved < count($exercise_ids)) { + PageLayout::postError(_('Einige Aufgaben konnten nicht verschoben werden, da die Aufgabenblätter gesperrt sind.')); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Return the appropriate CSS class for sortable column (if any). + * + * @param boolean $sort sort by this column + * @param boolean $desc set sort direction + */ + public function sort_class(bool $sort, ?bool $desc): string + { + return $sort ? ($desc ? 'sortdesc' : 'sortasc') : ''; + } + + /** + * Render a generic page chooser selector. The first occurence of '%d' + * in the URL is replaced with the selected page number. + * + * @param string $url URL for one of the pages + * @param string $count total number of entries + * @param string $page current page to display + * @param string|null $dialog Optional dialog attribute content + * @param int|null $page_size page size (defaults to system default) + * @return mixed + */ + function page_chooser(string $url, string $count, string $page, ?string $dialog = null, ?int $page_size = null) + { + $template = $GLOBALS['template_factory']->open('shared/pagechooser'); + $template->dialog = $dialog; + $template->num_postings = $count; + $template->page = $page; + $template->perPage = $page_size ?: Config::get()->ENTRIES_PER_PAGE; + $template->pagelink = str_replace('%%25d', '%d', str_replace('%', '%%', $url)); + + return $template->render(); + } +} diff --git a/app/controllers/vips/sheets.php b/app/controllers/vips/sheets.php new file mode 100644 index 0000000000000000000000000000000000000000..036ff10a77956fa719ab76da48ea72bad947fdc8 --- /dev/null +++ b/app/controllers/vips/sheets.php @@ -0,0 +1,2305 @@ +<?php +/** + * vips/sheets.php - course assignments controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_SheetsController extends AuthenticatedController +{ + /** + * Return the default action and arguments + * + * @return an array containing the action, an array of args and the format + */ + public function default_action_and_args() + { + return ['list_assignments', [], null]; + } + + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $course_id = Context::getId(); + + if ($action === 'list_assignments' && !VipsModule::hasStatus('tutor', $course_id)) { + $action = 'list_assignments_stud'; + } + + if ($action !== 'relay') { + if (Context::getId()) { + Navigation::activateItem('/course/vips/sheets'); + } else { + Navigation::activateItem('/contents/vips/assignments'); + PageLayout::setTitle(_('Meine Aufgabenblätter')); + } + PageLayout::setHelpKeyword('Basis.Vips'); + } + } + + ##################################### + # # + # Student Methods # + # # + ##################################### + + /** + * Restores an archived solution as the current solution. + */ + public function restore_solution_action() + { + // CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + $solutions = $assignment->getArchivedUserSolutions($solver_id, $exercise_id); + + if ($assignment->checkAccess($solver_id) || $assignment->checkEditPermission()) { + if ($assignment->type === 'exam' && $solutions) { + $assignment->restoreSolution($solutions[0]); + PageLayout::postSuccess(_('Die vorherige Lösung wurde wiederhergestellt.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * Only possible if test is selftest: Delete the solution of a student for + * a particular exercise. + */ + public function delete_solution_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + if ($assignment->checkAccess($solver_id) || $assignment->checkEditPermission()) { + if ($assignment->isResetAllowed() || $assignment->type === 'exam') { + $assignment->deleteSolution($solver_id, $exercise_id); + $undo_link = ''; + + if ($assignment->type === 'exam' && !$assignment->isSelfAssessment()) { + $undo_link = sprintf(' <a href="%s">%s</a>', + $this->link_for('vips/sheets/restore_solution', compact('assignment_id', 'exercise_id', 'solver_id')), + _('Diese Aktion zurücknehmen.')); + } + + PageLayout::postSuccess(_('Die Lösung wurde gelöscht.') . $undo_link); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * Only possible if test is selftest: Deletes all the solutions of a student or + * the student's group to enable him/her to redo it. + */ + public function delete_solutions_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + if ($assignment->isRunning() || $assignment->checkEditPermission()) { + if ($assignment->isResetAllowed()) { + $assignment->deleteSolutions($solver_id); + PageLayout::postSuccess(_('Die Lösungen wurden gelöscht.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id', 'solver_id'))); + } + + /** + * Only possible if test is exam: Begin working on the exam. + */ + public function begin_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $terms_accepted = Request::int('terms_accepted'); + $access_code = Request::get('access_code'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $ip_address = $_SERVER['REMOTE_ADDR']; + + VipsModule::requireViewPermission($assignment); + + if ($assignment->type === 'exam') { + if (!$assignment->getAssignmentAttempt($GLOBALS['user']->id)) { + $exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + if (!$assignment->isRunning() || !$assignment->active) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } else if (!$assignment->checkIPAccess($ip_address)) { + PageLayout::postError(sprintf(_('Sie haben mit Ihrer IP-Adresse „%s“ keinen Zugriff!'), htmlReady($ip_address))); + } else if ($exam_terms && !$terms_accepted) { + PageLayout::postError(_('Ein Start der Klausur ist nur mit Bestätigung der Teilnahmebedingungen möglich.')); + } else if (!$assignment->checkAccessCode($access_code)) { + PageLayout::postError(_('Der eingegebene Zugangscode ist nicht korrekt.')); + } else { + $assignment->recordAssignmentAttempt($GLOBALS['user']->id); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id', 'access_code'))); + } + + /** + * Only possible if test is exam: Immediately finish working on the exam. + */ + public function finish_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment); + + if ($assignment->checkAccess($GLOBALS['user']->id)) { + if ($assignment->finishAssignmentAttempt($GLOBALS['user']->id)) { + PageLayout::postSuccess(_('Das Aufgabenblatt wurde abgeschlossen, eine weitere Bearbeitung ist nicht mehr möglich.')); + } else { + PageLayout::postError(_('Eine Abgabe ist erst nach Start des Aufgabenblatts möglich.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id'))); + } + + /** + * SHEETS/EXAMS + * + * Is called when the submit button at the bottom of an exercise is called. + * If there is already a solution of this exercise by the same user or same group, + * a dialog pops up to confirm the submission. On database-level: EVERY solution is stored + * (even the unconfirmed ones), with the last solution being marked as last. + */ + public function submit_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + ################################################################## + # in case student solution is submitted by tutor or lecturer # + # (can happen if the student submits his/her solution by email) # + ################################################################## + + $solver_id = Request::option('solver_id'); + + if ($solver_id == '' || !$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + ############################ + # Checks before submission # + ############################ + + if (!$assignment->checkEditPermission()) { + $end = $assignment->getUserEndTime($solver_id); + + // not yet started + if (!$assignment->isStarted()) { + PageLayout::postError(_('Das Aufgabenblatt wurde noch nicht gestartet.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + // already ended + if ($end && time() - $end > 120) { + PageLayout::postError(_('Das Aufgabenblatt wurde bereits beendet.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if (!$assignment->checkIPAccess($_SERVER['REMOTE_ADDR']) || !$assignment->checkAccessCode()) { + PageLayout::postError(_('Kein Zugriff möglich!')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + $assignment->recordAssignmentAttempt($solver_id); + } + + /* if an exercise has been submitted */ + if (Request::submitted('submit_exercise') || Request::int('forced')) { + $request = Request::getInstance(); + $exercise = Exercise::find($exercise_id); + $solution = $exercise->getSolutionFromRequest($request, $_FILES); + $solution->user_id = $solver_id; + + if ($solution->isEmpty()) { + PageLayout::postWarning(_('Ihre Lösung ist leer und wurde nicht gespeichert.')); + } else { + $assignment->storeSolution($solution); + + PageLayout::postSuccess(sprintf(_('Ihre Lösung zur Aufgabe „%s“ wurde gespeichert.'), htmlReady($exercise->title))); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * SHEETS/EXAMS + * + * Displays an exercise (from student perspective) + */ + public function show_exercise_action() + { + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); // solver is handed over via address line, ie. user is a lecturer + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if ($solver_id == '' || !$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + ############################################################## + # check for ip_address, remaining time and interrupted # + ############################################################## + + // restrict access for students! + if (!$assignment->checkEditPermission()) { + // the assignment is not accessible any more after it has run out + if (!$assignment->checkAccess()) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if ($assignment->isFinished($solver_id)) { + PageLayout::postError(_('Die Zeit ist leider abgelaufen!')); + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id'))); + return; + } + + // enter user start time the moment he/she first clicks on any exercise + $assignment->recordAssignmentAttempt($solver_id); + } + + // fetch exercise info, type, points + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise = $exercise_ref->exercise; + + ################################### + # get user solution if applicable # + ################################### + + $solution = $assignment->getSolution($solver_id, $exercise_id); + $max_tries = $assignment->getMaxTries(); + $max_points = $exercise_ref->points; + $exercise_position = $exercise_ref->position; + $show_solution = false; + $tries_left = null; + + // if a solution has been submitted during a selftest + if ($max_tries && $solution) { + $tries_left = $max_tries - $solution->countTries(); + + if ($solution->points == $max_points || !$solution->state || $solution->grader_id || $tries_left <= 0) { + $show_solution = true; + } + } + + ############################## + # set template variables # + ############################## + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->exercise = $exercise; + $this->exercise_id = $exercise_id; + $this->exercise_position = $exercise_position; + + $this->solver_id = $solver_id; + $this->solution = $solution; // can be empty + $this->max_points = $max_points; + $this->show_solution = $show_solution; + $this->tries_left = $tries_left; + $this->user_end_time = $assignment->getUserEndTime($solver_id); + $this->remaining_time = $this->user_end_time - time(); + + $this->contentbar = $this->create_contentbar($assignment, $exercise_id, 'show'); + + $widget = new ActionsWidget(); + + if (($assignment->isResetAllowed() || $assignment->type === 'exam') && $solution) { + $widget->addLink( + _('Lösung dieser Aufgabe löschen'), + $this->url_for('vips/sheets/delete_solution', compact('assignment_id', 'exercise_id', 'solver_id')), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösung dieser Aufgabe wirklich löschen?')] + )->asButton(); + } + + Sidebar::get()->addWidget($widget); + + if ($assignment->checkEditPermission()) { + Helpbar::get()->addPlainText('', + _('Dies ist die Studierendenansicht (Vorschau) der Aufgabe. Sie können hier auch Lösungen von Teilnehmenden ansehen oder für sie abgeben.')); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabe bearbeiten'), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id]) + ); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id]) + )->setActive(); + Sidebar::get()->addWidget($widget); + + if ($assignment->range_type === 'course') { + $widget = new SelectWidget(_('Anzeigen für'), $this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id')), 'solver_id'); + $widget->class = 'nested-select'; + $element = new SelectElement($GLOBALS['user']->id, ' ', $GLOBALS['user']->id == $solver_id); + $widget->addElement($element); + + foreach ($assignment->course->members->findBy('status', 'autor')->orderBy('nachname, vorname') as $member) { + if ($assignment->isVisible($member->user_id)) { + $element = new SelectElement($member->user_id, $member->nachname . ', ' . $member->vorname, $member->user_id == $solver_id); + $widget->addElement($element); + } + } + Sidebar::get()->addWidget($widget); + } + } else { + Helpbar::get()->addPlainText('', + _('Bitte denken Sie daran, vor dem Verlassen der Seite Ihre Lösung zu speichern.')); + } + + $widget = new ViewsWidget(); + $widget->setTitle(_('Aufgabenblatt')); + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + foreach ($assignment->getExerciseRefs($solver_id) as $i => $item) { + $this->item = $item; + $this->position = $i + 1; + $element = new WidgetElement($this->render_template_as_string('vips/sheets/show_exercise_link')); + $element->active = $item->task_id === $exercise->id; + $widget->addElement($element, 'exercise-' . $item->task_id); + } + + setlocale(LC_NUMERIC, 'C'); + + Sidebar::get()->addWidget($widget); + } + + /** + * Displays all running assignments "work-on ready" for students (view of + * students when clicking on tab Uebungsblatt), respectively student view + * for lecturers and tutors. + */ + public function list_assignments_stud_action() + { + $course_id = Context::getId(); + $sort = Request::option('sort', 'start'); + $desc = Request::int('desc'); + VipsModule::requireStatus('autor', $course_id); + + $this->sort = $sort; + $this->desc = $desc; + $this->assignments = []; + + $assignments = VipsAssignment::findByRangeId($course_id); + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + $blocks[] = VipsBlock::build(['name' => _('Aufgabenblätter')]); + $ip_address = $_SERVER['REMOTE_ADDR']; + + usort($assignments, function($a, $b) use ($sort) { + if ($sort === 'title') { + return strcoll($a->test->title, $b->test->title); + } else if ($sort === 'type') { + return strcmp($a->type, $b->type); + } else if ($sort === 'start') { + return strcmp($a->start, $b->start); + } else { + return strcmp($a->end ?: '~', $b->end ?: '~'); + } + }); + + if ($desc) { + $assignments = array_reverse($assignments); + } + + foreach ($blocks as $block) { + $this->blocks[$block->id]['title'] = $block->name; + } + + foreach ($assignments as $assignment) { + if ($assignment->isRunning() && $assignment->isVisible($GLOBALS['user']->id)) { + if ($assignment->checkIPAccess($ip_address)) { + if (isset($assignment->block->group_id)) { + $this->blocks['']['assignments'][] = $assignment; + } else { + $this->blocks[$assignment->block_id]['assignments'][] = $assignment; + } + } + } + } + + // delete empty blocks + foreach ($blocks as $block) { + if (empty($this->blocks[$block->id]['assignments'])) { + unset($this->blocks[$block->id]); + } + } + + $this->user_id = $GLOBALS['user']->id; + } + + /** + * Display one assignment to the student, including the list of exercises. + */ + public function show_assignment_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + $ip_address = $_SERVER['REMOTE_ADDR']; + + VipsModule::requireViewPermission($assignment); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + $this->solver_id = $solver_id; + $this->user_end_time = $assignment->getUserEndTime($solver_id); + $this->remaining_time = $this->user_end_time - time(); + $this->access_code = trim(Request::get('access_code')); + $this->assignment = $assignment; + $this->needs_code = false; + $this->exam_terms = null; + $this->preview_exam_terms = null; + + $this->contentbar = $this->create_contentbar($assignment, null, 'show'); + + if (!$assignment->checkEditPermission()) { + if (!$assignment->isRunning() || !$assignment->active) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if (!$assignment->checkIPAccess($ip_address)) { + PageLayout::postError(sprintf(_('Sie haben mit Ihrer IP-Adresse „%s“ keinen Zugriff!'), htmlReady($ip_address))); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + $this->assignment_attempt = $assignment->getAssignmentAttempt($solver_id); + + if ($assignment->type === 'exam') { + if (!$assignment->checkAccessCode()) { + $this->needs_code = true; + } + + if (!$this->assignment_attempt) { + $this->exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + if ($this->exam_terms || $this->needs_code) { + $this->contentbar = $this->contentbar->withProps(['toc' => null]); + } + } + + $widget = new ActionsWidget(); + + if ($assignment->type !== 'exam') { + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'print_files' => 1]), + Icon::create('print'), + ['target' => '_blank'] + ); + } + if ($assignment->isResetAllowed()) { + $widget->addLink( + _('Lösungen dieses Blatts löschen'), + $this->url_for('vips/sheets/delete_solutions', ['assignment_id' => $assignment_id]), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösungen dieses Aufgabenblatts wirklich löschen?')] + )->asButton(); + } + if ($assignment->type === 'exam' && $this->assignment_attempt && $this->remaining_time > 0) { + $widget->addLink( + _('Klausur vorzeitig abgeben'), + $this->url_for('vips/sheets/finish_assignment', ['assignment_id' => $assignment_id]), + Icon::create('lock-locked'), + ['data-confirm' => _('Achtung: Wenn Sie die Klausur abgeben, sind keine weiteren Eingaben mehr möglich!')] + )->asButton(); + } + if ($assignment->type === 'selftest' && $this->assignment_attempt && $this->assignment_attempt->end === null) { + $widget->addLink( + _('Aufgabenblatt jetzt abgeben'), + $this->url_for('vips/sheets/finish_assignment', ['assignment_id' => $assignment_id]), + Icon::create('lock-locked'), + ['data-confirm' => _('Achtung: Wenn Sie das Aufgabenblatt abgeben, sind keine weiteren Eingaben mehr möglich!')] + )->asButton(); + } + Sidebar::get()->addWidget($widget); + } else { + if ($assignment->type === 'exam') { + $this->preview_exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + Helpbar::get()->addPlainText('', + _('Dies ist die Studierendensicht (Vorschau) des Aufgabenblatts.')); + + $widget = new ActionsWidget(); + + if ($assignment->type !== 'exam') { + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'print_files' => 1, 'user_ids[]' => $solver_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + } + if ($assignment->isResetAllowed()) { + $widget->addLink( + _('Lösungen dieses Blatts löschen'), + $this->url_for('vips/sheets/delete_solutions', ['assignment_id' => $assignment_id, 'solver_id' => $solver_id]), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösungen dieses Aufgabenblatts wirklich löschen?')] + )->asButton(); + } + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabenblatt bearbeiten'), + $this->url_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment_id]) + ); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment_id]) + )->setActive(); + Sidebar::get()->addWidget($widget); + + if ($assignment->range_type === 'course') { + $widget = new SelectWidget(_('Anzeigen für'), $this->url_for('vips/sheets/show_assignment', compact('assignment_id')), 'solver_id'); + $widget->class = 'nested-select'; + $element = new SelectElement($GLOBALS['user']->id, ' ', $GLOBALS['user']->id == $solver_id); + $widget->addElement($element); + + foreach ($assignment->course->members->findBy('status', 'autor')->orderBy('nachname, vorname') as $member) { + if ($assignment->isVisible($member->user_id)) { + $element = new SelectElement($member->user_id, $member->nachname . ', ' . $member->vorname, $member->user_id == $solver_id); + $widget->addElement($element); + } + } + Sidebar::get()->addWidget($widget); + } + } + } + + ##################################### + # # + # Lecturer Methods # + # # + ##################################### + + + /** + * Dialog for confirming the end date of a starting assignment. + */ + public function start_assignment_dialog_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $this->assignment = $assignment; + } + + /** + * EXAMS/SHEETS + * + * If an assignment hasn't started yet this function sets the start time to NOW + * so that it's running + * + */ + public function start_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $end_date = trim(Request::get('end_date')); + $end_time = trim(Request::get('end_time')); + $end_datetime = DateTime::createFromFormat('d.m.Y H:i', $end_date.' '.$end_time); + + // unlimited selftest + if ($assignment->type === 'selftest' && $end_date === '' && $end_time === '') { + $end = null; + } else if ($end_datetime) { + $end = strtotime($end_datetime->format('Y-m-d H:i:s')); + } else { + $end = $assignment->end; + PageLayout::postWarning(_('Ungültiger Endzeitpunkt, der Wert wurde nicht übernommen.')); + } + + // set new start and end time in database + $assignment->start = time(); + $assignment->end = $end; + $assignment->active = 1; + $assignment->store(); + + // delete start time for exam from database + VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$assignment_id]); + + $this->redirect('vips/sheets'); + } + + + /** + * EXAMS/SHEETS + * + * Stops/continues an assignment (no change of start/end time but temporary closure) + * + */ + public function stopgo_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $db = DBManager::get(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->type === 'exam') { + if ($assignment->active) { + $assignment->options['stopdate'] = date('Y-m-d H:i:s'); + } else if ($assignment->options['stopdate']) { + // extend exam duration for already active participants + $interval = time() - strtotime($assignment->options['stopdate']); + $sql = 'UPDATE etask_assignment_attempts SET end = end + ? + WHERE assignment_id = ? AND end > ?'; + $stmt = $db->prepare($sql); + $stmt->execute([$interval, $assignment_id, $assignment->options['stopdate']]); + + unset($assignment->options['stopdate']); + } + } + + $assignment->active = !$assignment->active; + $assignment->store(); + + $this->redirect('vips/sheets'); + } + + + /** + * EXAMS/SHEETS + * + * Deletes an assignment from the course (and block if applicable). + */ + public function delete_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $test_title = $assignment->test->title; + + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $assignment->delete(); + PageLayout::postSuccess(sprintf(_('Das Aufgabenblatt „%s“ wurde gelöscht.'), htmlReady($test_title))); + } + + $this->redirect('vips/sheets'); + } + + /** + * Delete a list of assignments from the course (and block if applicable). + */ + public function delete_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $deleted = 0; + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $assignment->delete(); + ++$deleted; + } + } + + if ($deleted > 0) { + PageLayout::postSuccess(sprintf(_('Es wurden %s Aufgabenblätter gelöscht.'), $deleted)); + } + + if ($deleted < count($assignment_ids)) { + PageLayout::postError(_('Einige Aufgabenblätter konnten nicht gelöscht werden, da bereits Lösungen abgegeben wurden.'), [ + _('Falls Sie diese wirklich löschen möchten, müssen Sie zuerst die Lösungen aller Teilnehmenden zurücksetzen.') + ]); + } + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Dialog for selecting a block for a list of assignments. + */ + public function assign_block_dialog_action() + { + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + } + + /** + * Assign a list of assignments to the specified block. + */ + public function assign_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $block_id = Request::int('block_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$block_id || $block->range_id === $assignment->range_id) { + $assignment->block_id = $block_id ?: null; + $assignment->store(); + } + } + + PageLayout::postSuccess(_('Die Blockzuordnung wurde gespeichert.')); + + $this->redirect('vips/sheets'); + } + + /** + * Dialog for copying a list of assignments into a course. + */ + public function copy_assignments_dialog_action() + { + PageLayout::setTitle(_('Aufgabenblätter kopieren')); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + $this->course_id = Context::getId(); + } + + /** + * Copy the selected assignments into the selected course. + */ + public function copy_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $course_id = Request::option('course_id'); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->copyIntoCourse($course_id); + } else { + $assignment->copyIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde kopiert.', 'Die Aufgabenblätter wurden kopiert.', count($assignment_ids))); + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Dialog for moving a list of assignments to another course. + */ + public function move_assignments_dialog_action() + { + PageLayout::setTitle(_('Aufgabenblätter verschieben')); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + $this->course_id = Context::getId(); + } + + /** + * Move a list of assignments to the specified course. + */ + public function move_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $course_id = Request::option('course_id'); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->moveIntoCourse($course_id); + } else { + $assignment->moveIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde verschoben.', 'Die Aufgabenblätter wurden verschoben.', count($assignment_ids))); + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Delete the solutions of all students and reset the assignment. + */ + public function reset_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->type === 'exam') { + $assignment->deleteAllSolutions(); + PageLayout::postSuccess(_('Die Klausur wurde zurückgesetzt und alle abgegebenen Lösungen archiviert.')); + } + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + + /** + * SHEETS/EXAMS + * + * Takes an exercise off an assignment and deletes it. + */ + public function delete_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $exercise = Exercise::find($exercise_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if (!$assignment->isLocked()) { + $assignment->test->removeExercise($exercise_id); + PageLayout::postSuccess(sprintf(_('Die Aufgabe „%s“ wurde gelöscht.'), htmlReady($exercise->title))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Deletes a list of exercises from a specific assignment. + */ + public function delete_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + if (!$assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + VipsModule::requireEditPermission($assignment, $exercise_id); + $assignment->test->removeExercise($exercise_id); + } + + PageLayout::postSuccess(sprintf(_('Es wurden %s Aufgaben gelöscht.'), count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Reorder exercise positions within an assignment. + */ + public function move_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $list = Request::intArray('item'); + + VipsModule::requireEditPermission($assignment); + + /* renumber all exercises in current assignment */ + foreach ($list as $i => $exercise_id) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + + if ($exercise_ref) { + $exercise_ref->position = $i + 1; + $exercise_ref->store(); + } + } + + $this->render_nothing(); + } + + /** + * SHEETS/EXAMS + * + * Displays the form for editing an exercise. + * + * Is called when editing an existing exercise or creating a new exercise. + */ + public function edit_exercise_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgaben'); + + $exercise_id = Request::int('exercise_id'); // is not set when creating new exercise + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if ($exercise_id) { + // edit already existing exercise + $exercise_ref = $assignment->test->getExerciseRef($exercise_id); + $exercise = $exercise_ref->exercise; + + $max_points = $exercise_ref->points; + $exercise_position = $exercise_ref->position; + } else { + // create new exercise + $exercise_type = Request::option('exercise_type'); + $exercise = new $exercise_type(); + + $max_points = null; + $exercise_position = null; + } + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->exercise = $exercise; + $this->exercise_position = $exercise_position; + $this->max_points = $max_points; + + $this->contentbar = $this->create_contentbar($assignment, $exercise_id); + + Helpbar::get()->addPlainText('', + _('Sie können hier den Aufgabentext und die Antwortoptionen dieser Aufgabe bearbeiten.')); + + $widget = new ActionsWidget(); + + if (!$assignment->isLocked()) { + $widget->addLink( + _('Neue Aufgabe erstellen'), + $this->url_for('vips/sheets/add_exercise_dialog', ['assignment_id' => $assignment_id]), + Icon::create('add') + )->asDialog('size=auto'); + } + + Sidebar::get()->addWidget($widget); + + if ($exercise->id) { + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabe bearbeiten'), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) + )->setActive(); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) + ); + Sidebar::get()->addWidget($widget); + } + + $widget = new ViewsWidget(); + $widget->setTitle(_('Aufgabenblatt')); + + foreach ($assignment->test->exercise_refs as $item) { + $widget->addLink( + sprintf(_('Aufgabe %d'), $item->position), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $item->task_id]) + )->setActive($item->task_id === $exercise->id); + } + + Sidebar::get()->addWidget($widget); + } + + + /** + * SHEETS/EXAMS + * + * Inserts/Updates an exercise into the database + */ + public function store_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); // not set when storing new exercise + $exercise_type = Request::option('exercise_type'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $test_id = $assignment->test_id; + $file_ids = Request::optionArray('file_ids'); + $request = Request::getInstance(); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if ($exercise_id) { + // update existing exercise. + $exercise = Exercise::find($exercise_id); + $item_count = $exercise->itemCount(); + $exercise->initFromRequest($request); + $exercise->store(); + + // update maximum points + if ($exercise->itemCount() != $item_count) { + $exercise_ref = VipsExerciseRef::find([$test_id, $exercise_id]); + $exercise_ref->points = $exercise->itemCount(); + $exercise_ref->store(); + } + } else { + // store exercise in database. + $exercise = new $exercise_type(); + $exercise->initFromRequest($request); + $exercise->user_id = $GLOBALS['user']->id; + $exercise->store(); + + // link new exercise to the assignment. + $assignment->test->addExercise($exercise); + $exercise_id = $exercise->id; + } + + $upload = $_FILES['upload'] ?: ['name' => []]; + $folder = Folder::findTopFolder($exercise->id, 'ExerciseFolder', 'task'); + + foreach ($folder->file_refs as $file_ref) { + if (!in_array($file_ref->id, $file_ids) || in_array($file_ref->name, $upload['name'])) { + $file_ref->delete(); + } + } + + FileManager::handleFileUpload($upload, $folder->getTypedFolder()); + + PageLayout::postSuccess(_('Die Aufgabe wurde eingetragen.')); + + $this->redirect($this->url_for('vips/sheets/edit_exercise', compact('assignment_id', 'exercise_id'))); + } + + /** + * Copy the selected exercises into this assignment. + */ + public function copy_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $exercise_id = Request::int('exercise_id'); + $exercise_ids = $exercise_id ? [$exercise_id => $assignment_id] : Request::intArray('exercise_ids'); + + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $copy_assignment_id) { + $copy_assignment = VipsAssignment::find($copy_assignment_id); + VipsModule::requireEditPermission($copy_assignment); + + $exercise_ref = VipsExerciseRef::find([$copy_assignment->test_id, $exercise_id]); + $exercise_ref->copyIntoTest($assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Dialog for copying a list of exercises to another assignment. + */ + public function copy_exercises_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Copy a list of exercises to the specified assignment. + */ + public function copy_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $target_assignment_id = Request::int('target_assignment_id'); + $exercise_ids = Request::intArray('exercise_ids'); + + $assignment = VipsAssignment::find($assignment_id); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($assignment); + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + $exercise_ref = $assignment->test->getExerciseRef($exercise_id); + $exercise_ref->copyIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Dialog for moving a list of exercises to another assignment. + */ + public function move_exercises_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Move a list of exercises to the specified assignment. + */ + public function move_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $target_assignment_id = Request::int('target_assignment_id'); + $exercise_ids = Request::intArray('exercise_ids'); + + $assignment = VipsAssignment::find($assignment_id); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($assignment); + VipsModule::requireEditPermission($target_assignment); + + if (!$assignment->isLocked() && !$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->moveIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde verschoben.', 'Die Aufgaben wurden verschoben.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * SHEETS/EXAMS + * + * Stores the specification (Grunddaten) of an assignment + * OR add new exercise, edit points/Bewertung (basically everything that can be done on + * page edit_exercise_action()) + */ + public function store_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $db = DBManager::get(); + + $assignment_id = Request::int('assignment_id'); + + if ($assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + } else { + $assignment = new VipsAssignment(); + $assignment->range_id = Context::getId() ?: $GLOBALS['user']->id; + $assignment->range_type = Context::getId() ? 'course' : 'user'; + } + + VipsModule::requireEditPermission($assignment); + + $assignment_name = trim(Request::get('assignment_name')); + $assignment_description = trim(Request::get('assignment_description')); + $assignment_description = Studip\Markup::purifyHtml($assignment_description); + $assignment_notes = trim(Request::get('assignment_notes')); + $assignment_type = Request::option('assignment_type', 'practice'); + $assignment_block = Request::int('assignment_block', 0); + $assignment_block_name = trim(Request::get('assignment_block_name')); + $start_date = trim(Request::get('start_date')); + $start_time = trim(Request::get('start_time')); + $end_date = trim(Request::get('end_date')); + $end_time = trim(Request::get('end_time')); + + $exam_length = Request::int('exam_length'); + $access_code = trim(Request::get('access_code')); + $ip_range = trim(Request::get('ip_range')); + $use_groups = Request::int('use_groups', 0); + $shuffle_answers = Request::int('shuffle_answers', 0); + $shuffle_exercises = Request::int('shuffle_exercises', 0); + $self_assessment = Request::int('self_assessment', 0); + $max_tries = Request::int('max_tries', 0); + $resets = Request::int('resets', 0); + $evaluation_mode = Request::int('evaluation_mode', 0); + $exercise_points = Request::floatArray('exercise_points'); + $selftest_threshold = Request::getArray('threshold'); + $selftest_feedback = Request::getArray('feedback'); + + $start_datetime = DateTime::createFromFormat('d.m.Y H:i', $start_date.' '.$start_time); + $end_datetime = DateTime::createFromFormat('d.m.Y H:i', $end_date.' '.$end_time); + + if ($assignment_name === '') { + $assignment_name = _('Aufgabenblatt'); + } + + if ($start_datetime) { + $start = $start_datetime->format('Y-m-d H:i:s'); + } else { + $start = date('Y-m-d H:00:00'); + PageLayout::postWarning(_('Ungültiger Startzeitpunkt, der Wert wurde nicht übernommen.')); + } + + // unlimited selftest + if ($assignment_type == 'selftest' && $end_date == '' && $end_time == '') { + $end = null; + } else if ($end_datetime) { + $end = $end_datetime->format('Y-m-d H:i:s'); + } else { + $end = date('Y-m-d H:00:00'); + PageLayout::postWarning(_('Ungültiger Endzeitpunkt, der Wert wurde nicht übernommen.')); + } + + if ($end && $end <= $start) { // start is *later* than end! + $end = $start; + PageLayout::postWarning(_('Bitte überprüfen Sie den Start- und den Endzeitpunkt!')); + } + + if ($assignment_block_name != '') { + $block = VipsBlock::create(['name' => $assignment_block_name, 'range_id' => $assignment->range_id]); + } else if ($assignment_block) { + $block = VipsBlock::find($assignment_block); + + if ($block->range_id !== $assignment->range_id) { + $block = null; + } + } else { + $block = null; + } + + foreach ($selftest_threshold as $i => $threshold) { + if ($threshold !== '') { + $feedback[$threshold] = Studip\Markup::purifyHtml($selftest_feedback[$i]); + } + } + + /*** store basic data (Grunddaten) of assignment */ + if ($assignment_id) { + // check whether the exam's start time has been moved + if ($assignment->start != strtotime($start) && time() <= strtotime($start)) { + $assignment->active = 1; + } + + // extend exam duration for already active participants + if ($assignment_type === 'exam' && $assignment->options['duration'] != $exam_length) { + $sql = 'UPDATE etask_assignment_attempts SET end = GREATEST(end + ? * 60, UNIX_TIMESTAMP()) + WHERE assignment_id = ? AND end > UNIX_TIMESTAMP()'; + $stmt = $db->prepare($sql); + $stmt->execute([$exam_length - $assignment->options['duration'], $assignment_id]); + } + + $assignment->test->setData([ + 'title' => $assignment_name, + 'description' => $assignment_description + ]); + $assignment->test->store(); + } else { + $assignment->test = VipsTest::create([ + 'title' => $assignment_name, + 'description' => $assignment_description, + 'user_id' => $GLOBALS['user']->id + ]); + } + + $assignment->setData([ + 'type' => $assignment_type, + 'start' => strtotime($start), + 'end' => $end ? strtotime($end) : null, + 'block_id' => $block ? $block->id : null + ]); + + // update options array + $assignment->options['evaluation_mode'] = $evaluation_mode; + $assignment->options['notes'] = $assignment_notes; + + unset($assignment->options['access_code']); + unset($assignment->options['ip_range']); + unset($assignment->options['shuffle_answers']); + unset($assignment->options['shuffle_exercises']); + unset($assignment->options['self_assessment']); + unset($assignment->options['use_groups']); + unset($assignment->options['max_tries']); + unset($assignment->options['resets']); + unset($assignment->options['feedback']); + + if ($assignment_type === 'exam') { + $assignment->options['duration'] = $exam_length; + + if ($access_code !== '') { + $assignment->options['access_code'] = $access_code; + } + + if ($ip_range !== '') { + $assignment->options['ip_range'] = $ip_range; + } + + $assignment->options['shuffle_answers'] = $shuffle_answers; + + if ($shuffle_exercises === 1) { + $assignment->options['shuffle_exercises'] = $shuffle_exercises; + } + + if ($self_assessment === 1) { + $assignment->options['self_assessment'] = $self_assessment; + } + } + + if ($assignment_type === 'practice') { + $assignment->options['use_groups'] = $use_groups; + } + + if ($assignment_type === 'selftest') { + $assignment->options['max_tries'] = $max_tries; + + if ($resets === 0) { + $assignment->options['resets'] = $resets; + } + + if (isset($feedback)) { + krsort($feedback); + $assignment->options['feedback'] = $feedback; + } + } + + $assignment->store(); + $assignment_id = $assignment->id; + + foreach ($assignment->test->exercise_refs as $exercise_ref) { + $points = $exercise_points[$exercise_ref->task_id]; + $exercise_ref->points = round($points * 2) / 2; + $exercise_ref->store(); + } + + PageLayout::postSuccess(_('Das Aufgabenblatt wurde gespeichert.')); + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Returns the dialog content to create a new exercise. + */ + public function add_exercise_dialog_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgaben'); + + $assignment_id = Request::int('assignment_id'); + + $this->assignment_id = $assignment_id; + $this->exercise_types = Exercise::getExerciseTypes(); + } + + /** + * Returns the dialog content to copy an existing exercise. + */ + public function copy_exercise_dialog_action() + { + $assignment_id = Request::int('assignment_id'); + $search_filter = Request::getArray('search_filter'); + + $sort = Request::option('sort', 'start_time'); + $desc = Request::int('desc', $sort === 'start_time'); + $page = Request::int('page', 1); + $size = 15; + + if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'exercise_type'], ''); + $search_filter['range_type'] = Context::getId() ? 'course' : 'user'; + } + + if ($search_filter['range_type'] === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + $exercises = $this->getAllExercises($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->size = $size; + $this->count = count($exercises); + $this->exercises = array_slice($exercises, $size * ($page - 1), $size); + $this->exercise_types = Exercise::getExerciseTypes(); + $this->assignment_id = $assignment_id; + $this->search_filter = $search_filter; + } + + /** + * Get all matching exercises from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get exercises from + * @param sort sort exercise list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching exercises + */ + public function getAllExercises($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $exercise_type = $search_filter['exercise_type']; + + $sql = "SELECT etask_tasks.*, + etask_assignments.id AS assignment_id, + etask_assignments.range_id, + etask_assignments.range_type, + etask_tests.title AS test_title, + seminare.name AS course_name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tasks + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_tests ON etask_tests.id = etask_test_tasks.test_id + JOIN etask_assignments USING (test_id) + LEFT JOIN seminare ON etask_assignments.range_id = seminare.seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN ('exam', 'practice', 'selftest') " . + ($search_string ? 'AND (etask_tasks.title LIKE :input OR + etask_tasks.description LIKE :input OR + etask_tests.title LIKE :input OR + seminare.name LIKE :input) ' : '') . + ($exercise_type ? 'AND etask_tasks.type = :exercise_type ' : '') . + "ORDER BY :sort :desc, start_time DESC, seminare.name, + etask_tests.mkdate DESC, etask_test_tasks.position"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':exercise_type', $exercise_type); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * SHEETS/EXAMS + * + * Displays form to edit an existing assignment + * + */ + public function edit_assignment_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgabenblatt'); + + $assignment_id = Request::int('assignment_id'); + + if ($assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + $test = $assignment->test; + } else { + $test = new VipsTest(); + $test->title = _('Aufgabenblatt'); + + $assignment = new VipsAssignment(); + $assignment->range_id = Context::getId() ?: $GLOBALS['user']->id; + $assignment->range_type = Context::getId() ? 'course' : 'user'; + $assignment->type = 'practice'; + $assignment->start = strtotime(date('Y-m-d H:00:00')); + $assignment->end = strtotime(date('Y-m-d H:00:00')); + } + + VipsModule::requireEditPermission($assignment); + + if (!isset($assignment->options['feedback'])) { + $assignment->options['feedback'] = ['' => '']; + } + + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$assignment->range_id]); + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->test = $test; + $this->blocks = $blocks; + $this->locked = $assignment_id && $assignment->isLocked(); + $this->exercises = $test->exercises; + $this->assignment_types = VipsAssignment::getAssignmentTypes(); + $this->exam_rooms = Config::get()->VIPS_EXAM_ROOMS; + + $this->contentbar = $this->create_contentbar($assignment); + + Helpbar::get()->addPlainText('', + _('Sie können hier die Grunddaten des Aufgabenblatts verwalten und Aufgaben hinzufügen, bearbeiten oder löschen.') . ' ' . + _('Alle Daten können später geändert oder ergänzt werden.')); + + $widget = new ActionsWidget(); + + if ($assignment_id && !$this->locked) { + $widget->addLink( + _('Neue Aufgabe erstellen'), + $this->url_for('vips/sheets/add_exercise_dialog', compact('assignment_id')), + Icon::create('add') + )->asDialog('size=auto'); + $widget->addLink( + _('Vorhandene Aufgabe kopieren'), + $this->url_for('vips/sheets/copy_exercise_dialog', compact('assignment_id')), + Icon::create('copy') + )->asDialog('size=big'); + } + + if ($assignment_id) { + if ($assignment->range_type === 'course') { + $widget->addLink( + _('Aufgabenblatt korrigieren'), + $this->url_for('vips/solutions/assignment_solutions', ['assignment_id' => $assignment_id]), + Icon::create('accept') + ); + } + + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabenblatt bearbeiten'), + $this->url_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment_id]) + )->setActive(); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment_id]) + ); + Sidebar::get()->addWidget($widget); + + $widget = new ExportWidget(); + $widget->addLink( + _('Aufgabenblatt exportieren'), + $this->url_for('vips/sheets/export_xml', ['assignment_id' => $assignment_id]), + Icon::create('export') + ); + } + + Sidebar::get()->addWidget($widget); + } + + /** + * Show preview of an existing exercise (using print view for now). + */ + public function preview_exercise_action() + { + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + // fetch exercise info + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise = $exercise_ref->exercise; + + $this->assignment = $assignment; + $this->exercise = $exercise; + $this->exercise_position = $exercise_ref->position; + $this->max_points = $exercise_ref->points; + $this->solution = new VipsSolution(); + $this->show_solution = false; + $this->print_correction = false; + $this->user_id = null; + + $this->render_template('vips/exercises/print_exercise'); + } + + /** + * Copy the selected assignments into the current course. + */ + public function copy_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + $assignment_id = Request::int('assignment_id'); + $assignment_ids = $assignment_id ? [$assignment_id] : Request::intArray('assignment_ids'); + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->copyIntoCourse($course_id); + } else { + $assignment->copyIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde kopiert.', 'Die Aufgabenblätter wurden kopiert.', count($assignment_ids))); + + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Imports a test from a text file. + */ + public function import_test_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + if ($_FILES['upload']['name'][0] == '') { + PageLayout::postError(_('Sie müssen eine Datei zum Importieren auswählen.')); + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + return; + } + + $num_assignments = 0; + $num_exercises = 0; + + for ($i = 0; $i < count($_FILES['upload']['name']); ++$i) { + if (!is_uploaded_file($_FILES['upload']['tmp_name'][$i])) { + $message = sprintf(_('Es trat ein Fehler beim Hochladen der Datei „%s“ auf.'), htmlReady($_FILES['upload']['name'][$i])); + PageLayout::postError($message); + continue; + } + + $text = file_get_contents($_FILES['upload']['tmp_name'][$i]); + + if (str_contains($text, '<?xml')) { + $assignment = VipsAssignment::importXML($text, $GLOBALS['user']->id, $course_id); + } else { + // convert from windows-1252 if legacy text format + $text = mb_decode_numericentity(mb_convert_encoding($text, 'UTF-8', 'WINDOWS-1252'), [0x100, 0xffff, 0, 0xffff], 'UTF-8'); + $test_title = trim(basename($_FILES['upload']['name'][$i], '.txt')); + $assignment = VipsAssignment::importText($test_title, $text, $GLOBALS['user']->id, $course_id); + } + + $num_assignments += 1; + $num_exercises += count($assignment->test->exercise_refs); + } + + if ($num_assignments == 1) { + $message = sprintf(ngettext('Das Aufgabenblatt „%s“ mit %d Aufgabe wurde hinzugefügt.', + 'Das Aufgabenblatt „%s“ mit %d Aufgaben wurde hinzugefügt.', $num_exercises), + htmlReady($assignment->test->title), $num_exercises); + PageLayout::postSuccess($message); + } else if ($num_assignments > 1) { + $message = sprintf(_('%1$d Aufgabenblätter mit insgesamt %2$d Aufgaben wurden hinzugefügt.'), $num_assignments, $num_exercises); + PageLayout::postSuccess($message); + } + + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Creates html print view of a sheet/exam (new window) specified by id + */ + public function print_assignments_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment); + + $user_ids = Request::optionArray('user_ids'); + $print_files = Request::int('print_files'); + $print_correction = Request::int('print_correction'); + $print_sample_solution = Request::int('print_sample_solution'); + $print_student_ids = false; + $assignment_data = []; + + if (!$assignment->checkEditPermission()) { + $user_ids = [$GLOBALS['user']->id]; + $released = $assignment->releaseStatus($user_ids[0]); + $print_correction = $released >= VipsAssignment::RELEASE_STATUS_CORRECTIONS; + $print_sample_solution = $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS; + + if ($assignment->type !== 'exam' && $assignment->checkAccess($user_ids[0])) { + $assignment->recordAssignmentAttempt($user_ids[0]); + } else if ($released < VipsAssignment::RELEASE_STATUS_CORRECTIONS) { + PageLayout::postError(_('Kein Zugriff möglich!')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + } + + if ($assignment->range_type === 'course') { + foreach ($assignment->course->getMembersWithStatus('dozent') as $member) { + $lecturers[] = $member->getUserFullname(); + } + + $sem_class = $assignment->course->getSemClass(); + $print_student_ids = !$sem_class['studygroup_mode']; + } + + if ($user_ids) { + foreach ($user_ids as $user_id) { + $group = $assignment->getUserGroup($user_id); + $students = $stud_ids = []; + + if ($group) { + $name = $group->name; + $members = $assignment->getGroupMembers($group); + + usort($members, function($a, $b) { + return strcoll($a->user->getFullName('no_title_rev'), $b->user->getFullName('no_title_rev')); + }); + + foreach ($members as $member) { + $students[] = $member->user->getFullName('no_title'); + $stud_ids[] = $member->user->matriculation_number ?: _('(keine Matrikelnummer)'); + } + } else { + $user = User::find($user_id); + $name = $user->getFullName('no_title_rev'); + $students[] = $user->getFullName('no_title'); + $stud_ids[] = $user->matriculation_number ?: _('(keine Matrikelnummer)'); + } + + $assignment_data[] = [ + 'user_id' => $user_id, + 'students' => $students, + 'stud_ids' => $stud_ids + ]; + } + } else { + $assignment_data[] = [ + 'user_id' => null + ]; + } + + if (count($user_ids) === 1) { + Config::get()->UNI_NAME_CLEAN = $name; + } + + PageLayout::setTitle($assignment->test->title); + $this->set_layout('vips/sheets/print_layout'); + + $this->assignment = $assignment; + $this->user_ids = $user_ids; + $this->lecturers = $lecturers; + $this->print_files = $print_files; + $this->print_correction = $print_correction; + $this->print_sample_solution = $print_sample_solution; + $this->print_student_ids = $print_student_ids; + $this->assignment_data = $assignment_data; + } + + /** + * SHEETS/EXAMS + * + * Main page of sheets/exams. + * Lists all the assignments (sheets or exams) in the course, grouped by "not yet started", + * "running" and "finished". + */ + public function list_assignments_action() + { + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $sort = Request::option('sort', 'start'); + $desc = Request::int('desc'); + $group = isset($_SESSION['group_assignments']) ? $_SESSION['group_assignments'] : 0; + $group = Request::int('group', $group); + + $_SESSION['group_assignments'] = $group; + $running = false; + + ###################################### + # get assignments in this course # + ###################################### + + $assignments = VipsAssignment::findByRangeId($course_id); + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + $blocks[] = VipsBlock::build(['name' => _('Aufgabenblätter ohne Blockzuordnung')]); + + usort($assignments, function($a, $b) use ($sort) { + if ($sort === 'title') { + return strcoll($a->test->title, $b->test->title); + } else if ($sort === 'type') { + return strcmp($a->type, $b->type); + } else if ($sort === 'start') { + return strcmp($a->start, $b->start); + } else { + return strcmp($a->end ?: '~', $b->end ?: '~'); + } + }); + + if ($desc) { + $assignments = array_reverse($assignments); + } + + $plugin_manager = PluginManager::getInstance(); + $courseware = $plugin_manager->getPluginInfo('CoursewareModule'); + $courseware_active = $courseware && $plugin_manager->isPluginActivated($courseware['id'], $course_id); + + if ($group == 2 && $courseware_active) { + $elements = Courseware\StructuralElement::findBySQL('range_id = ?', [$course_id]); + $unassigned = array_column($assignments, 'id'); + + foreach ($elements as $element) { + $assigned = $this->courseware_assignments($element); + $unassigned = array_diff($unassigned, $assigned); + + $assignment_data[] = [ + 'title' => $element->title, + 'assignments' => array_filter($assignments, function($assignment) use ($assigned) { + return in_array($assignment->id, $assigned); + }) + ]; + } + + $assignment_data[] = [ + 'title' => _('Aufgabenblätter ohne Courseware-Einbindung'), + 'assignments' => array_filter($assignments, function($assignment) use ($unassigned) { + return in_array($assignment->id, $unassigned); + }) + ]; + } else if ($group == 1) { + foreach ($blocks as $block) { + $assignment_data[$block->id] = [ + 'title' => $block->name, + 'block' => $block, + 'assignments' => [] + ]; + } + + foreach ($assignments as $assignment) { + $assignment_data[$assignment->block_id]['assignments'][] = $assignment; + } + } else { + $group = 0; + $assignment_data = [ + [ + 'title' => _('Noch nicht gestartete Aufgabenblätter'), + 'assignments' => [] + ], [ + 'title' => _('Laufende Aufgabenblätter'), + 'assignments' => [] + ], [ + 'title' => _('Beendete Aufgabenblätter'), + 'assignments' => [] + ] + ]; + + foreach ($assignments as $assignment) { + if ($assignment->isFinished()) { + $assignment_data[2]['assignments'][] = $assignment; + } else if ($assignment->isRunning()) { + $assignment_data[1]['assignments'][] = $assignment; + } else { + $assignment_data[0]['assignments'][] = $assignment; + } + } + } + + foreach ($assignments as $assignment) { + if ($assignment->isRunning()) { + $running = true; + } + } + + $this->assignment_data = $assignment_data; + $this->num_assignments = count($assignments); + $this->sort = $sort; + $this->desc = $desc; + $this->group = $group; + $this->blocks = $blocks; + + Helpbar::get()->addPlainText('', + _('Hier können Ãœbungen, Tests und Klausuren online vorbereitet und durchgeführt werden. Sie erhalten ' . + 'dabei auch eine Ãœbersicht über die Lösungen bzw. Antworten der Studierenden.') . "\n\n" . + _('Auf dieser Seite können Sie Aufgabenblätter in Ihrem Kurs anlegen und verwalten.')); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt erstellen'), + $this->url_for('vips/sheets/edit_assignment'), + Icon::create('add') + ); + $widget->addLink( + _('Aufgabenblatt kopieren'), + $this->url_for('vips/sheets/copy_assignment_dialog'), + Icon::create('copy') + )->asDialog('size=1200x800'); + $widget->addLink( + _('Aufgabenblatt importieren'), + $this->url_for('vips/sheets/import_assignment_dialog'), + Icon::create('import') + )->asDialog('size=auto'); + $widget->addLink( + _('Neuen Block erstellen'), + $this->url_for('vips/admin/edit_block'), + Icon::create('add') + )->asDialog('size=auto'); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Gruppiert nach Status'), + $this->url_for('vips/sheets', ['group' => 0]) + )->setActive($group == 0); + $widget->addLink( + _('Gruppiert nach Blöcken'), + $this->url_for('vips/sheets', ['group' => 1]) + )->setActive($group == 1); + + if ($courseware_active) { + $widget->addLink( + _('Verwendung in Courseware'), + $this->url_for('vips/sheets', ['group' => 2]) + )->setActive($group == 2); + } + + Sidebar::get()->addWidget($widget); + } + + /** + * Collect all assignment_ids used in the given Courseware element. + */ + private function courseware_assignments($element) + { + $result = []; + + foreach ($element->containers as $container) { + foreach ($container->blocks as $block) { + if ($block->block_type === 'test') { + $payload = json_decode($block->payload, true); + + if ($payload['assignment']) { + $result[] = $payload['assignment']; + } + } + } + } + + return $result; + } + + /** + * Returns the dialog content to import an assignment from text file. + */ + public function import_assignment_dialog_action() + { + } + + /** + * Returns the dialog content to copy available assignments. + */ + public function copy_assignment_dialog_action() + { + $search_filter = Request::getArray('search_filter'); + + $sort = Request::option('sort', 'start_time'); + $desc = Request::int('desc', $sort === 'start_time'); + $page = Request::int('page', 1); + $size = 15; + + if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'assignment_type'], ''); + $search_filter['range_type'] = Context::getId() ? 'course' : 'user'; + } + + if ($search_filter['range_type'] === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + $assignments = $this->getAllAssignments($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->size = $size; + $this->count = count($assignments); + $this->assignments = array_slice($assignments, $size * ($page - 1), $size); + $this->assignment_types = VipsAssignment::getAssignmentTypes(); + $this->search_filter = $search_filter; + } + + /** + * Get all matching assignments from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param array $course_ids list of courses to get assignments from + * @param string $sort sort assignment list by this property + * @param bool $desc true if sort direction is descending + * @param array $search_filter the currently active search filter + * + * @return array with data of all matching assignments + */ + public function getAllAssignments(array $course_ids, string $sort, bool $desc, array $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $assignment_type = $search_filter['assignment_type']; + $types = $assignment_type ? [$assignment_type] : ['exam', 'practice', 'selftest']; + + $sql = "SELECT etask_assignments.*, + etask_tests.title AS test_title, + seminare.name AS course_name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tests + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + LEFT JOIN seminare ON etask_assignments.range_id = seminare.seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN (:types) " . + ($search_string ? 'AND (etask_tests.title LIKE :input OR + etask_tests.description LIKE :input OR + seminare.name LIKE :input) ' : '') . + "ORDER BY :sort :desc, start_time DESC, seminare.name, + etask_tests.mkdate DESC"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':types', $types); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Exports all exercises in this assignment in Vips XML format. + */ + public function export_xml_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $this->set_content_type('text/xml; charset=UTF-8'); + header('Content-Disposition: attachment; ' . encode_header_parameter('filename', $assignment->test->title.'.xml')); + + $this->render_text($assignment->exportXML()); + } + + public function relay_action($action) + { + $params = func_get_args(); + $params[0] = $this; + $exercise_id = Request::int('exercise_id'); + $exercise = Exercise::find($exercise_id); + $action = $action . '_action'; + + $this->exercise = $exercise; + + if (method_exists($exercise, $action)) { + call_user_func_array([$exercise, $action], $params); + } else { + throw new InvalidArgumentException(get_class($exercise) . '::' . $action); + } + } + + /** + * Create a ContentBar for this assignment (if no exercise is specified) + * or for the given exercise on the assignment. + */ + public function create_contentbar( + VipsAssignment $assignment, + ?int $exercise_id = null, + string $view = 'edit', + ?string $solver_id = null + ) { + $toc = new TOCItem($assignment->test->title); + $toc->setURL($this->url_for("vips/sheets/{$view}_assignment", ['assignment_id' => $assignment->id])); + $toc->setActive($exercise_id === null); + + if ($view === 'edit') { + $exercise_refs = $assignment->test->exercise_refs; + } else { + $exercise_refs = $assignment->getExerciseRefs($solver_id); + } + + foreach ($exercise_refs as $i => $item) { + $child = new TOCItem(sprintf('%d. %s', $i + 1, $item->exercise->title)); + $child->setURL($this->url_for( + "vips/sheets/{$view}_exercise", + ['assignment_id' => $assignment->id, 'exercise_id' => $item->task_id, 'solver_id' => $solver_id] + )); + + $child->setActive($item->task_id == $exercise_id); + $toc->children[] = $child; + } + + foreach ($toc->children as $i => $item) { + if ($item->isActive()) { + $icons = $this->get_template_factory()->open('vips/sheets/content_bar_icons'); + + if ($i > 0) { + $icons->prev_exercise_url = $toc->children[$i - 1]->getURL(); + } + + if ($i < count($toc->children) - 1) { + $icons->next_exercise_url = $toc->children[$i + 1]->getURL(); + } + } + } + + return Studip\VueApp::create('ContentBar')->withProps([ + 'isContentBar' => true, + 'toc' => $toc + ])->withComponent( + 'ContentBarBreadcrumbs' + )->withSlot( + 'breadcrumb-list', sprintf("<content-bar-breadcrumbs :toc='%s'/>", json_encode($toc)) + )->withSlot( + 'buttons-left', $icons ?? '' + ); + } + + /** + * Return the appropriate CSS class for sortable column (if any). + * + * @param boolean $sort sort by this column + * @param boolean $desc set sort direction + */ + public function sort_class(bool $sort, ?bool $desc): string + { + return $sort ? ($desc ? 'sortdesc' : 'sortasc') : ''; + } + + /** + * Render a generic page chooser selector. The first occurence of '%d' + * in the URL is replaced with the selected page number. + * + * @param string $url URL for one of the pages + * @param string $count total number of entries + * @param string $page current page to display + * @param string|null $dialog Optional dialog attribute content + * @param int|null $page_size page size (defaults to system default) + * @return mixed + */ + public function page_chooser(string $url, string $count, string $page, ?string $dialog = null, ?int $page_size = null) + { + $template = $GLOBALS['template_factory']->open('shared/pagechooser'); + $template->dialog = $dialog; + $template->num_postings = $count; + $template->page = $page; + $template->perPage = $page_size ?: Config::get()->ENTRIES_PER_PAGE; + $template->pagelink = str_replace('%%25d', '%d', str_replace('%', '%%', $url)); + + return $template->render(); + } +} diff --git a/app/controllers/vips/solutions.php b/app/controllers/vips/solutions.php new file mode 100644 index 0000000000000000000000000000000000000000..ca46922cbe90a20560d3dae5efad8b1802995887 --- /dev/null +++ b/app/controllers/vips/solutions.php @@ -0,0 +1,2521 @@ +<?php +/** + * vips/solutions.php - assignment solutions controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_SolutionsController extends AuthenticatedController +{ + /** + * Return the default action and arguments + * + * @return array containing the action, an array of args and the format + */ + public function default_action_and_args() + { + return ['assignments', [], null]; + } + + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + Navigation::activateItem('/course/vips/solutions'); + PageLayout::setHelpKeyword('Basis.VipsErgebnisse'); + PageLayout::setTitle(PageLayout::getTitle() . ' - ' . _('Ergebnisse')); + } + + /** + * Displays all exercise sheets. + * Lecturer can select what sheet to correct. + */ + public function assignments_action() + { + $sort = Request::option('sort', 'start'); + $desc = Request::int('desc'); + $course_id = Context::getId(); + VipsModule::requireStatus('autor', $course_id); + + $this->sort = $sort; + $this->desc = $desc; + $this->course_id = $course_id; + $this->user_id = $GLOBALS['user']->id; + $this->test_data = $this->get_assignments_data($course_id, $this->user_id, $sort, $desc); + $this->blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + $this->blocks[] = VipsBlock::build(['name' => _('Aufgabenblätter ohne Blockzuordnung')]); + + foreach ($this->test_data['assignments'] as $assignment) { + $this->block_assignments[$assignment['assignment']->block_id][] = $assignment; + } + + $this->use_weighting = false; + + foreach ($this->blocks as $block) { + if ($block->weight !== null) { + if ($block->weight) { + $this->use_weighting = true; + } + } else if (isset($this->block_assignments[$block->id])) { + foreach ($this->block_assignments[$block->id] as $ass) { + if ($ass['assignment']->weight) { + $this->use_weighting = true; + } + } + } + } + + $settings = CourseConfig::get($course_id); + + // display course results if grades are defined for this course + if (!VipsModule::hasStatus('tutor', $course_id) && $settings->VIPS_COURSE_GRADES) { + $assignments = VipsAssignment::findBySQL("range_id = ? AND type IN ('exam', 'practice')", [$course_id]); + $show_overview = true; + + // find unreleased or unfinished assignments + foreach ($assignments as $assignment) { + if (!$this->use_weighting || $assignment->weight || $assignment->block_id && $assignment->block->weight) { + if ( + $assignment->isVisible($this->user_id) + && $assignment->releaseStatus($this->user_id) == VipsAssignment::RELEASE_STATUS_NONE + ) { + $show_overview = false; + } + } + } + + // if all assignments are finished and released + if ($show_overview) { + $this->overview_data = $this->participants_overview_data($course_id, $this->user_id); + } + } + + if (VipsModule::hasStatus('tutor', $course_id)) { + Helpbar::get()->addPlainText('', + _('Hier finden Sie eine Ãœbersicht über den Korrekturstatus Ihrer Aufgabenblätter und können Aufgaben korrigieren. ' . + 'Außerdem können Sie hier die Einstellungen für die Notenberechnung in Ihrem Kurs vornehmen.')); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Notenverteilung festlegen'), + $this->url_for('vips/admin/edit_grades'), + Icon::create('graph') + )->asDialog('size=auto'); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Ergebnisse'), + $this->url_for('vips/solutions') + )->setActive(); + $widget->addLink( + _('Punkteübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'points']) + ); + $widget->addLink( + _('Notenübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'weighting']) + ); + $widget->addLink( + _('Statistik'), + $this->url_for('vips/solutions/statistics') + ); + Sidebar::get()->addWidget($widget); + } + } + + /** + * Changes which correction information is released to the student (either + * nothing or only the points or points and correction). + */ + public function update_released_dialog_action() + { + PageLayout::setTitle(_('Freigabe für Studierende')); + + $this->assignment_ids = Request::intArray('assignment_ids'); + + foreach ($this->assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $released = $assignment->options['released']; + $default = isset($default) ? ($released === $default ? $default : -1) : $released; + + if ($assignment->type === 'exam') { + $this->exam_options = true; + } + } + + $this->default = $default; + } + + /** + * Changes which correction information is released to the student (either + * nothing or only the points or points and correction). + */ + public function update_released_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $released = Request::int('released'); + + if (isset($released)) { + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $assignment->options['released'] = $released; + $assignment->store(); + } + + PageLayout::postSuccess(_('Die Freigabeeinstellungen wurden geändert.')); + } + + $this->redirect('vips/solutions'); + } + + /** + * Changes which correction information is released to the student (either + * nothing or only the points or points and correction). + */ + public function change_released_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $assignment->options['released'] = Request::int('released'); + $assignment->store(); + + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id'))); + } + + /** + * Shows solution points for each student/group with a link to view solution and correct it. + */ + public function assignment_solutions_action() + { + PageLayout::setHelpKeyword('Basis.VipsKorrektur'); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $format = Request::option('format'); + + VipsModule::requireEditPermission($assignment); + + $view = Request::option('view'); + $expand = Request::option('expand'); + + // fetch info about assignment + $end = $assignment->end; + $duration = $assignment->options['duration']; + $released = $assignment->options['released']; + + // fetch solvers, exercises and solutions // + $arrays = $this->get_solutions($assignment, $view); + $solvers = $arrays['solvers']; + $exercises = $arrays['exercises']; + $solutions = $arrays['solutions']; + + if ($assignment->type === 'exam') { + $all_solvers = $solvers; + $solvers = []; + $started = []; + + // find all user ids which have an entry in etask_assignment_attempts + foreach ($assignment->assignment_attempts as $attempt) { + $start = $attempt->start; + $user_end = $attempt->end ? $attempt->end : $start + $duration * 60; + $user_end = min($end, $user_end); + $remaining_time = ceil(($user_end - time()) / 60); + + $started[$attempt->user_id] = [ + 'start' => $start, + 'remaining' => $remaining_time, + 'ip' => $attempt->ip_address + ]; + } + + // remove users which are not shown + foreach ($all_solvers as $solver) { + $user_id = $solver['id']; + + if (isset($started[$user_id])) { + $remaining = $started[$user_id]['remaining']; + + if ($view === 'working' && $remaining > 0 || $view == '' && $remaining <= 0) { + // working or finished + $solvers[$user_id] = $all_solvers[$user_id]; + $solvers[$user_id]['running_info'] = $started[$user_id]; + } + } else if ($view === 'pending') { + if ($assignment->isVisible($user_id)) { + // not yet started + $solvers[$user_id] = $all_solvers[$user_id]; + } + } + } + } + + /* reached points, uncorrected solutions and unanswered exercises */ + + $overall_uncorrected_solutions = 0; + $first_uncorrected_solution = null; + + foreach ($solvers as $solver_id => $solver) { + $extra_info = [ + 'points' => 0, + 'progress' => 0, + 'uncorrected' => 0, + 'unanswered' => count($exercises), + 'files' => 0 + ]; + + if (isset($solutions[$solver_id])) { + foreach ($solutions[$solver_id] as $solution) { + $extra_info['points'] += $solution['points']; + $extra_info['progress'] += $exercises[$solution['exercise_id']]['points']; + $extra_info['uncorrected'] += $solution['corrected'] ? 0 : 1; + $extra_info['unanswered'] -= 1; + $extra_info['files'] += $solution['uploads']; + + if (!$solution['corrected']) { + if (!isset($first_uncorrected_solution)) { + $first_uncorrected_solution = [ + 'solver_id' => $solver['user_id'], + 'exercise_id' => $solution['exercise_id'], + ]; + } + } + } + } + + $overall_uncorrected_solutions += $extra_info['uncorrected']; + $solvers[$solver_id]['extra_info'] = $extra_info; + } + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->view = $view; + $this->expand = $expand; + $this->solutions = $solutions; + $this->solvers = $solvers; + $this->exercises = $exercises; + $this->overall_max_points = $assignment->test->getTotalPoints(); + $this->overall_uncorrected_solutions = $overall_uncorrected_solutions; + $this->first_uncorrected_solution = $first_uncorrected_solution; + + if ($format === 'csv') { + $columns = [_('Teilnehmende')]; + + foreach ($exercises as $exercise) { + $columns[] = $exercise['position'] . '. ' . $exercise['title']; + } + + $columns[] = _('Summe'); + + $data = [$columns]; + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + $row = [_('Maximalpunktzahl:')]; + + foreach ($exercises as $exercise) { + $row[] = sprintf('%g', $exercise['points']); + } + + $row[] = sprintf('%g', $this->overall_max_points); + + $data[] = $row; + + foreach ($solvers as $solver) { + $row = [$solver['name']]; + + foreach ($exercises as $exercise) { + if (isset($solutions[$solver['id']][$exercise['id']])) { + $row[] = sprintf('%g', $solutions[$solver['id']][$exercise['id']]['points']); + } else { + $row[] = ''; + } + } + + $row[] = sprintf('%g', $solver['extra_info']['points']); + + $data[] = $row; + } + + setlocale(LC_NUMERIC, 'C'); + + $this->render_csv($data, $assignment->test->title . '.csv'); + } else { + Helpbar::get()->addPlainText('', + _('In dieser Ãœbersicht können Sie sich anzeigen lassen, welche Teilnehmenden Lösungen abgegeben haben, und diese Lösungen korrigieren und freigeben.')); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt bearbeiten'), + $this->url_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment_id]), + Icon::create('edit') + ); + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + $widget->addLink( + _('Autokorrektur starten'), + $this->url_for('vips/solutions/autocorrect_dialog', compact('assignment_id', 'expand', 'view')), + Icon::create('accept') + )->asDialog('size=auto'); + + if ($assignment->type === 'exam') { + $widget->addLink( + _('Alle Lösungen zurücksetzen'), + $this->url_for('vips/solutions/delete_solutions', compact('assignment_id', 'view') + ['solver_id' => 'all']), + Icon::create('refresh'), + ['data-confirm' => _('Achtung: Wenn Sie die Lösungen zurücksetzen, werden die Lösungen aller Teilnehmenden archiviert!')] + )->asButton(); + } + + $plugin_manager = PluginManager::getInstance(); + $gradebook = $plugin_manager->getPluginInfo('GradebookModule'); + + if ($gradebook && $plugin_manager->isPluginActivated($gradebook['id'], $assignment->range_id)) { + if ($assignment->options['gradebook_id']) { + $widget->addLink( + _('Gradebook-Eintrag entfernen'), + $this->url_for('vips/solutions/gradebook_unpublish', compact('assignment_id', 'expand', 'view')), + Icon::create('assessment'), + ['data-confirm' => _('Eintrag aus dem Gradebook löschen?')] + )->asButton(); + } else { + $widget->addLink( + _('Eintrag im Gradebook anlegen'), + $this->url_for('vips/solutions/gradebook_dialog', compact('assignment_id', 'expand', 'view')), + Icon::create('assessment') + )->asDialog('size=auto'); + } + } + + Sidebar::get()->addWidget($widget); + + $widget = new ExportWidget(); + $widget->addLink( + _('Punktetabelle exportieren'), + $this->url_for('vips/solutions/assignment_solutions', ['assignment_id' => $assignment_id, 'format' => 'csv']), + Icon::create('export') + ); + + if ($assignment->type === 'exam') { + $widget->addLink( + _('Abgabeprotokolle exportieren'), + $this->url_for('vips/solutions/assignment_logs', ['assignment_id' => $assignment_id]), + Icon::create('export') + ); + } + + $widget->addLink( + _('Lösungen der Teilnehmenden exportieren'), + $this->url_for('vips/solutions/download_responses', ['assignment_id' => $assignment_id]), + Icon::create('export') + ); + $widget->addLink( + _('Abgegebene Dateien herunterladen'), + $this->url_for('vips/solutions/download_all_uploads', ['assignment_id' => $assignment_id]), + Icon::create('export') + ); + Sidebar::get()->addWidget($widget); + + $widget = new OptionsWidget(); + $widget->setTitle(_('Freigabe für Studierende')); + $widget->addRadioButton( + _('Nichts'), + $this->url_for('vips/solutions/change_released', [ + 'assignment_id' => $assignment_id, + 'released' => VipsAssignment::RELEASE_STATUS_NONE, + ]), + $released == VipsAssignment::RELEASE_STATUS_NONE + ); + $widget->addRadioButton( + _('Vergebene Punkte'), + $this->url_for('vips/solutions/change_released', [ + 'assignment_id' => $assignment_id, + 'released' => VipsAssignment::RELEASE_STATUS_POINTS, + ]), + $released == VipsAssignment::RELEASE_STATUS_POINTS + ); + $widget->addRadioButton( + _('Punkte und Kommentare'), + $this->url_for('vips/solutions/change_released', [ + 'assignment_id' => $assignment_id, + 'released' => VipsAssignment::RELEASE_STATUS_COMMENTS, + ]), + $released == VipsAssignment::RELEASE_STATUS_COMMENTS + ); + $widget->addRadioButton( + _('… zusätzlich Aufgaben und Korrektur'), + $this->url_for('vips/solutions/change_released', [ + 'assignment_id' => $assignment_id, + 'released' => VipsAssignment::RELEASE_STATUS_CORRECTIONS, + ]), + $released == VipsAssignment::RELEASE_STATUS_CORRECTIONS + ); + $widget->addRadioButton( + _('… zusätzlich Musterlösungen'), + $this->url_for('vips/solutions/change_released', [ + 'assignment_id' => $assignment_id, + 'released' => VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS, + ]), + $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS + ); + Sidebar::get()->addWidget($widget); + } + } + + /** + * Download responses to all exercises for all users in an assignment. + */ + public function download_responses_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $arrays = $this->get_solutions($assignment, null); + $columns = [_('Teilnehmende')]; + $exercises = []; + $item_count = []; + + foreach ($arrays['exercises'] as $exercise) { + $exercises[$exercise['id']] = Exercise::find($exercise['id']); + $item_count[$exercise['id']] = $exercises[$exercise['id']]->itemCount(); + + for ($i = 0; $i < $item_count[$exercise['id']]; ++$i) { + if ($i === 0) { + $columns[] = $exercise['position'] . '. ' . $exercise['title']; + } else { + $columns[] = ''; + } + } + + if ($exercises[$exercise['id']]->options['comment']) { + $columns[] = _('Bemerkungen'); + } + } + + $data = [$columns]; + + foreach ($arrays['solvers'] as $solver) { + $row = [$solver['name']]; + + if (isset($arrays['solutions'][$solver['id']])) { + $solutions = $arrays['solutions'][$solver['id']]; + + foreach ($arrays['exercises'] as $exercise) { + $vips_solution = null; + $export = []; + + if (isset($solutions[$exercise['id']])) { + $vips_solution = VipsSolution::find($solutions[$exercise['id']]['id']); + $export = $exercises[$exercise['id']]->exportResponse($vips_solution->response); + } + + for ($i = 0; $i < $item_count[$exercise['id']]; ++$i) { + $row[] = isset($export[$i]) ? $export[$i] : ''; + } + + if ($exercises[$exercise['id']]->options['comment']) { + $row[] = $vips_solution ? $vips_solution->student_comment : ''; + } + } + + $data[] = $row; + } + } + + $this->render_csv($data, $assignment->test->title . '.csv'); + } + + /** + * Download uploads to current solutions for all users in an assignment. + */ + public function download_all_uploads_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $sem_class = $assignment->course->getSemClass(); + $filename = $assignment->test->title . '.zip'; + $zipfile = tempnam($GLOBALS['TMP_PATH'], 'upload'); + $zip = new ZipArchive(); + + if (!$zip->open($zipfile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { + throw new Exception(_('ZIP-Archiv konnte nicht erzeugt werden.')); + } + + $arrays = $this->get_solutions($assignment, null); + + foreach ($arrays['solvers'] as $solver) { + foreach ($arrays['exercises'] as $exercise) { + $solution = $arrays['solutions'][$solver['id']][$exercise['id']]; // may be null + $folder = $solver['name']; + + if ($solver['type'] === 'single' && !$sem_class['studygroup_mode']) { + $folder .= sprintf(' (%s)', $solver['stud_id'] ?: $solver['username']); + } + + if ($solution && $solution['uploads']) { + foreach (VipsSolution::find($solution['id'])->folder->file_refs as $file_ref) { + $zip->addFile($file_ref->file->getPath(), sprintf(_('%s/Aufgabe %d/'), $folder, $exercise['position']) . $file_ref->name); + } + } + } + } + + $zip->close(); + + if (!file_exists($zipfile)) { + file_put_contents($zipfile, base64_decode('UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==')); + } + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; ' . encode_header_parameter('filename', $filename)); + header('Content-Length: ' . filesize($zipfile)); + + readfile($zipfile); + unlink($zipfile); + die(); + } + + /** + * Download uploads to current solutions for a user in an assignment. + */ + public function download_uploads_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); + + VipsModule::requireEditPermission($assignment); + + $group = $assignment->getUserGroup($solver_id); + $solver_name = $group ? $group->name : get_username($solver_id); + + $filename = $assignment->test->title . '-' . $solver_name . '.zip'; + $zipfile = tempnam($GLOBALS['TMP_PATH'], 'upload'); + $zip = new ZipArchive(); + + if (!$zip->open($zipfile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { + throw new Exception(_('ZIP-Archiv konnte nicht erzeugt werden.')); + } + + foreach ($assignment->test->exercises as $i => $exercise) { + $solution = $assignment->getSolution($solver_id, $exercise->id); + + if ($solution) { + foreach ($solution->folder->file_refs as $file_ref) { + $zip->addFile($file_ref->file->getPath(), sprintf(_('Aufgabe %d/'), $i + 1) . $file_ref->name); + } + } + } + + $zip->close(); + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; ' . encode_header_parameter('filename', $filename)); + header('Content-Length: ' . filesize($zipfile)); + + readfile($zipfile); + unlink($zipfile); + die(); + } + + /** + * Show dialog for publishing the assignment in the gradebook. + */ + public function gradebook_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->assignment = VipsAssignment::find($this->assignment_id); + $this->view = Request::option('view'); + $this->expand = Request::option('expand'); + + VipsModule::requireEditPermission($this->assignment); + + $definitions = Grading\Definition::findByCourse_id($this->assignment->range_id); + $this->weights = array_sum(array_column($definitions, 'weight')); + } + + /** + * Publish this assignment in the gradebook of the course. + */ + public function gradebook_publish_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $view = Request::option('view'); + $expand = Request::option('expand'); + $title = Request::get('title'); + $weight = Request::float('weight'); + + VipsModule::requireEditPermission($assignment); + + $assignment->insertIntoGradebook($title, $weight); + $assignment->updateGradebookEntries(); + + PageLayout::postSuccess(_('Das Aufgabenblatt wurde in das Gradebook eingetragen.')); + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view', 'expand'))); + } + + /** + * Remove this assignment from the gradebook of the course. + */ + public function gradebook_unpublish_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $view = Request::option('view'); + $expand = Request::option('expand'); + + VipsModule::requireEditPermission($assignment); + + $assignment->removeFromGradebook(); + + PageLayout::postSuccess(_('Das Aufgabenblatt wurde aus dem Gradebook gelöscht.')); + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view', 'expand'))); + } + + /** + * Download a summary of the event logs for an assignment. + */ + public function assignment_logs_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $columns = [_('Nachname'), _('Vorname'), _('Kennung'), _('Ereignis'), + _('Zeit'), _('IP-Adresse'), _('Rechnername'), _('Sitzungs-ID')]; + $data = []; + + foreach ($assignment->assignment_attempts as $assignment_attempt) { + foreach ($assignment_attempt->getLogEntries() as $entry) { + $data[] = [ + $assignment_attempt->user->nachname, + $assignment_attempt->user->vorname, + $assignment_attempt->user->username, + $entry['label'], + $entry['time'], + $entry['ip_address'], + $entry['ip_address'] ? $this->gethostbyaddr($entry['ip_address']) : '', + $entry['session_id'] + ]; + } + } + + usort($data, function($a, $b) { + return strcoll("{$a[0]},{$a[1]},{$a[2]},{$a[4]}", "{$b[0]},{$b[1]},{$b[2]},{$b[4]}"); + }); + + array_unshift($data, $columns); + + $this->render_csv($data, $assignment->test->title . '_log.csv'); + } + + + + /******************************************************************************/ + /* + /* A U T O K O R R E K T U R + /* + /******************************************************************************/ + + /** + * Select options and run automatic correction of solutions. + */ + public function autocorrect_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->view = Request::option('view'); + $this->expand = Request::option('expand'); + } + + /** + * Deletes all solution-points, where the solutions are automatically corrected + */ + public function autocorrect_solutions_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $view = Request::option('view'); + $expand = Request::option('expand'); + $corrected = Request::int('corrected', 0); + + VipsModule::requireEditPermission($assignment); + + $corrected_solutions = 0; + + // select all solutions not manually corrected + $solutions = $assignment->solutions->findBy('grader_id', null); + + foreach ($solutions as $solution) { + $assignment->correctSolution($solution, $corrected); + $solution->store(); + + if ($solution->state) { + ++$corrected_solutions; + } + } + + $message = sprintf(ngettext('Es wurde %d Lösung korrigiert.', 'Es wurden %d Lösungen korrigiert.', $corrected_solutions), $corrected_solutions); + PageLayout::postSuccess($message); + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view', 'expand'))); + } + + /** + * Display form that allows lecturer to correct the student's solution. + */ + public function edit_solution_action() + { + PageLayout::setHelpKeyword('Basis.VipsKorrektur'); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + $archived_id = Request::int('solution_id'); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + $group = $assignment->getUserGroup($solver_id); + $solver_name = $group ? $group->name : get_fullname($solver_id, 'no_title_rev'); + $solver_or_group_id = $group ? $group->id : $solver_id; + + // fetch solvers, exercises and solutions // + + $arrays = $this->get_solutions($assignment, $view); + $solvers = $arrays['solvers']; + $exercises = $arrays['exercises']; + $solutions = $arrays['solutions']; + + // previous and next solver, exercise and uncorrected exercise // + + $prev_solver = null; + $prev_exercise = null; + $next_solver = null; + $next_exercise = null; + $next_uncorrected_exercise = null; + $before_current = true; // before current solver + current exercise + + foreach ($solvers as $solver) { + foreach ($exercises as $exercise) { + // current solver and current exercise + if ($solver['id'] == $solver_or_group_id && $exercise['id'] == $exercise_id) { + $before_current = false; + $exercise_position = $exercise['position']; + $max_points = $exercise['points']; + continue; + } + + if (isset($solutions[$solver['id']][$exercise['id']])) { + // previous/next solver (same exercise) + if ($solver['id'] != $solver_or_group_id && $exercise['id'] == $exercise_id) { + if ($before_current) { + $prev_solver = $solver; + } else if (!isset($next_solver)) { + $next_solver = $solver; + } + } + + // previous/next exercise (same solver) + if ($solver['id'] == $solver_or_group_id && $exercise['id'] != $exercise_id) { + if ($before_current) { + $prev_exercise = $exercise; + } else if (!isset($next_exercise)) { + $next_exercise = $exercise; + } + } + + // previous/next uncorrected exercise + if (!$solutions[$solver['id']][$exercise['id']]['corrected']) { + if ($before_current) { + $prev_uncorrected_exercise = [ + 'id' => $exercise['id'], + 'solver_id' => $solver['user_id'] + ]; + } else if (!isset($next_uncorrected_exercise)) { + $next_uncorrected_exercise = [ + 'id' => $exercise['id'], + 'solver_id' => $solver['user_id'] + ]; + } + } + + // break condition + if (isset($next_uncorrected_exercise) && isset($next_solver)) { + break 2; + } + } + } + } + + ################################### + # get user solution if applicable # + ################################### + + $exercise = Exercise::find($exercise_id); + $solution = $assignment->getSolution($solver_id, $exercise_id); + $solution_archive = $assignment->getArchivedSolutions($solver_id, $exercise_id); + + if (!$solution) { + $solution = new VipsSolution(); + $solution->assignment = $assignment; + $version = _('Nicht abgegeben'); + } else { + $version = date('d.m.Y, H:i', $solution->mkdate); + } + + if ($assignment->type !== 'selftest' && !isset($solution->feedback)) { + $solution->feedback = $exercise->options['feedback']; + } + + if ($archived_id) { + foreach ($solution_archive as $old_solution) { + if ($old_solution->id == $archived_id) { + $solution = $old_solution; + break; + } + } + } + + ############################## + # set template variables # + ############################## + + $this->exercise = $exercise; + $this->exercise_id = $exercise_id; + $this->exercise_position = $exercise_position; + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->solver_id = $solver_id; + $this->solver_name = $solver_name; + $this->solver_or_group_id = $solver_or_group_id; + $this->solution = $solution; + $this->edit_solution = !$archived_id; + $this->show_solution = true; + $this->max_points = $max_points; + $this->prev_solver = $prev_solver; + $this->prev_exercise = $prev_exercise; + $this->next_solver = $next_solver; + $this->next_exercise = $next_exercise; + $this->view = $view; + + Helpbar::get()->addPlainText('', + _('Sie können hier die Ergebnisse der Autokorrektur ansehen und Aufgaben manuell korrigieren.')); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabe bearbeiten'), + $this->url_for('vips/sheets/edit_exercise', compact('assignment_id', 'exercise_id')), + Icon::create('edit') + ); + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'user_ids[]' => $solver_id, 'print_files' => 1, 'print_correction' => !$view]), + Icon::create('print'), + ['target' => '_blank'] + ); + Sidebar::get()->addWidget($widget); + + $widget = new LinksWidget(); + $widget->setTitle(_('Links')); + if (isset($prev_uncorrected_exercise)) { + $widget->addLink( + _('Vorige unkorrigierte Aufgabe'), + $this->url_for('vips/solutions/edit_solution', [ + 'assignment_id' => $assignment_id, + 'exercise_id' => $prev_uncorrected_exercise['id'], + 'solver_id' => $prev_uncorrected_exercise['solver_id'], + 'view' => $view, + ]), + Icon::create('arr_1left') + ); + } + if (isset($next_uncorrected_exercise)) { + $widget->addLink( + _('Nächste unkorrigierte Aufgabe'), + $this->url_for('vips/solutions/edit_solution', [ + 'assignment_id' => $assignment_id, + 'exercise_id' => $next_uncorrected_exercise['id'], + 'solver_id' => $next_uncorrected_exercise['solver_id'], + 'view' => $view, + ]), + Icon::create('arr_1right') + ); + } + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Aufgabenblatt'), $this->url_for('vips/solutions/edit_solution', compact('assignment_id', 'solver_id', 'view')), 'exercise_id'); + + foreach ($exercises as $exercise) { + $element = new SelectElement($exercise['id'], sprintf(_('Aufgabe %d'), $exercise['position']), $exercise['id'] === $exercise_id); + $widget->addElement($element); + } + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Versionen'), $this->url_for('vips/solutions/edit_solution', compact('assignment_id', 'exercise_id', 'solver_id', 'view')), 'solution_id'); + $element = new SelectElement(0, sprintf(_('Aktuelle Version: %s'), $version), !$archived_id); + $widget->addElement($element); + + if (count($solution_archive) === 0) { + $widget->attributes = ['disabled' => 'disabled']; + } + + foreach ($solution_archive as $i => $old_solution) { + $element = new SelectElement($old_solution->id, + sprintf(_('Version %s vom %s'), count($solution_archive) - $i, date('d.m.Y, H:i', $old_solution->mkdate)), + $old_solution->id == $archived_id); + $widget->addElement($element); + } + Sidebar::get()->addWidget($widget); + } + + /** + * Display a student's corrected solution for a single exercise. + */ + public function view_solution_action() + { + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + $solver_id = $GLOBALS['user']->id; + $released = $assignment->releaseStatus($solver_id); + + if ($released < VipsAssignment::RELEASE_STATUS_CORRECTIONS) { + // the assignment is not finished or not yet released + PageLayout::postError(_('Die Korrekturen des Aufgabenblatts sind nicht freigegeben.')); + $this->redirect($this->url_for('vips/solutions/student_assignment_solutions', compact('assignment_id'))); + return; + } + + $exercise = Exercise::find($exercise_id); + $solution = $assignment->getSolution($solver_id, $exercise_id); + + if (!$solution) { + $solution = new VipsSolution(); + $solution->assignment = $assignment; + } + + // fetch previous and next exercises + $prev_exercise_id = null; + $next_exercise_id = null; + $before_current = true; + + foreach ($assignment->getExerciseRefs($solver_id) as $i => $item) { + if ($item->task_id == $exercise_id) { + $before_current = false; + $exercise_position = $i + 1; + $max_points = $item->points; + } else if ($before_current) { + $prev_exercise_id = $item->task_id; + } else { + $next_exercise_id = $item->task_id; + break; + } + } + + $this->exercise = $exercise; + $this->exercise_position = $exercise_position; + $this->assignment = $assignment; + $this->solution = $solution; + $this->show_solution = $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS; + $this->max_points = $max_points; + $this->prev_exercise_id = $prev_exercise_id; + $this->next_exercise_id = $next_exercise_id; + + $widget = new SelectWidget(_('Aufgabenblatt'), $this->url_for('vips/solutions/view_solution', compact('assignment_id')), 'exercise_id'); + + foreach ($assignment->getExerciseRefs($solver_id) as $i => $item) { + $element = new SelectElement($item->task_id, sprintf(_('Aufgabe %d'), $i + 1), $item->task_id === $exercise->id); + $widget->addElement($element); + } + Sidebar::get()->addWidget($widget); + } + + /** + * Restores an archived solution as the current solution. + */ + public function restore_solution_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $solution_id = Request::int('solution_id'); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + $solution = VipsSolution::find($solution_id); + + $exercise_id = $solution->task_id; + $assignment_id = $solution->assignment_id; + $assignment = $solution->assignment; + + VipsModule::requireEditPermission($assignment); + + $assignment->restoreSolution($solution); + PageLayout::postSuccess(_('Die ausgewählte Lösung wurde als aktuelle Version gespeichert.')); + + $this->redirect($this->url_for('vips/solutions/edit_solution', compact('exercise_id', 'assignment_id', 'solver_id', 'view'))); + } + + /** + * Displays a student's event log for an assignment. + */ + public function show_assignment_log_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); + + VipsModule::requireEditPermission($assignment); + + $assignment_attempt = $assignment->getAssignmentAttempt($solver_id); + + $this->user = User::find($solver_id); + $this->logs = $assignment_attempt->getLogEntries(); + } + + /** + * Offer to remove users from a group for this assignment. + */ + public function edit_group_dialog_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + VipsModule::requireEditPermission($assignment); + + $this->group = $assignment->getUserGroup($solver_id); + $this->members = $assignment->getGroupMembers($this->group); + + usort($this->members, function($a, $b) { + return strcoll($a->user->getFullName('no_title_rev'), $b->user->getFullName('no_title_rev')); + }); + + $this->assignment = $assignment; + $this->view = $view; + } + + /** + * Update group assignment for a list of users for this assignment. + */ + public function edit_group_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $group_id = Request::option('group_id'); + $group = VipsGroup::find($group_id); + $user_ids = Request::optionArray('user_ids'); + $view = Request::option('view'); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->isFinished() && $user_ids) { + foreach ($assignment->getGroupMembers($group) as $member) { + if (in_array($member->user_id, $user_ids)) { + $clone = $member->build($member); + $member->end = $assignment->end; + $member->store(); + $clone->start = $assignment->end; + $clone->store(); + } + } + + PageLayout::postSuccess(_('Die ausgewählten Personen wurden aus der Gruppe entfernt.')); + } + + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view'))); + } + + /** + * Write a message to selected members for an assignment. + */ + public function write_message_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $user_ids = Request::optionArray('user_ids'); + + VipsModule::requireEditPermission($assignment); + + foreach ($user_ids as $user_id) { + $group = $assignment->getUserGroup($user_id); + + if ($group) { + foreach ($assignment->getGroupMembers($group) as $member) { + $users[] = $member->username; + } + } else { + $users[] = get_username($user_id); + } + } + + $_SESSION['sms_data']['p_rec'] = $users; + $this->redirect(URLHelper::getURL('dispatch.php/messages/write')); + } + + /** + * Stores the lecturer comment and the corrected points for a solution. + */ + public function store_correction_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $solution_id = Request::int('solution_id'); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + $feedback = trim(Request::get('feedback')); + $feedback = Studip\Markup::purifyHtml($feedback); + $file_ids = Request::optionArray('file_ids'); + $corrected = Request::int('corrected', 0); + $reached_points = Request::float('reached_points'); + $max_points = Request::float('max_points'); + + if ($solution_id) { + $solution = VipsSolution::find($solution_id); + } else { + // create dummy empty solution + $solution = new VipsSolution(); + $solution->task_id = Request::int('exercise_id'); + $solution->assignment_id = Request::int('assignment_id'); + $solution->user_id = $solver_id; + } + + $exercise_id = $solution->task_id; + $assignment_id = $solution->assignment_id; + + VipsModule::requireEditPermission($solution->assignment, $exercise_id); + + // let exercise class handle special controls added to the form + $exercise = Exercise::find($exercise_id); + $exercise->correctSolutionAction($this, $solution); + + if (Request::submitted('store_solution')) { + // process lecturer's input + $solution->state = $corrected; + $solution->points = round($reached_points * 2) / 2; + $solution->feedback = $feedback ?: null; + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + if ($solution->points > $max_points) { + PageLayout::postInfo(sprintf(_('Sie haben Bonuspunkte vergeben: %g von %g.'), $solution->points, $max_points)); + } else if ($solution->points < 0) { + PageLayout::postWarning(sprintf(_('Sie haben eine negative Punktzahl eingegeben: %g von %g.'), $solution->points, $max_points)); + } else if ($solution->points != $reached_points) { + PageLayout::postWarning(sprintf(_('Die eingegebene Punktzahl wurde auf halbe Punkte gerundet: %g.'), $solution->points)); + } + + setlocale(LC_NUMERIC, 'C'); + + $upload = $_FILES['upload'] ?: ['name' => []]; + + if ($solution->isDirty() || count($upload)) { + $solution->grader_id = $GLOBALS['user']->id; + $solution->store(); + + PageLayout::postSuccess(_('Ihre Korrektur wurde gespeichert.')); + } + + $folder = Folder::findTopFolder($solution->id, 'FeedbackFolder', 'response'); + + foreach ($folder->file_refs as $file_ref) { + if (!in_array($file_ref->id, $file_ids) || in_array($file_ref->name, $upload['name'])) { + $file_ref->delete(); + } + } + + FileManager::handleFileUpload($upload, $folder->getTypedFolder()); + } + + // show exercise and correction form again + $this->redirect($this->url_for('vips/solutions/edit_solution', compact('exercise_id', 'assignment_id', 'solver_id', 'view'))); + } + + /** + * Edit an active assignment attempt (update end time). + */ + public function edit_assignment_attempt_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + VipsModule::requireEditPermission($assignment); + + $this->assignment = $assignment; + $this->assignment_attempt = $assignment->getAssignmentAttempt($solver_id); + $this->solver_id = $solver_id; + $this->view = $view; + } + + /** + * Update an active assignment attempt (store end time). + */ + public function store_assignment_attempt_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $end_time = trim(Request::get('end_time')); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + VipsModule::requireEditPermission($assignment); + + $assignment_attempt = $assignment->getAssignmentAttempt($solver_id); + + if ($assignment_attempt) { + $end_day = date('Y-m-d', $assignment->getUserEndTime($solver_id)); + $end_datetime = DateTime::createFromFormat('Y-m-d H:i:s', $end_day . ' ' . $end_time); + + if ($end_datetime) { + $assignment_attempt->end = strtotime($end_datetime->format('Y-m-d H:i:s')); + $assignment_attempt->store(); + + if ($assignment_attempt->end > $assignment->end) { + PageLayout::postWarning(_('Der Abgabezeitpunkt liegt nach dem Ende der Klausur.')); + } + } else { + PageLayout::postError(_('Der Abgabezeitpunkt ist keine gültige Uhrzeit.')); + } + } + + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view'))); + } + + /** + * Deletes the solutions of a student (or all students) and resets the + * assignment attempt. + */ + public function delete_solutions_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); + $view = Request::option('view'); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->type === 'exam') { + if ($solver_id === 'all') { + $assignment->deleteAllSolutions(); + PageLayout::postSuccess(_('Die Klausur wurde zurückgesetzt und alle abgegebenen Lösungen archiviert.')); + } else if ($assignment->isRunning()) { + $assignment->deleteSolutions($solver_id); + PageLayout::postSuccess(_('Die Teilnahme wurde zurückgesetzt und ggf. abgegebene Lösungen archiviert.')); + } + } + + $this->redirect($this->url_for('vips/solutions/assignment_solutions', compact('assignment_id', 'view'))); + } + + + + /** + * Shows all corrected exercises of an exercise sheet, if the status + * of "released" allows that, i.e. is at least 1. + */ + public function student_assignment_solutions_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment); + + $this->assignment = $assignment; + $this->user_id = $GLOBALS['user']->id; + $this->released = $assignment->releaseStatus($this->user_id); + $this->feedback = $assignment->getUserFeedback($this->user_id); + + // Security check -- is assignment really accessible for students? + if ($this->released == VipsAssignment::RELEASE_STATUS_NONE) { + PageLayout::postError(_('Die Korrekturen wurden noch nicht freigegeben.')); + $this->redirect('vips/solutions'); + return; + } + + Helpbar::get()->addPlainText('', + _('Sie können hier die Ergebnisse bzw. die Korrekturen ihrer Aufgaben ansehen.')); + + if ($this->released >= VipsAssignment::RELEASE_STATUS_CORRECTIONS) { + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + Sidebar::get()->addWidget($widget); + } + } + + + + /** + * Displays all course participants and all their results (reached points, + * percent, weighted percent) for all tests, blocks and exams plus their + * final grade. + */ + public function participants_overview_action() + { + $this->course_id = Context::getId(); + VipsModule::requireStatus('tutor', $this->course_id); + + $display = Request::option('display', 'points'); + $sort = Request::option('sort', 'name'); + $desc = Request::int('desc'); + $view = Request::option('view'); + $format = Request::option('format'); + + $sem_class = Context::get()->getSemClass(); + $attributes = $this->participants_overview_data($this->course_id, null, $display, $sort, $desc, $view); + + $settings = CourseConfig::get($this->course_id); + $this->has_grades = !empty($settings->VIPS_COURSE_GRADES); + + foreach ($attributes as $key => $value) { + $this->$key = $value; + } + + if ($format == 'csv') { + $columns = [_('Nachname'), _('Vorname'), _('Kennung'), _('Matrikelnr.')]; + + foreach ($this->items as $category => $list) { + foreach ($list as $item) { + $columns[] = $item['name']; + } + } + + $columns[] = _('Summe'); + + if ($display != 'points' && $this->has_grades) { + $columns[] = _('Note'); + } + + $data = [$columns]; + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + if ($display == 'points' || $this->overall['weighting']) { + if ($display == 'points') { + $row = [_('Maximalpunktzahl'), '', '', '']; + } else { + $row = [_('Gewichtung'), '', '', '']; + } + + foreach ($this->items as $category => $list) { + foreach ($list as $item) { + if ($display == 'points') { + $row[] = sprintf('%.1f', $item['points']); + } else { + $row[] = sprintf('%.1f%%', $item['weighting']); + } + } + } + + if ($display == 'points') { + $row[] = sprintf('%.1f', $this->overall['points']); + } else { + $row[] = '100%'; + + if ($this->has_grades) { + $row[] = ''; + } + } + + $data[] = $row; + } + + foreach ($this->participants as $p) { + $row = [$p['surname'], $p['forename'], $p['username']]; + + if (!$sem_class['studygroup_mode']) { + $row[] = $p['stud_id']; + } else { + $row[] = ''; + } + + foreach ($this->items as $category => $list) { + foreach ($list as $item) { + if ($display == 'points') { + if (isset($p['items'][$category][$item['id']]['points'])) { + $row[] = sprintf('%.1f', $p['items'][$category][$item['id']]['points']); + } else { + $row[] = ''; + } + } else { + if (isset($p['items'][$category][$item['id']]['percent'])) { + $row[] = sprintf('%.1f%%', $p['items'][$category][$item['id']]['percent']); + } else { + $row[] = ''; + } + } + } + } + + if ($display == 'points') { + if (isset($p['overall']['points'])) { + $row[] = sprintf('%.1f', $p['overall']['points']); + } else { + $row[] = ''; + } + } else { + if (isset($p['overall']['weighting'])) { + $row[] = sprintf('%.1f%%', $p['overall']['weighting']); + } else { + $row[] = ''; + } + + if ($this->has_grades) { + $row[] = $p['grade']; + } + } + + $data[] = $row; + } + + setlocale(LC_NUMERIC, 'C'); + + $this->render_csv($data, _('Notenliste.csv')); + } else { + Helpbar::get()->addPlainText('', + _('Diese Seite gibt einen Ãœberblick über die von allen Teilnehmenden erreichten Punkte und ggf. Noten.')); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Ergebnisse'), + $this->url_for('vips/solutions') + ); + $widget->addLink( + _('Punkteübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'points']) + )->setActive($display == 'points'); + $widget->addLink( + _('Notenübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'weighting']) + )->setActive($display == 'weighting'); + $widget->addLink( + _('Statistik'), + $this->url_for('vips/solutions/statistics') + ); + Sidebar::get()->addWidget($widget); + + $widget = new ExportWidget(); + $widget->addLink( + _('Liste im CSV-Format exportieren'), + $this->url_for('vips/solutions/participants_overview', ['display' => $display, 'view' => $view, 'sort' => $sort, 'format' => 'csv']), + Icon::create('export') + ); + Sidebar::get()->addWidget($widget); + } + } + + public function statistics_action() + { + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $db = DBManager::get(); + + $format = Request::option('format'); + $assignments = []; + + $_assignments = VipsAssignment::findBySQL("range_id = ? AND type IN ('exam', 'practice') ORDER BY start", [$course_id]); + + foreach ($_assignments as $assignment) { + $test_points = 0; + $test_average = 0; + $exercises = []; + + foreach ($assignment->test->exercise_refs as $exercise_ref) { + $exercise = $exercise_ref->exercise; + $exercise_points = (float) $exercise_ref->points; + $exercise_average = 0; + $exercise_correct = 0; + + $exercise_item_count = $exercise->itemCount(); + $exercise_items = array_fill(0, $exercise_item_count, 0); + $exercise_items_c = array_fill(0, $exercise_item_count, 0); + + $query = "SELECT etask_responses.* FROM etask_responses + LEFT JOIN seminar_user USING(user_id) + WHERE etask_responses.assignment_id = $assignment->id + AND etask_responses.task_id = $exercise->id + AND seminar_user.Seminar_id = '$course_id' + AND seminar_user.status = 'autor'"; + + $solution_result = $db->query($query); + $num_solutions = $solution_result->rowCount(); + + foreach ($solution_result as $solution_row) { + $solution = VipsSolution::buildExisting($solution_row); + $solution_points = (float) $solution->points; + + if ($exercise_item_count > 1) { + $items = $exercise->evaluateItems($solution); + $item_scale = $exercise_points / max(count($items), 1); + + foreach ($items as $index => $item) { + $exercise_items[$index] += $item['points'] * $item_scale / $num_solutions; + + if ($item['points'] == 1) { + $exercise_items_c[$index] += 1 / $num_solutions; + } + } + } + + if ($solution_points >= $exercise_points) { + ++$exercise_correct; + } + + $exercise_average += $solution_points / $num_solutions; + } + + $exercises[] = [ + 'id' => $exercise->id, + 'name' => $exercise->title, + 'position' => $exercise_ref->position, + 'points' => $exercise_points, + 'average' => $exercise_average, + 'correct' => $exercise_correct / max($num_solutions, 1), + 'items' => $exercise_items, + 'items_c' => $exercise_items_c + ]; + + $test_points += $exercise_points; + $test_average += $exercise_average; + } + + $assignments[] = [ + 'assignment' => $assignment, + 'points' => $test_points, + 'average' => $test_average, + 'exercises' => $exercises + ]; + } + + $this->assignments = $assignments; + + if ($format == 'csv') { + $columns = [ + _('Titel'), + _('Aufgabe'), + _('Item'), + _('Erreichbare Punkte'), + _('Durchschn. Punkte'), + _('Korrekte Lösungen') + ]; + + $data = [$columns]; + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + foreach ($assignments as $assignment) { + if (count($assignment['exercises'])) { + $data[] = [ + $assignment['assignment']->test->title, + '', + '', + sprintf('%.1f', $assignment['points']), + sprintf('%.1f', $assignment['average']), + '' + ]; + + foreach ($assignment['exercises'] as $exercise) { + $data[] = [ + $assignment['assignment']->test->title, + $exercise['position'] . '. ' . $exercise['name'], + '', + sprintf('%.1f', $exercise['points']), + sprintf('%.1f', $exercise['average']), + sprintf('%.1f%%', $exercise['correct'] * 100) + ]; + + if (count($exercise['items']) > 1) { + foreach ($exercise['items'] as $index => $item) { + $data[] = [ + $assignment['assignment']->test->title, + $exercise['position'] . '. ' . $exercise['name'], + sprintf(_('Item %d'), $index + 1), + sprintf('%.1f', $exercise['points'] / count($exercise['items'])), + sprintf('%.1f', $item), + sprintf('%.1f%%', $exercise['items_c'][$index] * 100) + ]; + } + } + } + } + } + + setlocale(LC_NUMERIC, 'C'); + + $this->render_csv($data, _('Statistik.csv')); + } else { + Helpbar::get()->addPlainText('', + _('Diese Seite gibt einen Ãœberblick über die im Durchschnitt von allen Teilnehmenden erreichten Punkte ' . + 'sowie den Prozentsatz der vollständig korrekten Lösungen.')); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Ergebnisse'), + $this->url_for('vips/solutions') + ); + $widget->addLink( + _('Punkteübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'points']) + ); + $widget->addLink( + _('Notenübersicht'), + $this->url_for('vips/solutions/participants_overview', ['display' => 'weighting']) + ); + $widget->addLink( + _('Statistik'), + $this->url_for('vips/solutions/statistics') + )->setActive(); + Sidebar::get()->addWidget($widget); + + $widget = new ExportWidget(); + $widget->addLink( + _('Liste im CSV-Format exportieren'), + $this->url_for('vips/solutions/statistics', ['format' => 'csv']), + Icon::create('export') + ); + Sidebar::get()->addWidget($widget); + } + } + + /** + * Get the internet host name corresponding to a given IP address. + * + * @param string $ip_address host IP address + */ + public function gethostbyaddr(string $ip_address): ?string + { + static $hostname = []; + + if (!array_key_exists($ip_address, $hostname)) { + $hostname[$ip_address] = gethostbyaddr($ip_address); + } + + if ($hostname[$ip_address] !== $ip_address) { + return $hostname[$ip_address]; + } + + return null; + } + + /** + * Get all exercise sheets belonging to course. + */ + private function get_assignments_data($course_id, $user_id, $sort, $desc) + { + $assignments_array = []; + $m_sum_max_points = 0; // holds the maximum points of all exercise sheets + $sum_reached_points = 0; // holds the reached points of all assignments + + // find all assignments + $assignments = VipsAssignment::findByRangeId($course_id); + + usort($assignments, function($a, $b) use ($sort) { + if ($sort === 'title') { + return strcoll($a->test->title, $b->test->title); + } else if ($sort === 'start') { + return strcmp($a->start, $b->start); + } else { + return strcmp($a->end ?: '~', $b->end ?: '~'); + } + }); + + if ($desc) { + $assignments = array_reverse($assignments); + } + + foreach ($assignments as $assignment) { + $max_points = $assignment->test->getTotalPoints(); + + // for students, get reached points + if (!VipsModule::hasStatus('tutor', $course_id)) { + $released = $assignment->releaseStatus($user_id); + + if ($assignment->isVisible($user_id) && $released > 0) { + $reached_points = $assignment->getUserPoints($user_id); + $sum_reached_points += $reached_points; + $m_sum_max_points += $max_points; + } else { + continue; + } + } else { + $released = $assignment->options['released']; + $reached_points = null; + $m_sum_max_points += $max_points; + } + + // count uncorrected solutions + $uncorrected_solutions = $this->count_uncorrected_solutions($assignment->id); + + $assignments_array[] = [ + 'assignment' => $assignment, + 'released' => $released, + 'reached_points' => $reached_points, + 'max_points' => $max_points, + 'uncorrected_solutions' => $uncorrected_solutions + ]; + } + + return [ + 'assignments' => $assignments_array, + 'sum_reached_points' => $sum_reached_points, + 'sum_max_points' => $m_sum_max_points + ]; + } + + private function participants_overview_data($course_id, $param_user_id, $display = null, $sort = null, $desc = null, $view = null) + { + $db = DBManager::get(); + + // fetch all course participants // + + $participants = []; + + $sql = "SELECT user_id + FROM seminar_user + WHERE Seminar_id = ? + AND status NOT IN ('dozent', 'tutor')"; + $result = $db->prepare($sql); + $result->execute([$course_id]); + + foreach ($result as $row) { + $participants[$row['user_id']] = []; + } + + // fetch all assignments with maximum points, assigned to blocks // + // (if appropriate), and with weighting (if appropriate) // + + $types = $view === 'selftest' ? ['selftest'] : ['exam', 'practice']; + + $sql = "SELECT etask_assignments.id, + etask_assignments.type, + etask_tests.title, + etask_assignments.end, + etask_assignments.weight, + etask_assignments.options, + etask_assignments.block_id, + SUM(etask_test_tasks.points) AS points, + etask_blocks.name AS block_name, + etask_blocks.weight AS block_weight + FROM etask_assignments + JOIN etask_tests ON etask_tests.id = etask_assignments.test_id + LEFT JOIN etask_test_tasks + ON etask_test_tasks.test_id = etask_tests.id + LEFT JOIN etask_blocks + ON etask_blocks.id = etask_assignments.block_id + WHERE etask_assignments.range_id = ? + AND etask_assignments.type IN (?) + GROUP BY etask_assignments.id + ORDER BY etask_assignments.type DESC, etask_blocks.name, etask_assignments.start"; + $result = $db->prepare($sql); + $result->execute([$course_id, $types]); + + // the result is ordered by + // * tests + // * blocks + // * exams + // with ascending start points in each category + + $assignments = []; + $items = [ + 'tests' => [], + 'blocks' => [], + 'exams' => [] + ]; + $overall_points = 0; + $overall_weighting = 0; + + // each assignment + foreach ($result as $row) { + $assignment_id = (int) $row['id']; + $test_type = $row['type']; + $test_title = $row['title']; + $points = (float) $row['points']; + $block_id = $row['block_id']; + $block_name = $row['block_name']; + $weighting = (float) $row['weight']; + + $assignment = VipsAssignment::find($assignment_id); + + if (isset($block_id) && $row['block_weight'] !== null) { + $category = 'blocks'; + + // store assignment + $assignments[$assignment_id] = [ + 'assignment' => $assignment, + 'category' => $category, + 'item_id' => $block_id + ]; + + // store item + if (!isset($items[$category][$block_id])) { + $weighting = (float) $row['block_weight']; + + // initialise block + $items[$category][$block_id] = [ + 'id' => $block_id, + 'item' => VipsBlock::find($block_id), + 'name' => $block_name, + 'tooltip' => $block_name.': '.$test_title, + 'points' => 0, + 'weighting' => $weighting + ]; + + // increase overall weighting (just once for each block!) + $overall_weighting += $weighting; + } else { + // extend tooltip for existing block + $items[$category][$block_id]['tooltip'] .= ', '.$test_title; + } + + // increase block's points (for each assignment) + $items[$category][$block_id]['points'] += $points; + + // increase overall points (for each assignment) + $overall_points += $points; + } else { + $category = $test_type === 'exam' ? 'exams' : 'tests'; + + // store assignment + $assignments[$assignment_id] = [ + 'assignment' => $assignment, + 'category' => $category, + 'item_id' => $assignment_id + ]; + + // store item + $items[$category][$assignment_id] = [ + 'id' => $assignment_id, + 'item' => $assignment, + 'name' => $test_title, + 'tooltip' => $test_title, + 'points' => $points, + 'weighting' => $weighting + ]; + + // increase overall points and weighting + $overall_points += $points; + $overall_weighting += $weighting; + } + } + + // overall sum column + $overall = [ + 'points' => $overall_points, + 'weighting' => $overall_weighting + ]; + + if ($overall['weighting'] == 0 && count($assignments) > 0) { + // if weighting is not used, all items weigh equally + $equal_weight = 100 / (count($items['tests']) + count($items['blocks']) + count($items['exams'])); + + foreach ($items as &$list) { + foreach ($list as &$item) { + $item['weighting'] = $equal_weight; + } + } + } + + if (count($assignments) > 0) { + + // fetch all assignments, grouped and summed up by user // + // (assignments that are not solved by any user won't appear) // + + $sql = "SELECT etask_responses.assignment_id, etask_responses.user_id + FROM etask_responses + LEFT JOIN seminar_user + ON seminar_user.user_id = etask_responses.user_id + AND seminar_user.Seminar_id = ? + WHERE etask_responses.assignment_id IN (?) + AND ( + seminar_user.status IS NULL OR + seminar_user.status NOT IN ('dozent', 'tutor') + ) + GROUP BY etask_responses.assignment_id, etask_responses.user_id"; + $result = $db->prepare($sql); + $result->execute([$course_id, array_keys($assignments)]); + + // each assignment + foreach ($result as $row) { + $assignment_id = (int) $row['assignment_id']; + $assignment = $assignments[$assignment_id]['assignment']; + $user_id = $row['user_id']; + $reached_points = $assignment->getUserPoints($user_id); // points in the assignment + + $category = $assignments[$assignment_id]['category']; + $item_id = $assignments[$assignment_id]['item_id']; + + $max_points = $items[$category][$item_id]['points']; // max points for the item + $weighting = $items[$category][$item_id]['weighting']; // item weighting + + // recalc weighting based on item visibility + $sum_weight = $this->participant_weight_sum($items, $user_id); + + if ($sum_weight && ($assignment->isVisible($user_id) || $assignment->getAssignmentAttempt($user_id))) { + $weighting = 100 * $weighting / $sum_weight; + } else { + $weighting = 0; + } + + // compute percent and weighted percent + if ($max_points > 0) { + $percent = round(100 * $reached_points / $max_points, 1); + $weighted_percent = round($weighting * $reached_points / $max_points, 1); + } else { + $percent = 0; + $weighted_percent = 0; + } + + $group = $assignment->getUserGroup($user_id); + + if (isset($group)) { + $members = array_column($assignment->getGroupMembers($group), 'user_id'); + } else { + $members = [$user_id]; + } + + // tests // + + if ($category == 'tests') { + foreach ($members as $member_id) { + if (!isset($participants[$member_id]['items']['tests'][$item_id])) { + // store reached points, percent and weighted percent for this item, for each group member + $participants[$member_id]['items'][$category][$item_id] = [ + 'points' => $reached_points, + 'percent' => $percent + ]; + + if (!isset($participants[$member_id]['overall'])) { + $participants[$member_id]['overall'] = ['points' => 0, 'weighting' => 0]; + } + + // sum up overall points and weighted percent + $participants[$member_id]['overall']['points'] += $reached_points; + $participants[$member_id]['overall']['weighting'] += $weighted_percent; + } + } + } + + // blocks // + + if ($category == 'blocks') { + foreach ($members as $member_id) { + if (!isset($participants[$member_id]['items']['tests_seen'][$assignment_id])) { + $participants[$member_id]['items']['tests_seen'][$assignment_id] = true; + + if (!isset($participants[$member_id]['items']['blocks'][$item_id])) { + $participants[$member_id]['items']['blocks'][$item_id] = ['points' => 0, 'percent' => 0]; + } + + // store reached points, percent and weighted percent for this item, for each group member + $participants[$member_id]['items']['blocks'][$item_id]['points'] += $reached_points; + $participants[$member_id]['items']['blocks'][$item_id]['percent'] += $percent; + + if (!isset($participants[$member_id]['overall'])) { + $participants[$member_id]['overall'] = ['points' => 0, 'weighting' => 0]; + } + + // sum up overall points and weighted percent + $participants[$member_id]['overall']['points'] += $reached_points; + $participants[$member_id]['overall']['weighting'] += $weighted_percent; + } + } + } + + // exams // + + if ($category == 'exams') { + // store reached points, percent and weighted percent for this item + $participants[$user_id]['items'][$category][$item_id] = [ + 'points' => $reached_points, + 'percent' => $percent + ]; + + if (!isset($participants[$user_id]['overall'])) { + $participants[$user_id]['overall'] = ['points' => 0, 'weighting' => 0]; + } + + // sum up overall points and weighted percent + $participants[$user_id]['overall']['points'] += $reached_points; + $participants[$user_id]['overall']['weighting'] += $weighted_percent; + } + } + } + + // if user_id parameter has been passed, delete all participants but the + // requested user (this must take place AFTER all that has been done before + // for to catch all group solutions) + if (isset($param_user_id)) { + $participants = [$param_user_id => $participants[$param_user_id]]; + } + + // get information for each participant + foreach ($participants as $user_id => $rest) { + $user = User::find($user_id); + + $participants[$user_id]['username'] = $user->username; + $participants[$user_id]['forename'] = $user->vorname; + $participants[$user_id]['surname'] = $user->nachname; + $participants[$user_id]['name'] = $user->nachname . ', ' . $user->vorname; + $participants[$user_id]['stud_id'] = $user->matriculation_number; + } + + + // sort participant array // + + $sort_by_name = function($a, $b) { // sort by name + return strcoll($a['name'], $b['name']); + }; + + $sort_by_points = function($a, $b) use ($sort_by_name) { // sort by points (or name, if points are equal) + if ($a['overall']['points'] == $b['overall']['points']) { + return $sort_by_name($a, $b); + } else { + return $a['overall']['points'] < $b['overall']['points'] ? -1 : 1; + } + }; + + $sort_by_grade = function($a, $b) use ($sort_by_name) { // sort by grade (or name, if grade is equal) + if ($a['overall']['weighting'] == $b['overall']['weighting']) { + return $sort_by_name($a, $b); + } else { + return $a['overall']['weighting'] < $b['overall']['weighting'] ? -1 : 1; + } + }; + + switch ($sort) { + case 'sum': // sort by sum row + if ($display == 'points') { + uasort($participants, $sort_by_points); + } else { + uasort($participants, $sort_by_grade); + } + break; + + case 'grade': // sort by grade (or name, if grade is equal) + uasort($participants, $sort_by_grade); + break; + + case 'name': // sort by name + default: + uasort($participants, $sort_by_name); + } + + if ($desc) { + $participants = array_reverse($participants, true); + } + + // fetch grades from database + $settings = CourseConfig::get($course_id); + + // grading is used + if ($settings->VIPS_COURSE_GRADES) { + foreach ($participants as $user_id => $participant) { + $participants[$user_id]['grade'] = '5,0'; + + if (isset($participant['overall'])) { + foreach ($settings->VIPS_COURSE_GRADES as $g) { + $grade = $g['grade']; + $percent = $g['percent']; + $comment = $g['comment']; + + if ($participant['overall']['weighting'] >= $percent) { + $participants[$user_id]['grade'] = $grade; + $participants[$user_id]['grade_comment'] = $comment; + break; + } + } + } + } + } + + return [ + 'display' => $display, + 'sort' => $sort, + 'desc' => $desc, + 'view' => $view, + 'items' => $items, + 'overall' => $overall, + 'participants' => $participants + ]; + } + + private function participant_weight_sum($items, $user_id) + { + static $weight_sum = []; + + if (!array_key_exists($user_id, $weight_sum)) { + $weight_sum[$user_id] = 0; + + foreach ($items as $list) { + foreach ($list as $item) { + if ($item['item']->isVisible($user_id) || $item['item']->getAssignmentAttempt($user_id)) { + $weight_sum[$user_id] += $item['weighting']; + } + } + } + } + + return $weight_sum[$user_id]; + } + + /** + * Get all solutions for an assignment. + * + * @param object $assignment The assignment + * @param string|bool $view If set to the empty string, only users with solutions are + * returned. If set to string <code>all</code>, virtually + * <i>all</i> course participants (including those who have + * not delivered any solution) are returned. + * @return Array An array consisting of <i>three</i> arrays, namely 'solvers' + * (containing all single solvers and groups), 'exercises' + * (containing all exercises in the assignment) and 'solutions' + * (containing all solvers and their solved exercises). + */ + private function get_solutions($assignment, $view) + { + // get exercises // + + $exercises = []; + + foreach ($assignment->test->exercise_refs as $exercise_ref) { + $exercise_id = (int) $exercise_ref->task_id; + + $exercises[$exercise_id] = [ + 'id' => $exercise_id, + 'title' => $exercise_ref->exercise->title, + 'type' => $exercise_ref->exercise->type, + 'position' => (int) $exercise_ref->position, + 'points' => (float) $exercise_ref->points + ]; + } + + // get course participants // + + $solvers = []; + $tutors = []; + + foreach ($assignment->course->members as $member) { + $user_id = $member->user_id; + $status = $member->status; + + // don't include tutors and lecturers + if ($status == 'tutor' || $status == 'dozent') { + $tutors[$user_id] = $status; + } else { + $solvers[$user_id] = [ + 'type' => 'single', + 'id' => $user_id, + 'user_id' => $user_id + ]; + } + } + + // get assignment attempts // + + foreach ($assignment->assignment_attempts as $attempt) { + $user_id = $attempt->user_id; + + $solvers[$user_id] = [ + 'type' => 'single', + 'id' => $user_id, + 'user_id' => $user_id + ]; + } + + // get solutions // + + $solutions = []; + + foreach ($assignment->solutions as $solution) { + $exercise_id = (int) $solution->task_id; + $user_id = $solution->user_id; + + $solutions[$user_id][$exercise_id] = [ + 'id' => (int) $solution->id, + 'exercise_id' => $exercise_id, + 'user_id' => $user_id, + 'time' => $solution->mkdate, + 'corrected' => (boolean) $solution->state, + 'points' => (float) $solution->points, + 'grader_id' => $solution->grader_id, + 'feedback' => $solution->feedback, + 'uploads' => $solution->folder && count($solution->folder->file_refs) + ]; + + // solver may be a non-participant (and must not be a tutor) + if (!isset($solvers[$user_id]) && !isset($tutors[$user_id])) { + $solvers[$user_id] = [ + 'type' => 'single', + 'id' => $user_id, + 'user_id' => $user_id + ]; + } + } + + /// NOTE: $solvers now *additionally* contains all students which have + /// submitted a solution + + // get groups // + + $groups = []; + + if ($assignment->hasGroupSolutions()) { + $all_groups = VipsGroup::findBySQL('range_id = ? ORDER BY name', [$assignment->range_id]); + + foreach ($all_groups as $group) { + $members = $assignment->getGroupMembers($group); + + foreach ($members as $member) { + $group_id = (int) $group->id; + $user_id = $member->user_id; + + if (!isset($solvers[$user_id])) { + // add group member to $solvers + $solvers[$user_id] = [ + 'type' => 'group_member', + 'id' => $user_id, + 'user_id' => $user_id + ]; + } else { + // update type for existing solvers + $solvers[$user_id]['type'] = 'group_member'; + } + + if (!isset($groups[$group_id])) { + $groups[$group_id] = [ + 'type' => 'group', + 'id' => $group_id, + 'user_id' => $user_id, + 'name' => $group->name, + 'members' => [] + ]; + } + + // store which user is member of which group (user_id => group_id) + $map_user_to_group[$user_id] = $group_id; + } + } + } + + /// NOTE: $solvers now *additionally* contains group members (if applicable) + + if (count($solvers)) { + $result = User::findMany(array_keys($solvers)); + + // get user names + foreach ($result as $user) { + $solvers[$user->id]['username'] = $user->username; + $solvers[$user->id]['forename'] = $user->vorname; + $solvers[$user->id]['surname'] = $user->nachname; + $solvers[$user->id]['name'] = $user->nachname . ', ' . $user->vorname; + $solvers[$user->id]['stud_id'] = $user->matriculation_number; + } + + uasort($solvers, function($a, $b) { + return strcoll($a['name'], $b['name']); + }); + } + + // add groups to $solvers array // + + foreach ($groups as $group_id => $group) { + $solvers[$group_id] = $group; + } + + // sort single solvers to groups // + + foreach ($solvers as $solver_id => $solver) { + if ($solver['type'] == 'group_member') { + $group_id = $map_user_to_group[$solver_id]; + + $solvers[$group_id]['members'][$solver_id] = $solver; // store solver as group member + unset($solvers[$solver_id]); // delete him as single solver + } + } + + // change solution user ids to group ids // + + foreach ($solutions as $solver_id => $exercise_solutions) { + if (isset($map_user_to_group[$solver_id])) { + $group_id = $map_user_to_group[$solver_id]; + + foreach ($exercise_solutions as $exercise_id => $solution) { + // always store most recent solution + if (!isset($solutions[$group_id][$exercise_id]) || $solution['time'] > $solutions[$group_id][$exercise_id]['time']) { + $solutions[$group_id][$exercise_id] = $solution; // store solution as group solution + } + unset($solutions[$solver_id][$exercise_id]); // delete single-solver-solution + } + } + } + + // remove hidden solver entries // + + if ($assignment->type !== 'exam') { + foreach ($solvers as $solver_id => $solver) { + if (!isset($solutions[$solver_id])) { // has no solutions + if (!$view || $view == 'todo') { + unset($solvers[$solver_id]); + } + } else if ($view == 'todo') { + foreach ($solutions[$solver_id] as $solution) { + if (!$solution['corrected']) { + continue 2; + } + } + + unset($solvers[$solver_id]); + } + } + } + + return [ + 'solvers' => $solvers, // ordered by name + 'exercises' => $exercises, // ordered by position + 'solutions' => $solutions // first single solvers then groups, furthermore unordered + ]; + } + + /** + * Counts uncorrected solutions for a assignment. + * + * @param $assignment_id The assignment id + * @return <code>null</code> if there does not exist any solution at all, else + * the number of uncorrected solutions + */ + private function count_uncorrected_solutions($assignment_id) + { + $db = DBManager::get(); + + $assignment = VipsAssignment::find($assignment_id); + $course_id = $assignment->range_id; + + // get all corrected and uncorrected solutions + $sql = "SELECT etask_responses.task_id, + etask_responses.user_id, + etask_responses.state + FROM etask_responses + LEFT JOIN seminar_user + ON seminar_user.user_id = etask_responses.user_id + AND seminar_user.Seminar_id = ? + WHERE etask_responses.assignment_id = ? + AND ( + seminar_user.status IS NULL OR + seminar_user.status NOT IN ('dozent', 'tutor') + ) + ORDER BY etask_responses.mkdate DESC"; + $result = $db->prepare($sql); + $result->execute([$course_id, $assignment_id]); + + // no solutions at all + if ($result->rowCount() == 0) { + return null; + } + + // count uncorrected solutions + $uncorrected_solutions = 0; + $solution = []; + $group = []; + + foreach ($result as $row) { + $exercise_id = (int) $row['task_id']; + $user_id = $row['user_id']; + $corrected = (boolean) $row['state']; + + if (!array_key_exists($user_id, $group)) { + $group[$user_id] = $assignment->getUserGroup($user_id); + } + + if (!array_key_exists($exercise_id . '_' . $user_id, $solution)) { + if (isset($group[$user_id])) { + $members = array_column($assignment->getGroupMembers($group[$user_id]), 'user_id'); + } else { + $members = [$user_id]; + } + + foreach ($members as $user_id) { + $solution[$exercise_id . '_' . $user_id] = true; + } + + if (!$corrected) { + $uncorrected_solutions++; + } + } + } + + return $uncorrected_solutions; + } + + /** + * Return the appropriate CSS class for sortable column (if any). + * + * @param boolean $sort sort by this column + * @param boolean $desc set sort direction + */ + public function sort_class(bool $sort, ?bool $desc): string + { + return $sort ? ($desc ? 'sortdesc' : 'sortasc') : ''; + } +} diff --git a/app/views/vips/admin/edit_block.php b/app/views/vips/admin/edit_block.php new file mode 100644 index 0000000000000000000000000000000000000000..70710cfccc4bab821bf69b9469641545026a6ef5 --- /dev/null +++ b/app/views/vips/admin/edit_block.php @@ -0,0 +1,47 @@ +<?php +/** + * @var Vips_AdminController $controller + * @var VipsBlock $block + * @var VipsGroup[] $groups + */ +?> +<form class="default" action="<?= $controller->link_for('vips/admin/store_block') ?>" data-secure method="POST"> + <?= CSRFProtection::tokenTag() ?> + + <? if ($block->id): ?> + <input type="hidden" name="block_id" value="<?= $block->id ?>"> + <? endif ?> + + <label> + <span class="required"><?= _('Blockname') ?></span> + <input type="text" name="block_name" required value="<?= htmlReady($block->name) ?>"> + </label> + + <label> + <?= _('Sichtbarkeit') ?> + <?= tooltipIcon(_('Blöcke und zugeordnete Aufgabenblätter können nur für bestimmte Gruppen sichtbar oder auch komplett unsichtbar gemacht werden.')) ?> + <select name="group_id"> + <option value="0"> + <?= _('Alle Teilnehmenden (keine Beschränkung)') ?> + </option> + <option value="" <?= !$block->visible ? 'selected' : '' ?>> + <?= _('Für Teilnehmende unsichtbar') ?> + </option> + <? foreach ($groups as $group): ?> + <option value="<?= $group->id ?>" <?= $block->group_id === $group->id ? 'selected' : '' ?>> + <?= sprintf(_('Gruppe „%s“'), htmlReady($group->name)) ?> + </option> + <? endforeach ?> + </select> + </label> + + <label> + <input type="checkbox" name="block_grouped" value="1" <?= $block->weight !== null ? 'checked' : '' ?>> + <?= _('Aufgabenblätter in der Bewertung gruppieren') ?> + <?= tooltipIcon(_('In der Ergebnisübersicht wird nur der Block anstelle der enthaltenen Aufgabenblätter aufgeführt.')) ?> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'store_block') ?> + </footer> +</form> diff --git a/app/views/vips/admin/edit_grades.php b/app/views/vips/admin/edit_grades.php new file mode 100644 index 0000000000000000000000000000000000000000..d83db43966e3847ecb8374cda6a56081b500b4b6 --- /dev/null +++ b/app/views/vips/admin/edit_grades.php @@ -0,0 +1,56 @@ +<?php +/** + * @var Vips_AdminController $controller + * @var array $grades + * @var bool $grade_settings + * @var array $percentages + * @var string[] $comments + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<form class="default" action="<?= $controller->link_for('vips/admin/store_grades') ?>" data-secure method="post"> + <?= CSRFProtection::tokenTag() ?> + + <table class="default"> + <caption> + <?= _('Notenverteilung') ?> + </caption> + <thead> + <tr> + <th><?= _('Note') ?></th> + <th><?= _('Schwellwert') ?></th> + <th><?= _('Kommentar') ?></th> + </tr> + </thead> + + <tbody> + <? for ($i = 0; $i < count($grades); ++$i): ?> + <? $class = $grade_settings && !$percentages[$i] ? 'quiet' : '' ?> + <tr class="<?= $class ?>"> + <td><?= htmlReady($grades[$i]) ?></td> + <td> + <input type="text" class="percent_input" name="percentage[<?= $i ?>]" value="<?= sprintf('%g', $percentages[$i]) ?>"> % + </td> + <td> + <input type="text" name="comment[<?= $i ?>]" value="<?= htmlReady($comments[$i]) ?>" <?= $class ? 'disabled' : '' ?>> + </td> + </tr> + <? endfor ?> + </tbody> + + <tfoot> + <tr> + <td class="smaller" colspan="3"> + <?= _('Wenn Sie eine bestimmte Notenstufe nicht verwenden wollen, lassen Sie das Feld für den Schwellwert leer.') ?> + </td> + </tr> + </tfoot> + </table> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> + </footer> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/config/index.php b/app/views/vips/config/index.php new file mode 100644 index 0000000000000000000000000000000000000000..d4b1f69bbe396bedd053dba2cdacdc028956b387 --- /dev/null +++ b/app/views/vips/config/index.php @@ -0,0 +1,90 @@ +<?php +/** + * @var Vips_ConfigController $controller + * @var Config $config + */ +?> +<form class="default width-1200" action="<?= $controller->link_for('vips/config/save') ?>" data-secure method="post"> + <?= CSRFProtection::tokenTag() ?> + <button hidden name="save"></button> + + <fieldset> + <legend> + <?= _('Einstellungen für Klausuren') ?> + </legend> + + <div class="label-text"> + <?= _('Klausurmodus aktivieren') ?> + </div> + + <label class="undecorated"> + <input type="checkbox" name="exam_mode" value="1" <?= $config->VIPS_EXAM_RESTRICTIONS ? 'checked' : '' ?>> + <?= _('Während einer Klausur den Zugriff auf andere Bereiche von Stud.IP sperren') ?> + <?= tooltipIcon(_('Gilt nur für Klausuren mit beschränktem IP-Zugriffsbereich.')) ?> + </label> + + <div class="label-text"> + <?= _('Vordefinierte IP-Bereiche für PC-Räume') ?> + </div> + + <table class="default"> + <thead> + <tr> + <th style="width: 20%;"> + <?= _('Raum') ?> + </th> + <th style="width: 75%;"> + <?= _('IP-Bereiche') ?> + <?= tooltipIcon($this->render_partial('vips/sheets/ip_range_tooltip'), false, true) ?> + </th> + <th class="actions"> + <?= _('Löschen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list"> + <? foreach ($config->VIPS_EXAM_ROOMS ?: [] as $room => $ip_range): ?> + <tr class="dynamic_row"> + <td> + <input type="text" name="room[]" value="<?= htmlReady($room) ?>"> + </td> + <td> + <input type="text" class="size-l validate_ip_range" name="ip_range[]" value="<?= htmlReady($ip_range) ?>"> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?> + </td> + </tr> + <? endforeach ?> + + <tr class="dynamic_row template"> + <td> + <input type="text" name="room[]"> + </td> + <td> + <input type="text" class="size-l validate_ip_range" name="ip_range[]"> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?> + </td> + </tr> + + <tr> + <th colspan="3"> + <?= Studip\Button::create(_('Eintrag hinzufügen'), 'add_room', ['class' => 'add_dynamic_row']) ?> + </th> + </tr> + </tbody> + </table> + + <label> + <?= _('Teilnahmebedingungen vor Beginn einer Klausur') ?> + <textarea name="exam_terms" class="size-l wysiwyg"><?= wysiwygReady($config->VIPS_EXAM_TERMS) ?></textarea> + </label> + </fieldset> + + <footer> + <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> + </footer> +</form> diff --git a/app/views/vips/config/pending_assignments.php b/app/views/vips/config/pending_assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..8a21874fec14e62c1c93f35297fe6be83583172e --- /dev/null +++ b/app/views/vips/config/pending_assignments.php @@ -0,0 +1,76 @@ +<?php +/** + * @var Vips_ConfigController $controller + * @var VipsAssignment[] $assignments + */ +?> +<? if (count($assignments) === 0): ?> + <?= MessageBox::info(_('Es gibt zur Zeit keine anstehenden Klausuren.')) ?> +<? else: ?> + <table class="default sortable-table" data-sortlist="[[1,0]]"> + <caption> + <?= _('Klausuren') ?> + <div class="actions"> + <?= sprintf(ngettext('%d Klausur', '%d Klausuren', count($assignments)), count($assignments)) ?> + </div> + </caption> + + <thead> + <tr class="sortable"> + <th data-sort="text" style="width: 35%;"> + <?= _('Titel') ?> + </th> + + <th data-sort="text" style="width: 10%;"> + <?= _('Start') ?> + </th> + + <th data-sort="text" style="width: 10%;"> + <?= _('Ende') ?> + </th> + + <th data-sort="text" style="width: 15%;"> + <?= _('Autor/-in') ?> + </th> + + <th data-sort="text" style="width: 30%;"> + <?= _('Veranstaltung') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignments as $assignment): ?> + <tr> + <td> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $assignment->range_id, 'assignment_id' => $assignment->id]) ?>"> + <?= $assignment->getTypeIcon() ?> + <?= htmlReady($assignment->test->title) ?> + </a> + <? if ($assignment->isRunning() && !$assignment->active): ?> + (<?= _('unterbrochen') ?>) + <? endif ?> + </td> + + <td data-text="<?= htmlReady($assignment->start) ?>"> + <?= date('d.m.Y, H:i', $assignment->start) ?> + </td> + + <td data-text="<?= htmlReady($assignment->end) ?>"> + <?= date('d.m.Y, H:i', $assignment->end) ?> + </td> + + <td> + <?= htmlReady($assignment->test->user->getFullName('no_title_rev')) ?> + </td> + + <td> + <a href="<?= URLHelper::getLink('seminar_main.php', ['cid' => $assignment->range_id]) ?>"> + <?= htmlReady($assignment->course->name) ?> + </a> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endif ?> diff --git a/app/views/vips/exam_mode/index.php b/app/views/vips/exam_mode/index.php new file mode 100644 index 0000000000000000000000000000000000000000..50bf0a68169608aa07aaddd9146ccda597d0e0cd --- /dev/null +++ b/app/views/vips/exam_mode/index.php @@ -0,0 +1,46 @@ +<?php +/** + * @var array $courses + */ +?> +<? if (count($courses)) : ?> + <table class="default width-1200"> + <caption> + <?= _('Bitte wählen Sie den Kurs, in dem Sie die Klausur schreiben möchten:') ?> + </caption> + + <thead> + <tr> + <th style="width: 5%;"></th> + <th style="width: 65%;"><?= _('Name') ?></th> + <th style="width: 30%;"><?= _('Inhalt') ?></th> + </tr> + </thead> + + <tbody> + <? foreach ($courses as $course_id => $course_name) : ?> + <? $nav = VipsModule::$instance->getIconNavigation($course_id, null, null) ?> + <? if ($nav): ?> + <tr> + <td> + <?= CourseAvatar::getAvatar($course_id)->getImageTag(Avatar::SMALL) ?> + </td> + <td> + <a href="<?= URLHelper::getLink($nav->getURL(), ['cid' => $course_id]) ?>"> + <?= htmlReady($course_name) ?> + </a> + </td> + <td> + <a href="<?= URLHelper::getLink($nav->getURL(), ['cid' => $course_id]) ?>"> + <?= $nav->getImage()->asImg($nav->getLinkAttributes()) ?> + </a> + </td> + </tr> + <? endif ?> + <? endforeach ?> + </tbody> + </table> +<? else : ?> + <? /* this should never be shown, but can be reached directly by URL */ ?> + <?= MessageBox::info(_('Zur Zeit laufen keine Klausuren.')) ?> +<? endif ?> diff --git a/app/views/vips/exercises/ClozeTask/correct.php b/app/views/vips/exercises/ClozeTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..8e4aac1bd69f39051e3c754029922cad1a30df29 --- /dev/null +++ b/app/views/vips/exercises/ClozeTask/correct.php @@ -0,0 +1,42 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + */ +?> +<div class="description"> + <!-- + <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text) : ?> + --><?= $text ?><!-- + <? if (isset($exercise->task['answers'][$blank])) : ?> + <? if ($solution->id): ?> + <? if ($results[$blank]['points'] == 1): ?> + --><span class="correct_item math-tex"><?= htmlReady($response[$blank]) ?><!-- + --><?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_inline', 'title' => _('richtig')]) ?><!-- + --></span><!-- + <? elseif ($results[$blank]['points'] == 0.5): ?> + --><span class="fuzzy_item math-tex"><?= htmlReady($response[$blank]) ?><!-- + --><?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['class' => 'correction_inline', 'title' => _('fast richtig')]) ?><!-- + --></span><!-- + <? elseif (empty($edit_solution) || $results[$blank]['safe']): ?> + --><span class="wrong_item math-tex"><?= htmlReady($response[$blank]) ?><!-- + --><?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('falsch')]) ?><!-- + --></span><!-- + <? else: ?> + --><span class="wrong_item math-tex"><?= htmlReady($response[$blank]) ?><!-- + --><?= Icon::create('question', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('unbekannte Antwort')]) ?><!-- + --></span><!-- + <? endif ?> + <? endif ?> + <? if ($show_solution && (empty($results) || $results[$blank]['points'] < 1) && $exercise->correctAnswers($blank)): ?> + --><span class="correct_item math-tex"><?= htmlReady(implode(' | ', $exercise->correctAnswers($blank))) ?></span><!-- + <? endif ?> + <? endif ?> + <? endforeach ?> + --> +</div> + +<?= $this->render_partial('exercises/evaluation_mode_info', ['evaluation_mode' => false]) ?> diff --git a/app/views/vips/exercises/ClozeTask/edit.php b/app/views/vips/exercises/ClozeTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..51c04a92c3ea01701d983b411f94f5481b7bef26 --- /dev/null +++ b/app/views/vips/exercises/ClozeTask/edit.php @@ -0,0 +1,67 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<? $tooltip = sprintf('<p>%s:<br>[[ ... ]]</p><p>%s:<br>[[ ... | ... | ... ]]</p><p>%s:<br>[[ ... | ... | *... ]]</p><p>%s:<br>[[: ... | ... | *... ]]</p>', + _('Lücke hinzufügen'), _('Mehrere Lösungen mit | trennen'), _('Falsche Antworten mit * markieren'), _('Auswahl aus Liste statt Eingabe')) ?> + +<label> + <?= _('Lückentext') ?> <?= tooltipIcon($tooltip, false, true) ?> + <? $cloze_text = $exercise->getClozeText() ?> + <textarea name="cloze_text" class="character_input size-l wysiwyg" rows="<?= $exercise->textareaSize($cloze_text) ?>"><?= wysiwygReady($cloze_text) ?></textarea> +</label> + +<label> + <?= _('Antwortmodus') ?> + + <select name="layout" onchange="$(this).parent().next().toggle(this.value === '')"> + <option value=""> + <?= _('Texteingabe') ?> + </option> + <option value="select" <?= $exercise->interactionType() === 'select' ? 'selected' : '' ?>> + <?= _('Antwort aus Liste auswählen') ?> + </option> + <option value="drag" <?= $exercise->interactionType() === 'drag' ? 'selected' : '' ?>> + <?= _('Antwort in das Feld ziehen') ?> + </option> + </select> +</label> + +<div style="<?= $exercise->interactionType() !== 'input' ? 'display: none;' : '' ?>"> + <label> + <?= _('Art des Textvergleichs') ?> + + <select name="compare" onchange="$(this).parent().next('label').toggle($(this).val() === 'numeric')"> + <option value=""> + <?= _('Groß-/Kleinschreibung unterscheiden') ?> + </option> + <option value="ignorecase" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'ignorecase' ? 'selected' : '' ?>> + <?= _('Groß-/Kleinschreibung ignorieren') ?> + </option> + <option value="numeric" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? 'selected' : '' ?>> + <?= _('Numerischer Wertevergleich (ggf. mit Einheit)') ?> + </option> + </select> + </label> + + <label style="<?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? '' : 'display: none;' ?>"> + <?= _('Erlaubte relative Abweichung vom korrekten Wert') ?> + <br> + <input type="text" class="size-s" style="display: inline; text-align: right;" + name="epsilon" value="<?= isset($exercise->task['epsilon']) ? sprintf('%g', $exercise->task['epsilon'] * 100) : '0' ?>"> % + </label> + + <label> + <input type="checkbox" <?= isset($exercise->task['input_width']) ? 'checked' : '' ?> onchange="$(this).next('select').attr('disabled', !this.checked)"> + <?= _('Feste Breite der Eingabefelder:') ?> + + <select name="input_width" style="display: inline; width: auto;" <?= isset($exercise->task['input_width']) ? '' : 'disabled' ?>> + <? foreach ([_('kurz'), _('mittel'), _('lang'), _('maximal')] as $key => $label): ?> + <option value="<?= $key ?>" <?= isset($exercise->task['input_width']) && $exercise->task['input_width'] == $key ? 'selected' : '' ?>> + <?= htmlReady($label) ?> + </option> + <? endforeach ?> + </select> + </label> +</div> diff --git a/app/views/vips/exercises/ClozeTask/print.php b/app/views/vips/exercises/ClozeTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..fce4bc41c9ced934070a5a0c26cf47b84b40736f --- /dev/null +++ b/app/views/vips/exercises/ClozeTask/print.php @@ -0,0 +1,62 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var array $response + * @var array $results + * @var bool $print_correction + * @var bool $show_solution + */ +?> +<div class="description"> + <!-- + <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text): ?> + --><?= $text ?><!-- + <? if (isset($exercise->task['answers'][$blank])) : ?> + <? if ($solution->id && $response[$blank] !== ''): ?> + --><span class="math-tex" style="text-decoration: underline;"> <?= htmlReady($response[$blank]) ?> </span><!-- + <? if ($print_correction): ?> + <? if ($results[$blank]['points'] == 1): ?> + --><?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?><!-- + <? elseif ($results[$blank]['points'] == 0.5): ?> + --><?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?><!-- + <? else: ?> + --><?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?><!-- + <? endif ?> + <? endif ?> + <? elseif ($exercise->isSelect($blank)): ?> + <? foreach ($exercise->task['answers'][$blank] as $index => $option) : ?> + --><?= $index ? ' | ' : '' ?><!-- + --><?= Assets::img('choice_unchecked.svg', ['style' => 'vertical-align: text-bottom;']) ?> <!-- + --><span class="math-tex" style="border-bottom: 1px dotted black;"><?= htmlReady($option['text']) ?></span><!-- + <? endforeach ?> + <? else: ?> + --><?= str_repeat('_', $exercise->getInputWidth($blank)) ?><!-- + <? endif ?> + <? if ($show_solution && (empty($results) || $results[$blank]['points'] < 1) && $exercise->correctAnswers($blank)): ?> + --><span class="correct_item math-tex"><?= htmlReady(implode(' | ', $exercise->correctAnswers($blank))) ?></span><!-- + <? endif ?> + <? endif ?> + <? endforeach ?> + --> +</div> + +<? if ($exercise->interactionType() === 'drag'): ?> + <div class="label-text"> + <? if ($print_correction): ?> + <?= _('Nicht zugeordnete Antworten:') ?> + <? else: ?> + <?= _('Antwortmöglichkeiten:') ?> + <? endif ?> + </div> + + <ol> + <? foreach ($exercise->availableAnswers($solution) as $item): ?> + <li> + <span class="math-tex"><?= htmlReady($item) ?></span> + </li> + <? endforeach ?> + </ol> +<? endif ?> + +<?= $this->render_partial('exercises/evaluation_mode_info', ['evaluation_mode' => false]) ?> diff --git a/app/views/vips/exercises/ClozeTask/solve.php b/app/views/vips/exercises/ClozeTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..bc17a032c10bb3aba865ed7b8e1aeb093358eba4 --- /dev/null +++ b/app/views/vips/exercises/ClozeTask/solve.php @@ -0,0 +1,46 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var array $response + */ +?> +<div class="description"> + <!-- + <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text): ?> + --><?= $text ?><!-- + <? if (isset($exercise->task['answers'][$blank])) : ?> + <? if ($exercise->interactionType() === 'drag'): ?> + --><span class="cloze_drop math-tex" title="<?= _('Elemente hier ablegen') ?>"> + <input type="hidden" name="answer[<?= $blank ?>]" value="<?= htmlReady($response[$blank] ?? '') ?>"> + <? if (isset($response[$blank]) && $response[$blank] !== ''): ?> + <span class="cloze_item drag-handle" data-value="<?= htmlReady($response[$blank]) ?>"><?= htmlReady($response[$blank]) ?></span> + <? endif ?> + </span><!-- + <? elseif ($exercise->isSelect($blank)): ?> + --><select class="cloze_select" name="answer[<?= $blank ?>]"> + <? if ($exercise->task['answers'][$blank][0]['text'] !== ''): ?> + <option value=""> </option> + <? endif ?> + <? foreach ($exercise->task['answers'][$blank] as $option): ?> + <option value="<?= htmlReady($option['text']) ?>" <?= trim($option['text']) === ($response[$blank] ?? '') ? ' selected' : '' ?>> + <?= htmlReady($option['text']) ?> + </option> + <? endforeach ?> + </select><!-- + <? else: ?> + --><input type="text" class="character_input cloze_input" name="answer[<?= $blank ?>]" + style="width: <?= $exercise->getInputWidth($blank) ?>em;" value="<?= htmlReady($response[$blank] ?? '') ?>"><!-- + <? endif ?> + <? endif ?> + <? endforeach ?> + --> +</div> + +<? if ($exercise->interactionType() === 'drag'): ?> + <span class="cloze_drop cloze_items math-tex"> + <? foreach ($exercise->availableAnswers($solution) as $item): ?> + <span class="cloze_item drag-handle" data-value="<?= htmlReady($item) ?>"><?= htmlReady($item) ?></span> + <? endforeach ?> + </span> +<? endif ?> diff --git a/app/views/vips/exercises/ClozeTask/xml.php b/app/views/vips/exercises/ClozeTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..65ebb98ccf008f9384f2af5a5a1a59f4657e5580 --- /dev/null +++ b/app/views/vips/exercises/ClozeTask/xml.php @@ -0,0 +1,63 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="cloze-<?= $exercise->interactionType() ?>"> + <description> + <? foreach (explode('[[]]', $exercise->task['text']) as $blank => $text): ?> + <text><?= htmlReady($text) ?></text> + <? if (isset($exercise->task['answers'][$blank])): ?> + <answers<? if ($exercise->isSelect($blank, false)): ?> select="true"<? endif ?>> + <? foreach ($exercise->task['answers'][$blank] as $answer): ?> + <answer score="<?= $answer['score'] ?>"><?= htmlReady($answer['text']) ?></answer> + <? endforeach ?> + </answers> + <? endif ?> + <? endforeach ?> + </description> + <? if (isset($exercise->task['input_width'])): ?> + <submission-hints> + <input type="text" width="<?= (int) $exercise->task['input_width'] ?>"/> + </submission-hints> + <? endif ?> + <? if (!empty($exercise->task['compare'])): ?> + <evaluation-hints> + <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/> + <? if ($exercise->task['compare'] === 'numeric'): ?> + <input-data type="relative-epsilon"> + <?= (float) $exercise->task['epsilon'] ?> + </input-data> + <? endif ?> + </evaluation-hints> + <? endif ?> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/MatchingTask/correct.php b/app/views/vips/exercises/MatchingTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..64faca600648a403db770cde8f784004b1f3cc4c --- /dev/null +++ b/app/views/vips/exercises/MatchingTask/correct.php @@ -0,0 +1,88 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + */ +?> +<? $exercise->sortAnswersById(); ?> + +<table class="default description inline-content"> + <thead> + <tr> + <th> + <?= _('Vorgegebener Text') ?> + </th> + + <th> + <?= _('Zugeordnete Antworten') ?> + </th> + + <? if ($show_solution): ?> + <th> + <?= _('Richtige Antworten') ?> + </th> + <? endif ?> + </tr> + </thead> + + <tbody> + <? foreach ($exercise->task['groups'] as $i => $group) : ?> + <tr style="vertical-align: top;"> + <td> + <div class="mc_item"> + <?= formatReady($group) ?> + </div> + </td> + + <td> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?> + <div class="<?= $exercise->isCorrectAnswer($answer, $i) ? 'correct_item' : 'mc_item' ?>"> + <?= formatReady($answer['text']) ?> + + <? if ($exercise->isCorrectAnswer($answer, $i)): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + </div> + <? endif ?> + <? endforeach ?> + </td> + + <? if ($show_solution): ?> + <td> + <? foreach ($exercise->correctAnswers($i) as $correct_answer): ?> + <div class="correct_item"> + <?= formatReady($correct_answer) ?> + </div> + <? endforeach ?> + </td> + <? endif ?> + </tr> + <? endforeach ?> + </tbody> +</table> + +<div class="label-text"> + <?= _('Nicht zugeordnete Antworten:') ?> +</div> + +<? foreach ($exercise->task['answers'] as $answer): ?> + <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?> + <div class="inline-block inline-content <?= $exercise->isCorrectAnswer($answer, -1) ? 'correct_item' : 'mc_item' ?>"> + <?= formatReady($answer['text']) ?> + + <? if ($solution->id): ?> + <? if ($exercise->isCorrectAnswer($answer, -1)): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_inline', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </div> + <? endif ?> +<? endforeach ?> diff --git a/app/views/vips/exercises/MatchingTask/edit.php b/app/views/vips/exercises/MatchingTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..3ddc8f557d999dd248f330e846960c5cbcf49140 --- /dev/null +++ b/app/views/vips/exercises/MatchingTask/edit.php @@ -0,0 +1,117 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<label> + <input class="rh_select_type" type="checkbox" name="multiple" value="1" <?= $exercise->isMultiSelect() ? 'checked' : '' ?>> + <?= _('Mehrfachzuordnungen zu einem vorgegebenen Text erlauben') ?> +</label> + +<table class="default description <?= $exercise->isMultiSelect() ? '' : 'rh_single' ?>"> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Vorgegebener Text') ?> + </th> + <th style="width: 50%;"> + <?= _('Zuzuordnende Antworten') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list" style="vertical-align: top;"> + <? foreach ($exercise->task['groups'] as $i => $group): ?> + <? $size = $exercise->flexibleInputSize($group) ?> + + <tr class="dynamic_row"> + <td class="size_toggle size_<?= $size ?>"> + <?= $this->render_partial('exercises/flexible_input', ['name' => "default[$i]", 'value' => $group, 'size' => $size]) ?> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Zuordnung löschen')]) ?> + </td> + <td class="dynamic_list"> + <? $j = 0 ?> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if ($answer['group'] == $i): ?> + <? $size = $exercise->flexibleInputSize($answer['text']) ?> + + <div class="dynamic_row size_toggle size_<?= $size ?>"> + <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i][$j]", 'value' => $answer['text'], 'size' => $size]) ?> + <input type="hidden" name="id[<?= $i ?>][<?= $j++ ?>]" value="<?= $answer['id'] ?>"> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + <? endif ?> + <? endforeach ?> + + <div class="dynamic_row size_toggle size_small template"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => "answer[$i]", 'size' => 'small']) ?> + <input type="hidden" data-name="id[<?= $i ?>]"> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row rh_add_answer']) ?> + </td> + </tr> + <? endforeach ?> + + <tr class="dynamic_row template"> + <td class="size_toggle size_small"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'default', 'size' => 'small']) ?> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Zuordnung löschen')]) ?> + </td> + <td class="dynamic_list"> + <div class="dynamic_row size_toggle size_small template"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => ':answer', 'size' => 'small']) ?> + <input type="hidden" data-name=":id"> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row rh_add_answer']) ?> + </td> + </tr> + + <tr> + <th colspan="2"> + <?= Studip\Button::create(_('Zuordnung hinzufügen'), 'add_pairs', ['class' => 'add_dynamic_row']) ?> + </th> + </tr> + </tbody> +</table> + +<div class="label-text"> + <?= _('Distraktoren (optional)') ?> + <?= tooltipIcon(_('Weitere Antworten, die keinem Text zugeordnet werden dürfen.')) ?> +</div> + +<div class="dynamic_list"> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if ($answer['group'] == -1): ?> + <? $size = $exercise->flexibleInputSize($answer['text']) ?> + + <div class="dynamic_row mc_row"> + <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['name' => '_answer[]', 'value' => $answer['text'], 'size' => $size]) ?> + <input type="hidden" name="_id[]" value="<?= $answer['id'] ?>"> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Distraktor löschen')]) ?> + </div> + <? endif ?> + <? endforeach ?> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => '', 'name' => '_answer[]', 'size' => 'small']) ?> + <input type="hidden" name="_id[]"> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Distraktor löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Distraktor hinzufügen'), 'add_false_answer', ['class' => 'add_dynamic_row']) ?> +</div> diff --git a/app/views/vips/exercises/MatchingTask/print.php b/app/views/vips/exercises/MatchingTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..a9617dfdad938511b0728a527abae8dc5acb105f --- /dev/null +++ b/app/views/vips/exercises/MatchingTask/print.php @@ -0,0 +1,95 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var bool $print_correction + * @var bool $show_solution + */ +?> +<? $exercise->sortAnswersById(); ?> + +<table class="content description inline-content" style="min-width: 40em;"> + <thead> + <tr> + <th> + <?= _('Vorgegebener Text') ?> + </th> + + <th> + <?= _('Zugeordnete Antworten') ?> + </th> + + <? if ($show_solution) : ?> + <th> + <?= _('Richtige Antworten') ?> + </th> + <? endif ?> + </tr> + </thead> + + <tbody> + <? foreach ($exercise->task['groups'] as $i => $group) : ?> + <tr style="vertical-align: top;"> + <td> + <div class="mc_item"> + <?= formatReady($group) ?> + </div> + </td> + + <td> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?> + <div class="<?= $print_correction && $exercise->isCorrectAnswer($answer, $i) ? 'correct_item' : 'mc_item' ?>"> + <?= formatReady($answer['text']) ?> + + <? if ($print_correction): ?> + <? if ($exercise->isCorrectAnswer($answer, $i)) : ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else : ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </div> + <? endif ?> + <? endforeach ?> + </td> + + <? if ($show_solution) : ?> + <td> + <? foreach ($exercise->correctAnswers($i) as $correct_answer): ?> + <div class="mc_item"> + <?= formatReady($correct_answer) ?> + </div> + <? endforeach ?> + </td> + <? endif ?> + </tr> + <? endforeach ?> + </tbody> +</table> + +<div class="label-text"> + <? if ($print_correction): ?> + <?= _('Nicht zugeordnete Antworten:') ?> + <? else: ?> + <?= _('Antwortmöglichkeiten:') ?> + <? endif ?> +</div> + +<ol class="inline-content"> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?> + <li> + <?= formatReady($answer['text']) ?> + + <? if ($solution->id && $print_correction): ?> + <? if ($exercise->isCorrectAnswer($answer, -1)): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </li> + <? endif ?> + <? endforeach ?> +</ol> diff --git a/app/views/vips/exercises/MatchingTask/solve.php b/app/views/vips/exercises/MatchingTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..82028195ddf9d3ae9907029ecc18632aa461ff4c --- /dev/null +++ b/app/views/vips/exercises/MatchingTask/solve.php @@ -0,0 +1,38 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<? $exercise->sortAnswersById(); ?> + +<table class="rh_table inline-content"> + <? foreach ($exercise->task['groups'] as $i => $group): ?> + <tr style="vertical-align: top"> + <td class="rh_label"> + <?= formatReady($group) ?> + </td> + <td class="rh_list <?= htmlReady($exercise->task['select']) ?>" data-group="<?= $i ?>" title="<?= _('Elemente hier ablegen') ?>"> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?> + <div class="rh_item drag-handle" tabindex="0"> + <?= formatReady($answer['text']) ?> + <input type="hidden" name="answer[<?= $answer['id'] ?>]" value="<?= $i ?>"> + </div> + <? endif ?> + <? endforeach ?> + </td> + <? if ($i == 0): ?> + <td rowspan="<?= count($exercise->task['groups']) ?>" class="rh_list answer_container" data-group="-1"> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?> + <div class="rh_item drag-handle" tabindex="0"> + <?= formatReady($answer['text']) ?> + <input type="hidden" name="answer[<?= $answer['id'] ?>]" value="-1"> + </div> + <? endif ?> + <? endforeach ?> + </td> + <? endif ?> + </tr> + <? endforeach ?> +</table> diff --git a/app/views/vips/exercises/MatchingTask/xml.php b/app/views/vips/exercises/MatchingTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..41d35af65119e65cbc56aa6a3f3ba6893a99379d --- /dev/null +++ b/app/views/vips/exercises/MatchingTask/xml.php @@ -0,0 +1,50 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="<?= $exercise->isMultiSelect() ? 'matching-multiple' : 'matching' ?>"> + <choices> + <? foreach ($exercise->task['groups'] as $group): ?> + <choice type="group"> + <?= htmlReady($group) ?> + </choice> + <? endforeach ?> + </choices> + <answers> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="1" correct="<?= $answer['group'] ?>"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/MatrixChoiceTask/correct.php b/app/views/vips/exercises/MatrixChoiceTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..1b4f8ba1cdd4a91322ee3d1e4d7f8b5fdc88184a --- /dev/null +++ b/app/views/vips/exercises/MatrixChoiceTask/correct.php @@ -0,0 +1,42 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + * @var array $optional_choice + */ +?> +<table class="description inline-content"> + <? foreach ($exercise->task['answers'] as $key => $entry): ?> + <tr class="mc_row"> + <td class="mc_item"> + <?= formatReady($entry['text']) ?> + </td> + + <td style="white-space: nowrap;"> + <? if (isset($response[$key]) && $response[$key] !== '' && $response[$key] != -1): ?> + <? if ($response[$key] == $entry['choice']): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + + <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?> + <span class="<?= $show_solution && $entry['choice'] == $val ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$key]) && $response[$key] === "$val"): ?> + <?= Assets::img('choice_checked.svg', ['style' => 'margin-left: 1ex;']) ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg', ['style' => 'margin-left: 1ex;']) ?> + <? endif ?> + <?= htmlReady($label) ?> + </span> + <? endforeach ?> + </td> + </tr> + <? endforeach ?> +</table> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MatrixChoiceTask/edit.php b/app/views/vips/exercises/MatrixChoiceTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..946a21784368cca12d7329ad6708ac10b1234a06 --- /dev/null +++ b/app/views/vips/exercises/MatrixChoiceTask/edit.php @@ -0,0 +1,91 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<div class="label-text"> + <?= _('Auswahlmöglichkeiten') ?> +</div> + +<div class="choice_list dynamic_list mc_row"> + <? foreach ($exercise->task['choices'] as $i => $choice): ?> + <span class="dynamic_row"> + <input type="text" class="character_input size-s" name="choice[<?= $i ?>]" value="<?= htmlReady($choice) ?>"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Auswahlmöglichkeit löschen')]) ?> + / + </span> + <? endforeach ?> + + <span class="dynamic_row template"> + <input type="text" class="character_input size-s" data-name="choice"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Auswahlmöglichkeit löschen')]) ?> + / + </span> + + <?= Icon::create('add')->asInput(['class' => 'add_dynamic_row', 'title' => _('Auswahlmöglichkeit hinzufügen')]) ?> +</div> + +<label> + <input type="checkbox" name="optional" value="1" <?= $exercise->options['optional'] ? 'checked' : '' ?>> + <?= _('Auswahlmöglichkeit „keine Antwort“ hinzufügen (ohne Bewertung)') ?> +</label> + +<div class="label-text"> + <?= _('Fragen/Aussagen') ?> +</div> + +<div class="dynamic_list"> + <? foreach ($exercise->task['answers'] as $i => $answer): ?> + <? $size = $exercise->flexibleInputSize($answer['text']); ?> + + <div class="dynamic_row mc_row"> + <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i]", 'value' => $answer['text'], 'size' => $size]) ?> + </label> + + <span class="choice_select dynamic_list"> + <? foreach ($exercise->task['choices'] as $val => $choice): ?> + <label class="dynamic_row undecorated"> + <input type="radio" name="correct[<?= $i ?>]" value="<?= $val ?>" <? if ($answer['choice'] === $val): ?>checked<? endif ?>> + <span><?= htmlReady($choice) ?></span> + </label> + <? endforeach ?> + + <label class="dynamic_row undecorated template"> + <input type="radio" name="correct[<?= $i ?>]" data-value> + <span></span> + </label> + </span> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Frage löschen')]) ?> + </div> + <? endforeach ?> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'answer', 'size' => 'small']) ?> + </label> + + <span class="choice_select dynamic_list"> + <? foreach ($exercise->task['choices'] as $val => $choice): ?> + <label class="dynamic_row undecorated"> + <input type="radio" data-name="correct" value="<?= $val ?>"> + <span><?= htmlReady($choice) ?></span> + </label> + <? endforeach ?> + + <label class="dynamic_row undecorated template"> + <input type="radio" data-name="correct" data-value=":value"> + <span></span> + </label> + </span> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Frage löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Frage hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> +</div> + +<div class="smaller"> + <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?> +</div> diff --git a/app/views/vips/exercises/MatrixChoiceTask/print.php b/app/views/vips/exercises/MatrixChoiceTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..314730fafd59c1bb3acfe455b502642596d78765 --- /dev/null +++ b/app/views/vips/exercises/MatrixChoiceTask/print.php @@ -0,0 +1,41 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var bool $print_correction + * @var bool $show_solution + * @var array $optional_choice + */ +?> +<table class="description inline-content"> + <? foreach ($exercise->task['answers'] as $key => $entry): ?> + <tr class="mc_row"> + <td class="mc_item"> + <?= formatReady($entry['text']) ?> + </td> + + <td style="white-space: nowrap;"> + <? if (isset($response[$key]) && $response[$key] !== '' && $response[$key] != -1 && $print_correction): ?> + <? if ($response[$key] == $entry['choice']): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + + <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?> + <span class="<?= $show_solution && $entry['choice'] == $val ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$key]) && $response[$key] === "$val"): ?> + <?= Assets::img('choice_checked.svg', ['style' => 'margin-left: 1ex;']) ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg', ['style' => 'margin-left: 1ex;']) ?> + <? endif ?> + <?= htmlReady($label) ?> + </span> + <? endforeach ?> + </td> + </tr> + <? endforeach ?> +</table> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MatrixChoiceTask/solve.php b/app/views/vips/exercises/MatrixChoiceTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..5b767641cc9a0b9f8e367e7684ce76a7f0bd5032 --- /dev/null +++ b/app/views/vips/exercises/MatrixChoiceTask/solve.php @@ -0,0 +1,27 @@ +<?php +/** + * @var ClozeTask $exercise + * @var array $optional_choice + */ +?> +<table class="description inline-content"> + <? foreach ($exercise->task['answers'] as $key => $entry): ?> + <tr> + <td> + <?= formatReady($entry['text']) ?> + </td> + + <td style="white-space: nowrap;"> + <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="answer[<?= $key ?>]" value="<?= $val ?>" + <? if (!isset($response[$key]) && $val == -1 || isset($response[$key]) && $response[$key] === "$val"): ?>checked<? endif ?>> + <?= htmlReady($label) ?> + </label> + <? endforeach ?> + </td> + </tr> + <? endforeach ?> +</table> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MatrixChoiceTask/xml.php b/app/views/vips/exercises/MatrixChoiceTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..2b8238b13e84078c0dc4b2228f8a9149b36118f1 --- /dev/null +++ b/app/views/vips/exercises/MatrixChoiceTask/xml.php @@ -0,0 +1,51 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + * @var array $optional_choice + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="choice-multiple"> + <choices> + <? foreach ($exercise->task['choices'] + $optional_choice as $key => $choice): ?> + <choice type="<?= $key == 0 ? 'yes' : ($key == 1 ? 'no' : ($key == -1 ? 'none' : 'group')) ?>"> + <?= htmlReady($choice) ?> + </choice> + <? endforeach ?> + </choices> + <answers> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="<?= $answer['choice'] ? 0 : 1 ?>" correct="<?= (int) $answer['choice'] ?>"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/MultipleChoiceTask/correct.php b/app/views/vips/exercises/MultipleChoiceTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..2f2a6dc80a55d4171fb03922e09c569f929fed24 --- /dev/null +++ b/app/views/vips/exercises/MultipleChoiceTask/correct.php @@ -0,0 +1,32 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + */ +?> +<div class="mc_list inline-content"> + <? foreach ($exercise->task['answers'] as $key => $entry): ?> + <div class="mc_flex <?= $show_solution && $entry['score'] ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$key]) && $response[$key]): ?> + <?= Assets::img('choice_checked.svg') ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg') ?> + <? endif ?> + + <?= formatReady($entry['text']) ?> + + <? if (isset($response[$key])): ?> + <? if ((int) $response[$key] == $entry['score']): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </div> + <? endforeach ?> +</div> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MultipleChoiceTask/edit.php b/app/views/vips/exercises/MultipleChoiceTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..c1ab69f0f78cbdfe553f2f69d5dfd3e91fcf7c09 --- /dev/null +++ b/app/views/vips/exercises/MultipleChoiceTask/edit.php @@ -0,0 +1,46 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<div class="label-text"> + <?= _('Antwortalternativen') ?> +</div> + +<div class="dynamic_list"> + <? foreach ($exercise->task['answers'] as $i => $answer): ?> + <? $size = $exercise->flexibleInputSize($answer['text']); ?> + + <div class="dynamic_row mc_row"> + <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i]", 'value' => $answer['text'], 'size' => $size]) ?> + </label> + + <label class="undecorated" style="padding: 1ex;"> + <input type="checkbox" name="correct[<?= $i ?>]" value="1"<? if ($answer['score']): ?> checked<? endif ?>> + <?= _('richtig') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + <? endforeach ?> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'answer', 'size' => 'small']) ?> + </label> + + <label class="undecorated" style="padding: 1ex;"> + <input type="checkbox" data-name="correct" value="1"> + <?= _('richtig') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> +</div> + +<div class="smaller"> + <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?> +</div> diff --git a/app/views/vips/exercises/MultipleChoiceTask/print.php b/app/views/vips/exercises/MultipleChoiceTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..d352f17375af4cf9c0105690851e54b02d8c8341 --- /dev/null +++ b/app/views/vips/exercises/MultipleChoiceTask/print.php @@ -0,0 +1,30 @@ +<?php +/** + * @var ClozeTask $exercise + * @var bool $print_correction + * @var bool $show_solution + */ +?> +<div class="mc_list inline-content"> + <? foreach ($exercise->task['answers'] as $key => $entry): ?> + <div class="mc_flex <?= $show_solution && $entry['score'] ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$key]) && $response[$key]): ?> + <?= Assets::img('choice_checked.svg') ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg') ?> + <? endif ?> + + <?= formatReady($entry['text']) ?> + + <? if (isset($response[$key]) && $print_correction): ?> + <? if ((int) $response[$key] == $entry['score']): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </div> + <? endforeach ?> +</div> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MultipleChoiceTask/solve.php b/app/views/vips/exercises/MultipleChoiceTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..3c716e4f93dd5d1d1159b8be98122a4757ef1c2c --- /dev/null +++ b/app/views/vips/exercises/MultipleChoiceTask/solve.php @@ -0,0 +1,13 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<? foreach ($exercise->task['answers'] as $key => $entry): ?> + <label class="inline-content mc_flex"> + <input type="checkbox" name="answer[<?= $key ?>]" value="1"<? if (isset($response[$key]) && $response[$key]): ?> checked<? endif ?>> + <?= formatReady($entry['text']) ?> + </label> +<? endforeach ?> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/MultipleChoiceTask/xml.php b/app/views/vips/exercises/MultipleChoiceTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..4c1389daf23d45cf71968928b36a7c4e5833f373 --- /dev/null +++ b/app/views/vips/exercises/MultipleChoiceTask/xml.php @@ -0,0 +1,43 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="choice-multiple"> + <answers> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="<?= (int) $answer['score'] ?>"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/SequenceTask/correct.php b/app/views/vips/exercises/SequenceTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..72cfba5edaf6d94b7cadf2a030c0b3a352ad35d0 --- /dev/null +++ b/app/views/vips/exercises/SequenceTask/correct.php @@ -0,0 +1,85 @@ +<?php +/** + * @var bool $show_solution + * @var array|null $response + * @var Exercise $exercise + * @var array $results + */ +?> +<table class="default description inline-content nohover"> + <thead> + <tr> + <th> + <?= _('Anzuordnende Antworten') ?> + </th> + + <? if ($show_solution): ?> + <th> + <?= _('Richtige Antworten') ?> + </th> + <? endif ?> + </tr> + </thead> + + <tbody> + <tr style="vertical-align: top;"> + <td> + <? if ($response): ?> + <? foreach ($response as $n => $id): ?> + <? foreach ($exercise->task['answers'] as $i => $answer): ?> + <? if ($answer['id'] === $id): ?> + <? if ($exercise->task['compare'] === 'sequence'): ?> + <div class="neutral_item"> + <?= formatReady($answer['text']) ?> + </div> + + <? if ($n + 1 < count($response)): ?> + <div class="correction_marker sequence"> + <? if ($results[$i]['points'] == 1): ?> + <span style="color: green;">}</span> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?> + <? else: ?> + <span style="color: red;">}</span> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?> + <? endif ?> + </div> + <? endif ?> + <? elseif ($exercise->task['compare'] === 'position'): ?> + <div class="<?= $results[$i]['points'] == 1 ? 'correct_item' : 'mc_item' ?>"> + <?= formatReady($answer['text']) ?> + + <? if ($results[$i]['points'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + </div> + <? else: ?> + <div class="mc_item"> + <?= formatReady($answer['text']) ?> + </div> + <? endif ?> + <? endif ?> + <? endforeach ?> + <? endforeach ?> + <? else: ?> + <? foreach ($exercise->orderedAnswers($response) as $answer): ?> + <div class="mc_item"> + <?= formatReady($answer['text']) ?> + </div> + <? endforeach ?> + <? endif ?> + </td> + + <? if ($show_solution): ?> + <td> + <? foreach ($exercise->task['answers'] as $answer): ?> + <div class="correct_item"> + <?= formatReady($answer['text']) ?> + </div> + <? endforeach ?> + </td> + <? endif ?> + </tr> + </tbody> +</table> diff --git a/app/views/vips/exercises/SequenceTask/edit.php b/app/views/vips/exercises/SequenceTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..d8d61ccbb53925809d81843db8cca5c04fedb9ac --- /dev/null +++ b/app/views/vips/exercises/SequenceTask/edit.php @@ -0,0 +1,50 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<div class="label-text"> + <?= _('Anzuordnende Antworten') ?> +</div> + +<div class="dynamic_list sortable_list"> + <? foreach ($exercise->task['answers'] as $answer): ?> + <? $size = $exercise->flexibleInputSize($answer['text']); ?> + + <div class="dynamic_row mc_row sortable_item drag-handle" tabindex="0"> + <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['name' => 'answer[]', 'value' => $answer['text'], 'size' => $size]) ?> + <input type="hidden" name="id[]" value="<?= $answer['id'] ?>"> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + <? endforeach ?> + + <div class="dynamic_row mc_row sortable_item drag-handle template" tabindex="0"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => '', 'name' => 'answer[]', 'size' => 'small']) ?> + <input type="hidden" name="id[]"> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> +</div> + +<label> + <?= _('Verfahren zur Punktevergabe') ?> + + <select name="compare"> + <option value=""> + <?= _('Punkte nur bei vollständig korrekter Lösung') ?> + </option> + <option value="position" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'position' ? 'selected' : '' ?>> + <?= _('Punkte für Antworten an den korrekten Positionen') ?> + </option> + <option value="sequence" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'sequence' ? 'selected' : '' ?>> + <?= _('Punkte für Paare von Antworten in korrekter Reihenfolge') ?> + </option> + </select> +</label> diff --git a/app/views/vips/exercises/SequenceTask/print.php b/app/views/vips/exercises/SequenceTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..1ccb76d365e40fa3458159fd8b5fa17bd4d53379 --- /dev/null +++ b/app/views/vips/exercises/SequenceTask/print.php @@ -0,0 +1,92 @@ +<?php +/** + * @var bool $show_solution + * @var array|null $response + * @var Exercise $exercise + * @var bool $print_correction + * @var array $results + */ +?> +<table class="content description inline-content" style="min-width: 40em;"> + <thead> + <tr> + <th> + <?= _('Anzuordnende Antworten') ?> + </th> + + <? if ($show_solution): ?> + <th> + <?= _('Richtige Antworten') ?> + </th> + <? endif ?> + </tr> + </thead> + + <tbody> + <tr style="vertical-align: top;"> + <td> + <ol> + <? if ($response): ?> + <? foreach ($response as $n => $id): ?> + <? foreach ($exercise->task['answers'] as $i => $answer): ?> + <? if ($answer['id'] === $id): ?> + <? if ($exercise->task['compare'] === 'sequence'): ?> + <li class="neutral_item"> + <?= formatReady($answer['text']) ?> + </li> + + <? if ($print_correction && $n + 1 < count($response)): ?> + <div class="correction_marker sequence"> + <? if ($results[$i]['points'] == 1): ?> + <span style="color: green;">}</span> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?> + <? else: ?> + <span style="color: red;">}</span> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?> + <? endif ?> + </div> + <? endif ?> + <? elseif ($exercise->task['compare'] === 'position'): ?> + <li class="<?= $print_correction && $results[$i]['points'] == 1 ? 'correct_item' : 'mc_item' ?>"> + <?= formatReady($answer['text']) ?> + + <? if ($print_correction): ?> + <? if ($results[$i]['points'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </li> + <? else: ?> + <li class="mc_item"> + <?= formatReady($answer['text']) ?> + </li> + <? endif ?> + <? endif ?> + <? endforeach ?> + <? endforeach ?> + <? else: ?> + <? foreach ($exercise->orderedAnswers($response) as $answer): ?> + <li class="mc_item"> + <?= formatReady($answer['text']) ?> + </li> + <? endforeach ?> + <? endif ?> + </ol> + </td> + + <? if ($show_solution): ?> + <td> + <ol> + <? foreach ($exercise->task['answers'] as $answer): ?> + <li class="mc_item"> + <?= formatReady($answer['text']) ?> + </li> + <? endforeach ?> + </ol> + </td> + <? endif ?> + </tr> + </tbody> +</table> diff --git a/app/views/vips/exercises/SequenceTask/solve.php b/app/views/vips/exercises/SequenceTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..9ffe605b742b0528b1ffee567430208d86c00045 --- /dev/null +++ b/app/views/vips/exercises/SequenceTask/solve.php @@ -0,0 +1,14 @@ +<?php +/** + * @var Exercise $exercise + * @var array $response + */ +?> +<div class="mc_list rh_list inline-content" title="<?= _('Elemente hier ablegen') ?>"> + <? foreach ($exercise->orderedAnswers($response) as $answer): ?> + <div class="rh_item drag-handle" tabindex="0"> + <?= formatReady($answer['text']) ?> + <input type="hidden" name="answer[]" value="<?= $answer['id'] ?>"> + </div> + <? endforeach ?> +</div> diff --git a/app/views/vips/exercises/SequenceTask/xml.php b/app/views/vips/exercises/SequenceTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..244784c928b726b19641729a7e4e7a2bab764d8b --- /dev/null +++ b/app/views/vips/exercises/SequenceTask/xml.php @@ -0,0 +1,48 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="sequence"> + <answers> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="1"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if (!empty($exercise->task['compare'])): ?> + <evaluation-hints> + <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/> + </evaluation-hints> + <? endif ?> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/SingleChoiceTask/correct.php b/app/views/vips/exercises/SingleChoiceTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..cecc743089a77259fa702e02e1158c72afb925df --- /dev/null +++ b/app/views/vips/exercises/SingleChoiceTask/correct.php @@ -0,0 +1,41 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + * @var array $optional_answer + */ +?> +<? foreach ($exercise->task as $group => $task): ?> + <div <?= $group ? 'class="group_separator"' : '' ?>> + <? if (isset($task['description'])): ?> + <?= formatReady($task['description']) ?> + <? endif ?> + </div> + + <div class="mc_list inline-content"> + <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?> + <div class="mc_flex <?= $show_solution && $entry['score'] == 1 ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$group]) && $response[$group] === "$key"): ?> + <?= Assets::img('choice_checked.svg') ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg') ?> + <? endif ?> + + <?= formatReady($entry['text']) ?> + + <? if (isset($response[$group]) && $response[$group] === "$key"): ?> + <? if ($entry['score'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? elseif ($key != -1): ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + </div> + <? endforeach ?> + </div> +<? endforeach ?> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/SingleChoiceTask/edit.php b/app/views/vips/exercises/SingleChoiceTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..bd368c716fcf6b1310aa7549e86c31536fa36bf0 --- /dev/null +++ b/app/views/vips/exercises/SingleChoiceTask/edit.php @@ -0,0 +1,86 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<label> + <input type="checkbox" name="optional" value="1" <?= $exercise->options['optional'] ? 'checked' : '' ?>> + <?= _('Antwortalternative „keine Antwort“ hinzufügen (ohne Bewertung)') ?> +</label> + +<div class="label-text"> + <?= _('Antwortalternativen') ?> +</div> + +<div class="dynamic_list"> + <? foreach ($exercise->task as $j => $task): ?> + <div class="dynamic_list dynamic_row" style="border-bottom: 1px dotted grey;"> + <label class="hide_first"> + <?= _('Zwischentext') ?> + <textarea name="description[<?= $j ?>]" class="character_input size-l wysiwyg"><?= isset($task['description']) ? wysiwygReady($task['description']) : '' ?></textarea> + </label> + + <? foreach ($task['answers'] as $i => $answer): ?> + <? $size = $exercise->flexibleInputSize($answer['text']); ?> + + <div class="dynamic_row mc_row"> + <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$j][$i]", 'value' => $answer['text'], 'size' => $size]) ?> + </label> + + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="correct[<?= $j ?>]" value="<?= $i ?>"<? if ($answer['score'] == 1): ?> checked<? endif ?>> + <?= _('richtig') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + <? endforeach ?> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => "answer[$j]", 'size' => 'small']) ?> + </label> + + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="correct[<?= $j ?>]" data-value> + <?= _('richtig') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> + <?= Studip\Button::create(_('Antwortblock löschen'), 'del_group', ['class' => 'delete_dynamic_row']) ?> + </div> + <? endforeach ?> + + <div class="dynamic_list dynamic_row template" style="border-bottom: 1px dotted grey;"> + <label class="hide_first"> + <?= _('Zwischentext') ?> + <textarea data-name="description" class="character_input size-l wysiwyg-hidden"></textarea> + </label> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter size_toggle size_small undecorated"> + <?= $this->render_partial('exercises/flexible_input', ['data_name' => ':answer', 'size' => 'small']) ?> + </label> + + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" data-name="correct" data-value=":value"> + <?= _('richtig') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> + <?= Studip\Button::create(_('Antwortblock löschen'), 'del_group', ['class' => 'delete_dynamic_row']) ?> + </div> + + <?= Studip\Button::create(_('Antwortblock hinzufügen'), 'add_group', ['class' => 'add_dynamic_row']) ?> +</div> + +<div class="smaller"> + <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?> +</div> diff --git a/app/views/vips/exercises/SingleChoiceTask/print.php b/app/views/vips/exercises/SingleChoiceTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..a61bc84a1994ad34cf599db236e39f27bbc708e1 --- /dev/null +++ b/app/views/vips/exercises/SingleChoiceTask/print.php @@ -0,0 +1,41 @@ +<?php +/** + * @var ClozeTask $exercise + * @var bool $print_correction + * @var bool $show_solution + * @var array $optional_answer + */ +?> +<? foreach ($exercise->task as $group => $task): ?> + <div <?= $group ? 'class="group_separator"' : '' ?>> + <? if (isset($task['description'])): ?> + <?= formatReady($task['description']) ?> + <? endif ?> + </div> + + <div class="mc_list inline-content"> + <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?> + <div class="mc_flex <?= $show_solution && $entry['score'] == 1 ? 'correct_item' : 'mc_item' ?>"> + <? if (isset($response[$group]) && $response[$group] === "$key"): ?> + <?= Assets::img('choice_checked.svg') ?> + <? else: ?> + <?= Assets::img('choice_unchecked.svg') ?> + <? endif ?> + + <?= formatReady($entry['text']) ?> + + <? if ($print_correction): ?> + <? if (isset($response[$group]) && $response[$group] === "$key"): ?> + <? if ($entry['score'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?> + <? elseif ($key != -1): ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> + <? endif ?> + </div> + <? endforeach ?> + </div> +<? endforeach ?> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/SingleChoiceTask/solve.php b/app/views/vips/exercises/SingleChoiceTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..70689bff1c4c909714bbefe576bfa0eb2a5e825b --- /dev/null +++ b/app/views/vips/exercises/SingleChoiceTask/solve.php @@ -0,0 +1,23 @@ +<?php +/** + * @var ClozeTask $exercise + * @var array $optional_answer + */ +?> +<? foreach ($exercise->task as $group => $task): ?> + <div <?= $group ? 'class="group_separator"' : '' ?>> + <? if (isset($task['description'])): ?> + <?= formatReady($task['description']) ?> + <? endif ?> + </div> + + <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?> + <label class="inline-content mc_flex"> + <input type="radio" name="answer[<?= $group ?>]" value="<?= $key ?>" + <? if (!isset($response[$group]) && $key == -1 || isset($response[$group]) && $response[$group] === "$key"): ?>checked<? endif ?>> + <?= formatReady($entry['text']) ?> + </label> + <? endforeach ?> +<? endforeach ?> + +<?= $this->render_partial('exercises/evaluation_mode_info') ?> diff --git a/app/views/vips/exercises/SingleChoiceTask/xml.php b/app/views/vips/exercises/SingleChoiceTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..423a183ae1185a2ce34c3fe36ab9b4933427a653 --- /dev/null +++ b/app/views/vips/exercises/SingleChoiceTask/xml.php @@ -0,0 +1,51 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + * @var array $optional_answer + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <? foreach ($exercise->task as $group => $task): ?> + <item type="choice-single"> + <? if (isset($task['description']) && $task['description'] != ''): ?> + <description> + <text><?= htmlReady($task['description']) ?></text> + </description> + <? endif ?> + <answers> + <? foreach ($task['answers'] + $optional_answer as $key => $answer): ?> + <answer score="<?= (int) $answer['score'] ?>"<? if ($key == -1): ?> default="true"<? endif ?>> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + <? endforeach ?> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/TextLineTask/correct.php b/app/views/vips/exercises/TextLineTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..27bcefcb7f79d1175d6b527f49e64aeff041777a --- /dev/null +++ b/app/views/vips/exercises/TextLineTask/correct.php @@ -0,0 +1,37 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + * @var bool $edit_solution + */ +?> +<? if ($solution->id): ?> + <div class="label-text"> + <?= _('Antwort:') ?> + </div> + + <?= htmlReady($response[0]) ?> + + <? if ($results[0]['points'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?> + <? elseif ($results[0]['points'] == 0.5): ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?> + <? elseif (!$edit_solution || $results[0]['safe']): ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?> + <? else: ?> + <?= Icon::create('question', Icon::ROLE_STATUS_RED)->asImg(['title' => _('unbekannte Antwort')]) ?> + <? endif ?> +<? endif ?> + +<? if ($show_solution && $exercise->correctAnswers()): ?> + <div class="label-text"> + <?= _('Richtige Antworten:') ?> + + <span class="correct_item"> + <?= htmlReady(implode(' | ', $exercise->correctAnswers())) ?> + </span> + </div> +<? endif ?> diff --git a/app/views/vips/exercises/TextLineTask/edit.php b/app/views/vips/exercises/TextLineTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..3d2af0a3300fc38e2c52f043778a91583cf73d7e --- /dev/null +++ b/app/views/vips/exercises/TextLineTask/edit.php @@ -0,0 +1,80 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<div class="label-text"> + <?= _('Automatisch bewertete Antworten') ?> +</div> + +<div class="dynamic_list"> + <? foreach ($exercise->task['answers'] as $i => $answer): ?> + <div class="dynamic_row mc_row"> + <label class="dynamic_counter undecorated"> + <input class="character_input" name="answer[<?= $i ?>]" type="text" value="<?= htmlReady($answer['text']) ?>"> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="correct[<?= $i ?>]" value="1"<? if ($answer['score'] == 1): ?> checked<? endif ?>> + <?= _('richtig') ?> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="correct[<?= $i ?>]" value="0.5"<? if ($answer['score'] == 0.5): ?> checked<? endif ?>> + <?= _('teils richtig') ?> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" name="correct[<?= $i ?>]" value="0"<? if ($answer['score'] == 0): ?> checked<? endif ?>> + <?= _('falsch') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + <? endforeach ?> + + <div class="dynamic_row mc_row template"> + <label class="dynamic_counter undecorated"> + <input class="character_input" data-name="answer" type="text"> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" data-name="correct" value="1"> + <?= _('richtig') ?> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" data-name="correct" value="0.5"> + <?= _('teils richtig') ?> + </label> + <label class="undecorated" style="padding: 1ex;"> + <input type="radio" data-name="correct" value="0" checked> + <?= _('falsch') ?> + </label> + + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?> + </div> + + <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?> +</div> + +<label> + <?= _('Art des Textvergleichs') ?> + + <select name="compare" onchange="$(this).parent().next('label').toggle($(this).val() === 'numeric')"> + <option value=""> + <?= _('Groß-/Kleinschreibung ignorieren') ?> + </option> + <option value="levenshtein" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'levenshtein' ? 'selected' : '' ?>> + <?= _('Textähnlichkeit (Levenshtein-Distanz)') ?> + </option> + <option value="soundex" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'soundex' ? 'selected' : '' ?>> + <?= _('Ähnlichkeit der Aussprache (Soundex)') ?> + </option> + <option value="numeric" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? 'selected' : '' ?>> + <?= _('Numerischer Wertevergleich (ggf. mit Einheit)') ?> + </option> + </select> +</label> + +<label style="<?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? '' : 'display: none;' ?>"> + <?= _('Erlaubte relative Abweichung vom korrekten Wert') ?> + <br> + <input type="text" class="size-s" style="display: inline; text-align: right;" + name="epsilon" value="<?= isset($exercise->task['epsilon']) ? sprintf('%g', $exercise->task['epsilon'] * 100) : '0' ?>"> % +</label> diff --git a/app/views/vips/exercises/TextLineTask/print.php b/app/views/vips/exercises/TextLineTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..3bfbc2e25ca2937377fc3c4471e62062e086b730 --- /dev/null +++ b/app/views/vips/exercises/TextLineTask/print.php @@ -0,0 +1,35 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var array $response + * @var array $results + * @var bool $print_correction + * @var bool $show_solution + */ +?> +<? if ($solution->id) : ?> + <?= htmlReady($response[0]) ?> + + <? if ($print_correction): ?> + <? if ($results[0]['points'] == 1): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?> + <? elseif ($results[0]['points'] == 0.5): ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?> + <? else: ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?> + <? endif ?> + <? endif ?> +<? else : ?> + <div style="height: 6em;"></div> +<? endif ?> + +<? if ($show_solution && $exercise->correctAnswers()) : ?> + <div> + <?= _('Richtige Antworten:') ?> + + <span class="correct_item"> + <?= htmlReady(implode(' | ', $exercise->correctAnswers())) ?> + </span> + </div> +<? endif ?> diff --git a/app/views/vips/exercises/TextLineTask/solve.php b/app/views/vips/exercises/TextLineTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..8fab98ab0fa894d44d16e03a25637e41c042d225 --- /dev/null +++ b/app/views/vips/exercises/TextLineTask/solve.php @@ -0,0 +1,9 @@ +<?php +/** + * @var array $response + */ +?> +<label> + <?= _('Antwort') ?> + <input type="text" class="character_input" name="answer[0]" value="<?= htmlReady($response[0] ?? '') ?>"> +</label> diff --git a/app/views/vips/exercises/TextLineTask/xml.php b/app/views/vips/exercises/TextLineTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..d4b46c08349c67eacf0cfe61fc73f9cb0d52001f --- /dev/null +++ b/app/views/vips/exercises/TextLineTask/xml.php @@ -0,0 +1,53 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="text-line"> + <answers> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="<?= (float) $answer['score'] ?>"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <? if (!empty($exercise->task['compare'])): ?> + <evaluation-hints> + <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/> + <? if ($exercise->task['compare'] === 'numeric'): ?> + <input-data type="relative-epsilon"> + <?= (float) $exercise->task['epsilon'] ?> + </input-data> + <? endif ?> + </evaluation-hints> + <? endif ?> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/TextTask/correct.php b/app/views/vips/exercises/TextTask/correct.php new file mode 100644 index 0000000000000000000000000000000000000000..bdd99a6763e9d7d1040df427005ae1f0a3253a03 --- /dev/null +++ b/app/views/vips/exercises/TextTask/correct.php @@ -0,0 +1,184 @@ +<?php +/** + * @var Exercise $exercise + * @var VipsSolution $solution + * @var array $results + * @var array $response + * @var bool $show_solution + * @var bool $edit_solution + */ +?> +<? if ($exercise->getLayout() !== 'none' && $response[0] != ''): ?> + <div class="vips_tabs <?= $solution->commented_solution ? '' : 'edit-hidden' ?>"> + <ul> + <li class="edit-tab"> + <a href="#commented-<?= $exercise->id ?>"> + <?= _('Kommentierte Lösung') ?> + </a> + </li> + <li> + <a href="#solution-<?= $exercise->id ?>"> + <?= _('Lösung') ?> + </a> + </li> + <? if ($exercise->task['template'] != ''): ?> + <li> + <a href="#default-<?= $exercise->id ?>"> + <?= _('Vorbelegung') ?> + </a> + </li> + <? endif ?> + </ul> + + <div id="commented-<?= $exercise->id ?>"> + <? if ($edit_solution): ?> + <? if ($exercise->getLayout() === 'markup'): ?> + <? $answer = $response[0] ?> + <? elseif ($exercise->getLayout() === 'code'): ?> + <? $answer = "[pre][nop]\n{$response[0]}\n[/nop][/pre]" ?> + <? elseif (Studip\Markup::editorEnabled()): ?> + <? $answer = Studip\Markup::markAsHtml(htmlReady($response[0], true, true)) ?> + <? else: ?> + <? $answer = $response[0] ?> + <? endif ?> + <textarea <?= $solution->commented_solution ? 'name="commented_solution"' : '' ?> class="character_input size-l wysiwyg" rows="20" + ><?= wysiwygReady($solution->commented_solution ?: $answer) ?></textarea> + + <?= Studip\Button::create(_('Kommentierte Lösung löschen'), 'delete_commented_solution', ['data-confirm' => _('Wollen Sie die kommentierte Lösung löschen?')]) ?> + + <? if ($solution->commented_solution): ?> + <? if (!Studip\Markup::editorEnabled()): ?> + <div class="label-text"> + <?= _('Textvorschau') ?> + </div> + + <div class="vips_output"> + <?= formatReady($solution->commented_solution) ?> + </div> + <? endif ?> + <? endif ?> + <? else: ?> + <div class="vips_output"> + <?= formatReady($solution->commented_solution) ?> + </div> + <? endif ?> + </div> + + <div id="solution-<?= $exercise->id ?>"> + <div class="vips_output"> + <? if ($exercise->getLayout() === 'text'): ?> + <?= htmlReady($response[0], true, true) ?> + <? elseif ($exercise->getLayout() === 'markup'): ?> + <?= formatReady($response[0]) ?> + <? elseif ($exercise->getLayout() === 'code'): ?> + <pre><?= htmlReady($response[0]) ?></pre> + <input type="hidden" class="download" value="<?= htmlReady($response[0]) ?>"> + <? endif ?> + </div> + + <? if ($edit_solution): ?> + <?= Studip\Button::create(_('Lösung bearbeiten'), 'edit_solution', ['class' => 'edit_solution']) ?> + + <? if ($exercise->getLayout() === 'code'): ?> + <a hidden download="<?= htmlReady($exercise->title) ?>.txt" target="_blank"></a> + <?= Studip\Button::create(_('Lösung herunterladen'), 'download', ['class' => 'vips_file_download']) ?> + <? endif ?> + <? endif ?> + </div> + + <? if ($exercise->task['template'] != ''): ?> + <div id="default-<?= $exercise->id ?>"> + <div class="vips_output"> + <? if ($exercise->getLayout() === 'text'): ?> + <?= htmlReady($exercise->task['template'], true, true) ?> + <? elseif ($exercise->getLayout() === 'markup'): ?> + <?= formatReady($exercise->task['template']) ?> + <? elseif ($exercise->getLayout() === 'code'): ?> + <pre><?= htmlReady($exercise->task['template']) ?></pre> + <? endif ?> + </div> + </div> + <? endif ?> + </div> +<? elseif ($exercise->getLayout() !== 'none'): ?> + <div class="description" style="font-style: italic;"> + <?= _('Es wurde kein Text als Lösung abgegeben.') ?> + </div> +<? endif ?> + +<? if ($exercise->options['file_upload'] && $solution->folder && count($solution->folder->file_refs)): ?> + <? foreach ($solution->folder->file_refs as $file_ref): ?> + <? if ($file_ref->isImage()): ?> + <div class="label-text"> + <?= htmlReady($file_ref->name) ?>: + </div> + <div class="formatted-content"> + <img src="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + </div> + <? endif ?> + <? endforeach ?> + + <div class="label-text"> + <?= _('Hochgeladene Dateien') ?> + </div> + + <table class="default"> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Name') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 20%;"> + <?= _('Autor/-in') ?> + </th> + <th style="width: 20%;"> + <?= _('Datum') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($solution->folder->file_refs as $file_ref): ?> + <tr> + <td> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?> + </td> + <td> + <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <? if ($solution->folder && count($solution->folder->file_refs) > 1): ?> + <tfoot> + <tr> + <td colspan="4"> + <?= Studip\LinkButton::create(_('Alle Dateien herunterladen'), $controller->url_for('file/download_folder', $solution->folder->id)) ?> + </td> + </tr> + </tfoot> + <? endif ?> + </table> +<? endif ?> + +<? if ($show_solution && $exercise->task['answers'][0]['text'] != ''): ?> + <div class="label-text"> + <?= _('Musterlösung') ?> + </div> + <div class="vips_output"> + <?= formatReady($exercise->task['answers'][0]['text']) ?> + </div> +<? endif ?> diff --git a/app/views/vips/exercises/TextTask/edit.php b/app/views/vips/exercises/TextTask/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..df7304e1f4305e23ef4bf134fbd1409d8985f72f --- /dev/null +++ b/app/views/vips/exercises/TextTask/edit.php @@ -0,0 +1,47 @@ +<?php +/** + * @var ClozeTask $exercise + */ +?> +<label> + <?= _('Art der Abgabe') ?> + + <select class="tb_layout" name="layout" onchange="$(this).closest('fieldset').find('.none-hidden').toggle($(this).val() !== 'none')"> + <option value=""> + <?= _('Texteingabe - einfacher Text ohne Formatierungen') ?> + </option> + <option value="markup" <? if ($exercise->getLayout() === 'markup'): ?>selected<? endif ?>> + <?= _('Texteingabe - Textformatierungen bei Eingabe der Lösung anbieten') ?> + </option> + <option value="code" <? if ($exercise->getLayout() === 'code'): ?>selected<? endif ?>> + <?= _('Texteingabe - Programmcode (nichtproportionale Schriftart)') ?> + </option> + <option value="none" <? if ($exercise->getLayout() === 'none'): ?>selected<? endif ?>> + <?= _('keine Texteingabe - nur Hochladen von Dateien erlauben') ?> + </option> + </select> +</label> + +<label class="none-hidden" style="<?= $exercise->getLayout() === 'none' ? 'display: none;' : '' ?>"> + <?= _('Vorgegebener Text im Antwortfeld') ?> + <?= $this->render_partial('exercises/flexible_textarea', + ['name' => 'answer_default', 'value' => $exercise->task['template'], 'monospace' => $exercise->getLayout() === 'code', 'wysiwyg' => $exercise->getLayout() === 'markup']) ?> +</label> + +<label> + <?= _('Musterlösung') ?> + <textarea class="character_input size-l wysiwyg" name="answer_0" rows="<?= $exercise->textareaSize($exercise->task['answers'][0]['text']) ?>"><?= wysiwygReady($exercise->task['answers'][0]['text']) ?></textarea> +</label> + +<div class="none-hidden" style="<?= $exercise->getLayout() === 'none' ? 'display: none;' : '' ?>"> + <label> + <input type="checkbox" name="file_upload" value="1" <?= $exercise->options['file_upload'] ? ' checked' : '' ?>> + <?= _('Hochladen von Dateien als Lösung erlauben') ?> + <?= tooltipIcon(_('Hochgeladene Dateien können nicht automatisch bewertet werden.')) ?> + </label> + + <label> + <input type="checkbox" name="compare" value="levenshtein" <?= $exercise->task['compare'] === 'levenshtein' ? 'checked' : '' ?>> + <?= _('Punktevorschlag basierend auf Textähnlichkeit (Levenshtein-Distanz)') ?> + </label> +</div> diff --git a/app/views/vips/exercises/TextTask/print.php b/app/views/vips/exercises/TextTask/print.php new file mode 100644 index 0000000000000000000000000000000000000000..e8e29c5cbb5d5b9091ed0233e3a279d81524b5d8 --- /dev/null +++ b/app/views/vips/exercises/TextTask/print.php @@ -0,0 +1,83 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var array $response + * @var array $results + * @var bool $print_correction + * @var bool $show_solution + * @var bool $print_files + */ +?> +<? if ($exercise->getLayout() !== 'none'): ?> + <? if ($print_correction && $solution->commented_solution != '') : ?> + <div class="label-text"> + <?= _('Kommentierte Lösung:') ?> + </div> + + <?= formatReady($solution->commented_solution) ?> + <? elseif ($solution->id && $response[0] != '') : ?> + <div class="label-text"> + <?= _('Lösung des Teilnehmers:') ?> + </div> + + <div class="vips_output"> + <? if ($exercise->getLayout() === 'markup'): ?> + <?= formatReady($response[0]) ?> + <? elseif ($exercise->getLayout() === 'code'): ?> + <pre><?= htmlReady($response[0]) ?></pre> + <? else: ?> + <?= htmlReady($response[0], true, true) ?> + <? endif ?> + </div> + <? elseif ($print_correction) : ?> + <div class="description" style="font-style: italic;"> + <?= _('Es wurde kein Text als Lösung abgegeben.') ?> + </div> + <? else : ?> + <div class="vips_output" style="min-height: 30em;"> + <? if ($exercise->getLayout() === 'markup'): ?> + <?= formatReady($exercise->task['template']) ?> + <? elseif ($exercise->getLayout() === 'code'): ?> + <pre><?= htmlReady($exercise->task['template']) ?></pre> + <? else: ?> + <?= htmlReady($exercise->task['template'], true, true) ?> + <? endif ?> + </div> + <? endif ?> +<? endif ?> + +<? if ($exercise->options['file_upload'] && $solution && $solution->folder && count($solution->folder->file_refs)): ?> + <? foreach ($solution->folder->file_refs as $file_ref): ?> + <? if ($print_files && $file_ref->isImage()): ?> + <div class="label-text"> + <?= htmlReady($file_ref->name) ?>: + </div> + <div class="formatted-content"> + <img src="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + </div> + <? endif ?> + <? endforeach ?> + + <div class="label-text"> + <?= _('Hochgeladene Dateien:') ?> + </div> + + <ul> + <? foreach ($solution->folder->file_refs as $file_ref): ?> + <li> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= htmlReady($file_ref->name) ?> + </a> + </li> + <? endforeach ?> + </ul> +<? endif ?> + +<? if ($show_solution && $exercise->task['answers'][0]['text'] != '') : ?> + <div class="label-text"> + <?= _('Musterlösung:') ?> + </div> + + <?= formatReady($exercise->task['answers'][0]['text']) ?> +<? endif ?> diff --git a/app/views/vips/exercises/TextTask/solve.php b/app/views/vips/exercises/TextTask/solve.php new file mode 100644 index 0000000000000000000000000000000000000000..ddafce606ef033e1ad11b52953462490c239aea3 --- /dev/null +++ b/app/views/vips/exercises/TextTask/solve.php @@ -0,0 +1,131 @@ +<?php +/** + * @var ClozeTask $exercise + * @var VipsSolution $solution + * @var VipsAssignment $assignment + */ +?> +<? if ($exercise->getLayout() !== 'none'): ?> + <? if ($exercise->task['template'] != ''): ?> + <div class="vips_tabs"> + <ul> + <li> + <a href="#solution-<?= $exercise->id ?>"> + <?= _('Antwort') ?> + </a> + </li> + <li> + <a href="#default-<?= $exercise->id ?>"> + <?= _('Vorbelegung') ?> + </a> + </li> + </ul> + <? else: ?> + <label> + <?= _('Antwort') ?> + <? endif ?> + + <? /* student answer */ ?> + <div id="solution-<?= $exercise->id ?>"> + <? $answer = isset($response) ? $response[0] : $exercise->task['template'] ?> + <? if ($exercise->getLayout() === 'markup'): ?> + <textarea name="answer[0]" class="character_input size-l wysiwyg" data-editor="removePlugins=studip-quote,studip-upload,ImageUpload" rows="20"><?= wysiwygReady($answer) ?></textarea> + <? elseif ($exercise->getLayout() === 'code'): ?> + <textarea name="answer[0]" class="character_input size-l monospace download" rows="20"><?= htmlReady($answer) ?></textarea> + + <a hidden download="<?= htmlReady($exercise->title) ?>.txt" target="_blank"></a> + <?= Studip\Button::create(_('Antwort herunterladen'), 'download', ['class' => 'vips_file_download']) ?> + <input hidden class="file_upload inline" type="file"> + <?= Studip\Button::create(_('Text in das Eingabefeld hochladen'), 'upload', ['class' => 'vips_file_upload']) ?> + <? else: ?> + <textarea name="answer[0]" class="character_input size-l" rows="20"><?= htmlReady($answer) ?></textarea> + <? endif ?> + </div> + + <? if ($exercise->task['template'] == ''): ?> + </label> + <? else: ?> + <? /* default answer */ ?> + <div id="default-<?= $exercise->id ?>"> + <? if ($exercise->getLayout() === 'markup'): ?> + <textarea readonly class="size-l wysiwyg" rows="20"><?= wysiwygReady($exercise->task['template']) ?></textarea> + <? elseif ($exercise->getLayout() === 'code'): ?> + <textarea readonly class="size-l monospace" rows="20"><?= htmlReady($exercise->task['template']) ?></textarea> + <? else: ?> + <textarea readonly class="size-l" rows="20"><?= htmlReady($exercise->task['template']) ?></textarea> + <? endif ?> + </div> + </div> + <? endif ?> +<? endif ?> + +<? if ($exercise->options['file_upload']): ?> + <div class="label-text"> + <? if ($solution && $solution->folder && count($solution->folder->file_refs)): ?> + <?= _('Hochgeladene Dateien') ?> + <? else: ?> + <?= _('Keine Dateien hochgeladen') ?> + <? endif ?> + (<?= sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576) ?>) + </div> + + <table class="default"> + <? if ($solution && $solution->folder && count($solution->folder->file_refs)): ?> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Name') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 20%;"> + <?= _('Autor/-in') ?> + </th> + <th style="width: 15%;"> + <?= _('Datum') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list"> + <? foreach ($solution->folder->file_refs as $file_ref): ?> + <tr class="dynamic_row"> + <td> + <input type="hidden" name="file_ids[]" value="<?= $file_ref->id ?>"> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?> + </td> + <td> + <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + + <tfoot> + <tr> + <td colspan="5"> + <?= Studip\Button::create(_('Datei als Lösung hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?> + <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span> + <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple> + </td> + </tr> + </tfoot> + </table> +<? endif ?> diff --git a/app/views/vips/exercises/TextTask/xml.php b/app/views/vips/exercises/TextTask/xml.php new file mode 100644 index 0000000000000000000000000000000000000000..dd79d5360f23ff5e9d6aa11af7969ce935b70957 --- /dev/null +++ b/app/views/vips/exercises/TextTask/xml.php @@ -0,0 +1,61 @@ +<?php +/** + * @var ClozeTask $exercise + * @var float|int $points + */ +?> +<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>" +<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>> + <title> + <?= htmlReady($exercise->title) ?> + </title> + <description> + <?= htmlReady($exercise->description) ?> + </description> + <? if ($exercise->options['hint'] != ''): ?> + <hint> + <?= htmlReady($exercise->options['hint']) ?> + </hint> + <? endif ?> + <items> + <item type="text-area"> + <answers> + <? if ($exercise->task['template'] != ''): ?> + <answer score="0" default="true"> + <?= htmlReady($exercise->task['template']) ?> + </answer> + <? endif ?> + <? foreach ($exercise->task['answers'] as $answer): ?> + <answer score="<?= (float) $answer['score'] ?>"> + <?= htmlReady($answer['text']) ?> + </answer> + <? endforeach ?> + </answers> + <submission-hints> + <? if (!empty($exercise->task['layout'])): ?> + <input type="<?= htmlReady($exercise->task['layout']) ?>"/> + <? endif ?> + <? if ($exercise->options['file_upload']): ?> + <attachments upload="true"/> + <? endif ?> + </submission-hints> + <? if (!empty($exercise->task['compare'])): ?> + <evaluation-hints> + <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/> + </evaluation-hints> + <? endif ?> + <? if ($exercise->options['feedback'] != ''): ?> + <feedback> + <?= htmlReady($exercise->options['feedback']) ?> + </feedback> + <? endif ?> + </item> + </items> + <? if ($exercise->folder): ?> + <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <file-ref ref="file-<?= $file_ref->file_id ?>"/> + <? endforeach ?> + </file-refs> + <? endif ?> +</exercise> diff --git a/app/views/vips/exercises/correct_exercise.php b/app/views/vips/exercises/correct_exercise.php new file mode 100644 index 0000000000000000000000000000000000000000..c62cb0eef5d8cbdc1aa65150ab5684e4a622a651 --- /dev/null +++ b/app/views/vips/exercises/correct_exercise.php @@ -0,0 +1,39 @@ +<?php +/** + * @var int $exercise_position + * @var Exercise $exercise + * @var float|int $max_points + * @var VipsSolution $solution + */ +?> +<fieldset> + <legend> + <?= $exercise_position ?>. + <?= htmlReady($exercise->title) ?> + <div style="float: right;"> + <? if ($max_points == (int) $max_points): ?> + <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?> + <? else: ?> + <?= sprintf(_('%g Punkte'), $max_points) ?> + <? endif ?> + </div> + </legend> + + <div class="description"> + <?= formatReady($exercise->description) ?> + </div> + + <?= $this->render_partial('vips/exercises/show_exercise_hint') ?> + <?= $this->render_partial('vips/exercises/show_exercise_files') ?> + + <?= $this->render_partial($exercise->getCorrectionTemplate($solution)) ?> + + <? if (!empty($exercise->options['comment']) && $solution->student_comment != '') : ?> + <div class="label-text"> + <?= _('Bemerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= htmlReady($solution->student_comment, true, true) ?> + </div> + <? endif ?> +</fieldset> diff --git a/app/views/vips/exercises/courseware_block.php b/app/views/vips/exercises/courseware_block.php new file mode 100644 index 0000000000000000000000000000000000000000..4259f9097380d0d98794eb1447c0f76341aa9ea3 --- /dev/null +++ b/app/views/vips/exercises/courseware_block.php @@ -0,0 +1,79 @@ +<?php +/** + * @var int $tries_left + * @var bool $show_solution + * @var Exercise $exercise + * @var float|int $max_points + * @var VipsSolution $solution + * @var bool $sample_solution + * @var VipsAssignment $assignment + * @var string $user_id + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<? if ($tries_left > 0 && !$show_solution): ?> + <?= MessageBox::warning(sprintf(ngettext( + 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weiteren Versuch.', + 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weitere Versuche.', $tries_left), $tries_left)) ?> +<? endif ?> + +<h4 class="exercise"> + <?= htmlReady($exercise->title) ?> + + <div class="points"> + <? if ($max_points == (int) $max_points): ?> + <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?> + <? else: ?> + <?= sprintf(_('%g Punkte'), $max_points) ?> + <? endif ?> + </div> +</h4> + +<div class="description"> + <?= formatReady($exercise->description) ?> +</div> + +<?= $this->render_partial('vips/exercises/show_exercise_hint') ?> +<?= $this->render_partial('vips/exercises/show_exercise_files') ?> + +<? if ($show_solution): ?> + <?= $this->render_partial($exercise->getCorrectionTemplate($solution), ['show_solution' => $sample_solution]) ?> + + <? if ($exercise->options['comment'] && $solution->student_comment != ''): ?> + <div class="label-text"> + <?= _('Bemerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= htmlReady($solution->student_comment, true, true) ?> + </div> + <? endif ?> + + <header> + <?= _('Bewertung') ?> + </header> + + <? if ($solution->feedback != ''): ?> + <div class="label-text"> + <?= _('Anmerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <div class="description"> + <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> +<? else: ?> + <?= $this->render_partial($exercise->getSolveTemplate($solution, $assignment, $user_id)) ?> + + <? if (!empty($exercise->options['comment'])): ?> + <label> + <?= _('Bemerkungen zur Lösung (optional)') ?> + <textarea name="student_comment"><?= htmlReady($solution->student_comment) ?></textarea> + </label> + <? endif ?> +<? endif ?> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/exercises/evaluation_mode_info.php b/app/views/vips/exercises/evaluation_mode_info.php new file mode 100644 index 0000000000000000000000000000000000000000..d39d01e982865252dc6b7af57f10da1163b8f1ac --- /dev/null +++ b/app/views/vips/exercises/evaluation_mode_info.php @@ -0,0 +1,22 @@ +<?php +/** + * @var bool $evaluation_mode + * @var Exercise $exercise + * @var bool $show_solution + */ +?> +<? if ($evaluation_mode && $exercise->itemCount() > 1): ?> + <div class="description smaller"> + <? if ($evaluation_mode == VipsAssignment::SCORING_NEGATIVE_POINTS) : ?> + <?= _('Vorsicht: Falsche Antworten geben Punktabzug!') ?> + <? elseif ($evaluation_mode == VipsAssignment::SCORING_ALL_OR_NOTHING) : ?> + <?= _('Vorsicht: Falsche Antworten führen zur Bewertung der Aufgabe mit 0 Punkten.') ?> + <? endif ?> + </div> +<? endif ?> + +<? if ($show_solution): ?> + <div class="description smaller"> + <?= sprintf(_('Richtige Antworten %shervorgehoben%s.'), '<span class="correct_item">', '</span>') ?> + </div> +<? endif ?> diff --git a/app/views/vips/exercises/flexible_input.php b/app/views/vips/exercises/flexible_input.php new file mode 100644 index 0000000000000000000000000000000000000000..f2e89403764950cae9ee060ba1acbfa39812450c --- /dev/null +++ b/app/views/vips/exercises/flexible_input.php @@ -0,0 +1,25 @@ +<?php +/** + * @var string $size + */ +?> +<div class="flexible_input"> + <input type="text" class="character_input small_input size-l" + <? if ($size === 'small'): ?> + <?= isset($name) ? 'name="'.$name.'"' : (isset($data_name) ? 'data-name="'.$data_name.'"' : '') ?> + <? endif ?> + <? if (isset($value)): ?> + value="<?= htmlReady($value) ?>" + <? endif ?> + > + <div class="large_input"> + <? $wysiwyg = isset($data_name) ? 'wysiwyg-hidden' : 'wysiwyg' ?> + <textarea class="character_input <?= $wysiwyg ?> size-l" data-editor="removePlugins=studip-quote,studip-settings;toolbar=small" + <? if ($size === 'large'): ?> + <?= isset($name) ? 'name="'.$name.'"' : (isset($data_name) ? 'data-name="'.$data_name.'"' : '') ?> + <? endif ?> + ><?= wysiwygReady($value ?? '') ?></textarea> + </div> +</div> +<?= Icon::create('arr_1down')->asInput(['class' => 'textarea_toggle small_input', 'title' => _('Auf mehrzeilige Eingabe umschalten')]) ?> +<?= Icon::create('arr_1up')->asInput(['class' => 'textarea_toggle large_input', 'title' => _('Auf einzeilige Eingabe umschalten')]) ?> diff --git a/app/views/vips/exercises/flexible_textarea.php b/app/views/vips/exercises/flexible_textarea.php new file mode 100644 index 0000000000000000000000000000000000000000..64db62fb6b16af0cbe11486a63b008cecae62bfb --- /dev/null +++ b/app/views/vips/exercises/flexible_textarea.php @@ -0,0 +1,17 @@ +<?php +/** + * @var Exercise $exercise + * @var bool $wysiwyg + * @var bool $monospace + * @var string $name + * @var string $value + */ +?> +<div class="size_toggle <?= $wysiwyg ? 'size_large' : 'size_small' ?>"> + <textarea class="character_input size-l small_input <?= $monospace ? 'monospace' : '' ?>" <?= $wysiwyg ? '' : 'name="'.$name.'"' ?> + rows="<?= $exercise->textareaSize($value) ?>"><?= htmlReady($value) ?></textarea> + <div class="large_input"> + <textarea class="character_input size-l wysiwyg" <?= $wysiwyg ? 'name="'.$name.'"' : '' ?>><?= wysiwygReady($value) ?></textarea> + </div> + <button hidden class="textarea_toggle"></button> +</div> diff --git a/app/views/vips/exercises/print_exercise.php b/app/views/vips/exercises/print_exercise.php new file mode 100644 index 0000000000000000000000000000000000000000..a3da7bad63be9294dfcc82012ac0063363f58561 --- /dev/null +++ b/app/views/vips/exercises/print_exercise.php @@ -0,0 +1,64 @@ +<?php +/** + * @var int $exercise_position + * @var Exercise $exercise + * @var float|int $max_points + * @var VipsSolution $solution + * @var VipsAssignment $assignment + * @var string $user_id + * @var bool $print_correction + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<div class="exercise"> + <h3> + <?= $exercise_position ?>. + <?= htmlReady($exercise->title) ?> + + <div class="points"> + <? if ($max_points == (int) $max_points): ?> + <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?> + <? else: ?> + <?= sprintf(_('%g Punkte'), $max_points) ?> + <? endif ?> + </div> + </h3> + + <div class="description"> + <?= formatReady($exercise->description) ?> + </div> + + <?= $this->render_partial('vips/exercises/show_exercise_hint') ?> + <?= $this->render_partial('vips/exercises/show_exercise_files') ?> + + <?= $this->render_partial($exercise->getPrintTemplate($solution, $assignment, $user_id)) ?> + + <? if ($solution && $solution->student_comment != '') : ?> + <div class="label-text"> + <?= _('Bemerkungen zur Lösung:') ?> + </div> + + <?= htmlReady($solution->student_comment, true, true) ?> + <? endif ?> + + <? if ($print_correction): ?> + <? if ($solution): ?> + <? if ($solution->feedback != ''): ?> + <div class="label-text"> + <?= _('Anmerkung des Korrektors:') ?> + </div> + + <?= formatReady($solution->feedback) ?> + <? endif ?> + + <?= $this->render_partial('vips/solutions/feedback_files') ?> + <? endif ?> + + <div class="label-text"> + <?= sprintf(_('Erreichte Punkte: %g / %g'), $solution->points, $max_points) ?> + </div> + <? endif ?> +</div> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/exercises/show_exercise_files.php b/app/views/vips/exercises/show_exercise_files.php new file mode 100644 index 0000000000000000000000000000000000000000..822fcee048c56ca9357521db66aa391b955ea825 --- /dev/null +++ b/app/views/vips/exercises/show_exercise_files.php @@ -0,0 +1,20 @@ +<?php +/** + * @var Exercise $exercise + */ +?> +<? if ($exercise->folder && count($exercise->folder->file_refs) > 0 && !$exercise->options['files_hidden']): ?> + <div class="label-text"> + <?= _('Dateien zur Aufgabe:') ?> + </div> + + <ul> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <li> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>" <?= $file_ref->getContentDisposition() === 'inline' ? 'target="_blank"' : '' ?>> + <?= htmlReady($file_ref->name) ?> + </a> + </li> + <? endforeach ?> + </ul> +<? endif ?> diff --git a/app/views/vips/exercises/show_exercise_hint.php b/app/views/vips/exercises/show_exercise_hint.php new file mode 100644 index 0000000000000000000000000000000000000000..8d686471e920b7862dd1539e7a7c23807b7ae0b4 --- /dev/null +++ b/app/views/vips/exercises/show_exercise_hint.php @@ -0,0 +1,12 @@ +<?php +/** + * @var Exercise $exercise + */ +?> +<? if (isset($exercise->options['hint']) && $exercise->options['hint'] !== ''): ?> + <div class="exercise_hint inline-content"> + <h4><?= _('Hinweis:') ?></h4> + <?= formatReady($exercise->options['hint']) ?> + </div> + <br> +<? endif ?> diff --git a/app/views/vips/pool/assignments.php b/app/views/vips/pool/assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..795e2f607c2d85f447eaec88b84b3ee1597b4dd7 --- /dev/null +++ b/app/views/vips/pool/assignments.php @@ -0,0 +1,25 @@ +<?php +/** + * @var int $count + * @var array $search_filter + */ +?> +<? if ($count == 0 && empty(array_filter($search_filter))): ?> + <div class="vips-teaser"> + <header><?= _('Aufgaben und Prüfungen') ?></header> + <p> + <?= _('Mit diesem Werkzeug können Ãœbungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' . + 'Die Lehrenden erhalten eine Ãœbersicht darüber, welche Teilnehmenden eine Ãœbung oder einen ' . + 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Ãœbungszetteln ' . + 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' . + 'erhalten sofort einen Ãœberblick darüber, was noch zu korrigieren ist. Neben allgemein ' . + 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' . + 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.') ?> + </p> + <?= Studip\LinkButton::create(_('Aufgabenblatt erstellen'), $controller->url_for('vips/sheets/edit_assignment')) ?> + </div> +<? elseif ($count): ?> + <?= $this->render_partial('vips/pool/list_assignments') ?> +<? else: ?> + <?= MessageBox::info(_('Mit den aktuellen Sucheinstellungen sind keine Aufgabenblätter mit Zugriffsberechtigung vorhanden.')) ?> +<? endif ?> diff --git a/app/views/vips/pool/copy_exercises_dialog.php b/app/views/vips/pool/copy_exercises_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..810be1121f4f5fc1a2005fc766aa530b03abc956 --- /dev/null +++ b/app/views/vips/pool/copy_exercises_dialog.php @@ -0,0 +1,50 @@ +<?php +/** + * @var Vips_PoolController $controller + * @var int[] $exercise_ids + * @var Course[] $courses + */ +?> +<form class="default" action="<?= $controller->link_for('vips/pool/copy_exercises') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($exercise_ids as $exercise_id => $assignment_id): ?> + <input type="hidden" name="exercise_ids[<?= htmlReady($exercise_id) ?>]" value="<?= htmlReady($assignment_id) ?>"> + <? endforeach ?> + + <label> + <?= _('Aufgabenblatt auswählen') ?> + + <select name="assignment_id" class="vips_nested_select"> + <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= htmlReady($assignment->id) ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + + <? foreach ($courses as $course): ?> + <? $assignments = VipsAssignment::findByRangeId($course->id) ?> + <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= htmlReady($assignment->id) ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?> + </footer> +</form> diff --git a/app/views/vips/pool/exercises.php b/app/views/vips/pool/exercises.php new file mode 100644 index 0000000000000000000000000000000000000000..de11e7a2bca400a1e152fc898caa83b8e602f3a1 --- /dev/null +++ b/app/views/vips/pool/exercises.php @@ -0,0 +1,15 @@ +<?php +/** + * @var int $count + * @var array $search_filter + */ +?> +<? if ($count == 0 && empty(array_filter($search_filter))): ?> + <?= MessageBox::info(_('Es wurden noch keine Aufgabenblätter eingerichtet.'), [ + _('Auf dieser Seite finden Sie eine Ãœbersicht über alle Aufgaben, auf die Sie Zugriff haben.') + ]) ?> +<? elseif ($count): ?> + <?= $this->render_partial('vips/pool/list_exercises') ?> +<? else: ?> + <?= MessageBox::info(_('Mit den aktuellen Sucheinstellungen sind keine Aufgaben mit Zugriffsberechtigung vorhanden.')) ?> +<? endif ?> diff --git a/app/views/vips/pool/list_assignments.php b/app/views/vips/pool/list_assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..4caf303aa8584e545c6603695f460f59ffa74400 --- /dev/null +++ b/app/views/vips/pool/list_assignments.php @@ -0,0 +1,174 @@ +<?php +/** + * @var Vips_PoolController $controller + * @var string $sort + * @var bool $desc + * @var int $page + * @var array $search_filter + * @var int $count + * @var VipsAssignment[] $assignments + */ +?> +<form action="" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="sort" value="<?= htmlReady($sort) ?>"> + <input type="hidden" name="desc" value="<?= $desc ?>"> + <input type="hidden" name="page" value="<?= $page ?>"> + <input type="hidden" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>"> + <input type="hidden" name="search_filter[assignment_type]" value="<?= htmlReady($search_filter['assignment_type']) ?>"> + + <table class="default"> + <caption> + <?= _('Aufgabenblätter') ?> + <div class="actions"> + <?= sprintf(ngettext('%d Aufgabenblatt', '%d Aufgabenblätter', $count), $count) ?> + </div> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 20px;"> + <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>"> + </th> + + <th style="width: 35%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Titel') ?> + </a> + </th> + + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'Nachname', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'Nachname', 'desc' => $sort === 'Nachname' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Autor/-in') ?> + </a> + </th> + + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'mkdate', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'mkdate', 'desc' => $sort === 'mkdate' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Datum') ?> + </a> + </th> + + <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'Name', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'Name', 'desc' => $sort === 'Name' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Veranstaltung') ?> + </a> + </th> + + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Semester') ?> + </a> + </th> + + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignments as $assignment): ?> + <? $assignment_obj = VipsAssignment::buildExisting($assignment) ?> + <? $course_id = $assignment['range_type'] === 'course' ? $assignment['range_id'] : null ?> + <tr> + <td> + <input class="batch_select" type="checkbox" name="assignment_ids[]" value="<?= htmlReady($assignment['id']) ?>" aria-label="<?= _('Zeile auswählen') ?>"> + </td> + + <td> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]) ?>"> + <?= $assignment_obj->getTypeIcon() ?> + <?= htmlReady($assignment['test_title']) ?> + </a> + </td> + + <td> + <? if (isset($assignment['Nachname']) || isset($assignment['Vorname'])): ?> + <?= htmlReady($assignment['Nachname'] . ', ' . $assignment['Vorname']) ?> + <? endif ?> + </td> + + <td> + <?= date('d.m.Y, H:i', $assignment['mkdate']) ?> + </td> + + <td> + <? if ($course_id): ?> + <a href="<?= URLHelper::getLink('seminar_main.php', ['cid' => $course_id]) ?>"> + <?= htmlReady($assignment['Name']) ?> + </a> + <? endif ?> + </td> + + <td> + <? if ($course_id && $assignment['start_time']): ?> + <?= htmlReady(Semester::findByTimestamp($assignment['start_time'])->name) ?> + <? endif ?> + </td> + + <td class="actions"> + <? $menu = ActionMenu::get(); ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/show_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]), + _('Studierendensicht anzeigen'), + Icon::create('community') + ) ?> + + <? $menu->addLink( + $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment['id']]), + _('Aufgabenblatt drucken'), + Icon::create('print'), + ['target' => '_blank'] + ) ?> + + <? $menu->addLink( + $controller->url_for('vips/sheets/copy_assignments_dialog', ['assignment_ids[]' => $assignment['id']]), + _('Aufgabenblatt kopieren'), + Icon::create('copy'), + ['data-dialog' => 'size=auto'] + ) ?> + + <? if ($assignment_obj->isLocked()): ?> + <? $menu->addButton('reset', _('Alle Lösungen zurücksetzen'), Icon::create('refresh'), [ + 'formaction' => $controller->url_for('vips/sheets/reset_assignment', ['assignment_id' => $assignment['id']]), + 'data-confirm' => _('Achtung: Wenn Sie die Lösungen zurücksetzen, werden die Lösungen aller Teilnehmenden archiviert!') + ]) ?> + <? else: ?> + <? $menu->addButton('delete', _('Aufgabenblatt löschen'), Icon::create('trash'), [ + 'formaction' => $controller->url_for('vips/sheets/delete_assignments', ['assignment_ids[]' => $assignment['id']]), + 'data-confirm' => sprintf(_('Wollen Sie wirklich das Aufgabenblatt „%s“ löschen?'), $assignment['test_title']) + ]) ?> + <? endif ?> + <?= $menu->render() ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="4"> + <?= Studip\Button::create(_('Kopieren'), 'copy_selected', [ + 'class' => 'batch_action', + 'data-dialog' => 'size=auto', + 'formaction' => $controller->url_for('vips/sheets/copy_assignments_dialog') + ]) ?> + <?= Studip\Button::create(_('Verschieben'), 'move_selected', [ + 'class' => 'batch_action', + 'data-dialog' => 'size=auto', + 'formaction' => $controller->url_for('vips/sheets/move_assignments_dialog') + ]) ?> + <?= Studip\Button::create(_('Löschen'), 'delete_selected', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/delete_assignments'), + 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgabenblätter löschen?') + ]) ?> + </td> + <td colspan="3" class="actions"> + <?= $controller->page_chooser($controller->url_for('vips/pool/assignments', ['page' => '%d', 'sort' => $sort, 'desc' => $desc, 'search_filter' => $search_filter]), $count, $page) ?> + </td> + </tr> + </tfoot> + </table> +</form> diff --git a/app/views/vips/pool/list_exercises.php b/app/views/vips/pool/list_exercises.php new file mode 100644 index 0000000000000000000000000000000000000000..83544f08555ea44a60573818630cbb30705475e0 --- /dev/null +++ b/app/views/vips/pool/list_exercises.php @@ -0,0 +1,151 @@ +<?php +/** + * @var Vips_PoolController $controller + * @var string $sort + * @var bool $desc + * @var int $page + * @var array $search_filter + * @var int $count + * @var Exercise[] $exercises + */ +?> +<form action="" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="sort" value="<?= $sort ?>"> + <input type="hidden" name="desc" value="<?= $desc ?>"> + <input type="hidden" name="page" value="<?= $page ?>"> + <input type="hidden" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>"> + <input type="hidden" name="search_filter[exercise_type]" value="<?= htmlReady($search_filter['exercise_type']) ?>"> + + <table class="default"> + <caption> + <?= _('Aufgaben') ?> + <div class="actions"> + <?= sprintf(ngettext('%d Aufgabe', '%d Aufgaben', $count), $count) ?> + </div> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 20px;"> + <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgaben auswählen') ?>"> + </th> + + <th style="width: 35%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Titel') ?> + </a> + </th> + + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Aufgabentyp') ?> + </a> + </th> + + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'Nachname', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'Nachname', 'desc' => $sort === 'Nachname' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Autor/-in') ?> + </a> + </th> + + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'mkdate', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'mkdate', 'desc' => $sort === 'mkdate' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Datum') ?> + </a> + </th> + + <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc, 'search_filter' => $search_filter]) ?>"> + <?= _('Aufgabenblatt') ?> + </a> + </th> + + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($exercises as $exercise): ?> + <? $course_id = $exercise['range_type'] === 'course' ? $exercise['range_id'] : null ?> + <tr> + <td> + <input class="batch_select" type="checkbox" name="exercise_ids[<?= $exercise['id'] ?>]" value="<?= $exercise['assignment_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>"> + </td> + + <td> + <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]) ?>"> + <?= htmlReady($exercise['title']) ?> + </a> + </td> + + <td> + <? if (isset($exercise_types[$exercise['type']])): ?> + <?= htmlReady($exercise_types[$exercise['type']]['name']) ?> + <? endif ?> + </td> + + <td> + <? if (isset($exercise['Nachname']) || isset($exercise['Vorname'])): ?> + <?= htmlReady($exercise['Nachname'] . ', ' . $exercise['Vorname']) ?> + <? endif ?> + </td> + + <td> + <?= date('d.m.Y, H:i', $exercise['mkdate']) ?> + </td> + + <td> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id']]) ?>"> + <?= htmlReady($exercise['test_title']) ?> + </a> + </td> + + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink($controller->url_for('vips/sheets/show_exercise', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]), + _('Studierendensicht anzeigen'), Icon::create('community') + ) ?> + + <? $menu->addLink($controller->url_for('vips/pool/copy_exercises_dialog', ["exercise_ids[{$exercise['id']}]" => $exercise['assignment_id']]), + _('Aufgabe kopieren'), Icon::create('copy'), ['data-dialog' => 'size=auto'] + ) ?> + + <? $menu->addButton('delete', _('Aufgabe löschen'), Icon::create('trash'), [ + 'formaction' => $controller->url_for('vips/pool/delete_exercises', ["exercise_ids[{$exercise['id']}]" => $exercise['assignment_id']]), + 'data-confirm' => sprintf(_('Wollen Sie wirklich die Aufgabe „%s“ löschen?'), $exercise['title']) + ]) ?> + <?= $menu->render() ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="4"> + <?= Studip\Button::create(_('Kopieren'), 'copy_selected', [ + 'class' => 'batch_action', + 'data-dialog' => 'size=auto', + 'formaction' => $controller->url_for('vips/pool/copy_exercises_dialog') + ]) ?> + <?= Studip\Button::create(_('Verschieben'), 'move_selected', [ + 'class' => 'batch_action', + 'data-dialog' => 'size=auto', + 'formaction' => $controller->url_for('vips/pool/move_exercises_dialog') + ]) ?> + <?= Studip\Button::create(_('Löschen'), 'delete_selected', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/pool/delete_exercises'), + 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgaben löschen?') + ]) ?> + </td> + <td colspan="3" class="actions"> + <?= $controller->page_chooser($controller->url_for('vips/pool/exercises', ['page' => '%d', 'sort' => $sort, 'desc' => $desc, 'search_filter' => $search_filter]), $count, $page) ?> + </td> + </tr> + </tfoot> + </table> +</form> diff --git a/app/views/vips/pool/move_exercises_dialog.php b/app/views/vips/pool/move_exercises_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..09e7ac34c894af930ebdd8037dd806e47908efae --- /dev/null +++ b/app/views/vips/pool/move_exercises_dialog.php @@ -0,0 +1,50 @@ +<?php +/** + * @var Vips_PoolController $controller + * @var int[] $exercise_ids + * @var Course[] $courses + */ +?> +<form class="default" action="<?= $controller->link_for('vips/pool/move_exercises') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($exercise_ids as $exercise_id => $assignment_id): ?> + <input type="hidden" name="exercise_ids[<?= $exercise_id ?>]" value="<?= $assignment_id ?>"> + <? endforeach ?> + + <label> + <?= _('Aufgabenblatt auswählen') ?> + + <select name="assignment_id" class="vips_nested_select"> + <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + + <? foreach ($courses as $course): ?> + <? $assignments = VipsAssignment::findByRangeId($course->id) ?> + <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/add_exercise_dialog.php b/app/views/vips/sheets/add_exercise_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..f13a08d580c1018a04bea79d00b85fa00aa16daf --- /dev/null +++ b/app/views/vips/sheets/add_exercise_dialog.php @@ -0,0 +1,26 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var array<class-string<Exercise>, array> $exercise_types + */ +?> +<form class="default" action="<?= $controller->edit_exercise() ?>" method="POST"> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + + <fieldset> + <legend> + <?= _('Aufgabentyp auswählen') ?> + </legend> + + <div class="exercise_types"> + <? foreach ($exercise_types as $type => $entry): ?> + <button class="exercise_type" name="exercise_type" value="<?= htmlReady($type) ?>" + style="<?= $type::getTypeIcon()->asCSS(40) ?>"> + <b><?= htmlReady($entry['name']) ?></b><br> + <?= htmlReady($type::getTypeDescription()) ?> + </button> + <? endforeach ?> + </div> + </fieldset> +</form> diff --git a/app/views/vips/sheets/assign_block_dialog.php b/app/views/vips/sheets/assign_block_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..ba75f964f8d7f7ad64e522913814de3a5b2e55cd --- /dev/null +++ b/app/views/vips/sheets/assign_block_dialog.php @@ -0,0 +1,32 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int[] $assignment_ids + * @var VipsBlock[] $blocks + */ +?> +<form class="default" action="<?= $controller->assign_block() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($assignment_ids as $assignment_id): ?> + <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>"> + <? endforeach ?> + + <label> + <?= _('Block auswählen') ?> + + <select name="block_id"> + <option value="0"> + <?= _('Keinem Block zuweisen') ?> + </option> + <? foreach ($blocks as $block): ?> + <option value="<?= $block->id ?>"> + <?= htmlReady($block->name) ?> + </option> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Zuweisen'), 'assign_block') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/assignment_type_tooltip.php b/app/views/vips/sheets/assignment_type_tooltip.php new file mode 100644 index 0000000000000000000000000000000000000000..a6bf99e49d25d35228c1fa998ade42fa9ce3e97f --- /dev/null +++ b/app/views/vips/sheets/assignment_type_tooltip.php @@ -0,0 +1,18 @@ +<dl style="margin-top: 0;"> + <dt><?= _('Ãœbung') ?></dt> + <dd> + <?= _('Hausaufgabe, freie Bearbeitung im festgelegten Zeitraum, auch Gruppenarbeit möglich') ?> + </dd> + <dt><?= _('Selbsttest') ?></dt> + <dd> + <?= _('Kontrolle des Lernfortschritts, Feedback nach der Abgabe einer Lösung, automatische Korrektur') ?> + </dd> + <dt><?= _('Klausur') ?></dt> + <dd> + <?= _('Online-Klausur mit individueller Bearbeitungszeit, konfigurierbare Zugangsbeschränkungen') ?> + </dd> +</dl> + +<a href="<?= format_help_url(PageLayout::getHelpKeyword()) ?>" target="_blank"> + <?= _('Weitere Informationen in der Hilfe') ?> +</a> diff --git a/app/views/vips/sheets/content_bar_icons.php b/app/views/vips/sheets/content_bar_icons.php new file mode 100644 index 0000000000000000000000000000000000000000..5d6268a7b38b6bc7f045fc4b28aa602bd380786b --- /dev/null +++ b/app/views/vips/sheets/content_bar_icons.php @@ -0,0 +1,19 @@ +<? if (isset($prev_exercise_url)): ?> + <a href="<?= htmlReady($prev_exercise_url) ?>"> + <?= Icon::create('arr_1left')->asImg(24, ['title' => _('Vorige Aufgabe')]) ?> + </a> +<? else: ?> + <span> + <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(24) ?> + </span> +<? endif ?> + +<? if (isset($next_exercise_url)): ?> + <a href="<?= htmlReady($next_exercise_url) ?>"> + <?= Icon::create('arr_1right')->asImg(24, ['title' => _('Nächste Aufgabe')]) ?> + </a> +<? else: ?> + <span> + <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(24) ?> + </span> +<? endif ?> diff --git a/app/views/vips/sheets/copy_assignment_dialog.php b/app/views/vips/sheets/copy_assignment_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..49eecb95bbfa0fe597b453535532d63b2c2548b2 --- /dev/null +++ b/app/views/vips/sheets/copy_assignment_dialog.php @@ -0,0 +1,104 @@ +<form class="default" action="<?= $controller->link_for('vips/sheets/copy_assignment') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="sort" value="<?= $sort ?>"> + <input type="hidden" name="desc" value="<?= $desc ?>"> + + <input type="text" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>" aria-label="<?= _('Suchbegriff eingeben') ?>" + placeholder="<?= _('Aufgabenblatt oder Veranstaltung') ?>" style="max-width: 24em;"> + + <select name="search_filter[assignment_type]" class="inline_select" aria-label="<?= _('Modus auswählen') ?>"> + <option value=""> + <?= _('Beliebiger Modus') ?> + </option> + <? foreach ($assignment_types as $type => $entry): ?> + <option value="<?= $type ?>" <?= $search_filter['assignment_type'] == $type ? 'selected' : '' ?>> + <?= htmlReady($entry['name']) ?> + </option> + <? endforeach ?> + </select> + + <select name="search_filter[range_type]" class="inline_select" aria-label="<?= _('Quelle auswählen') ?>" style="margin-left: 1em;"> + <option value="user" <?= $search_filter['range_type'] == 'user' ? 'selected' : '' ?>> + <?= _('Persönliche Aufgabensammlung') ?> + </option> + <option value="course" <?= $search_filter['range_type'] == 'course' ? 'selected' : '' ?>> + <?= _('Aufgaben in Veranstaltungen') ?> + </option> + </select> + + <span style="margin-left: 1em;"> + <?= Studip\Button::create(_('Suchen'), 'start_search', ['data-dialog' => 'size=1200x800', 'formaction' => $controller->url_for('vips/sheets/copy_assignment_dialog')]) ?> + <?= Studip\Button::create(_('Zurücksetzen'), 'reset_search', ['data-dialog' => 'size=1200x800', 'formaction' => $controller->url_for('vips/sheets/copy_assignment_dialog')]) ?> + </div> + + <? if ($count): ?> + <table class="default"> + <thead> + <tr class="sortable"> + <th style="width: 45%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>"> + <input type="checkbox" data-proxyfor=".batch_select_d" data-activates=".batch_action_d" aria-label="<?= _('Alle Aufgaben auswählen') ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog', + compact('search_filter') + ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc]) ?>" data-dialog="size=1200x800"> + <?= _('Aufgabenblatt') ?> + </a> + </th> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'course_name', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog', + compact('search_filter') + ['sort' => 'course_name', 'desc' => $sort === 'course_name' && !$desc]) ?>" data-dialog="size=1200x800"> + <?= _('Veranstaltung') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog', + compact('search_filter') + ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc]) ?>" data-dialog="size=1200x800"> + <?= _('Semester') ?> + </a> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignments as $assignment): ?> + <? $course_id = $assignment['range_type'] === 'course' ? $assignment['range_id'] : null ?> + <tr> + <td> + <label class="undecorated"> + <input class="batch_select_d" type="checkbox" name="assignment_ids[]" value="<?= $assignment['id'] ?>" aria-label="<?= _('Zeile auswählen') ?>"> + <?= htmlReady($assignment['test_title']) ?> + + <a href="<?= $controller->link_for('vips/sheets/show_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]) ?>" target="_blank"> + <?= Icon::create('link-intern')->asImg(['title' => _('Vorschau anzeigen')]) ?> + </a> + </label> + </td> + <td> + <? if ($course_id): ?> + <?= htmlReady($assignment['course_name']) ?> + <? endif ?> + </td> + <td> + <? if ($course_id && $assignment['start_time']): ?> + <?= htmlReady(Semester::findByTimestamp($assignment['start_time'])->name) ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="3" class="actions"> + <?= $controller->page_chooser($controller->url_for('vips/sheets/copy_assignment_dialog', ['page' => '%d'] + compact('search_filter', 'sort', 'desc')), + $count, $page, 'data-dialog="size=1200x800"', $size) ?> + </td> + </tr> + </tfoot> + </table> + <? else: ?> + <?= MessageBox::info(_('Es wurden keine Aufgabenblätter gefunden.')) ?> + <? endif ?> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Kopieren'), 'copy_assignment', ['class' => 'batch_action_d']) ?> + </footer> +</form> diff --git a/app/views/vips/sheets/copy_assignments_dialog.php b/app/views/vips/sheets/copy_assignments_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..2c376616f2a6c1af9711fc120ce564e3de2f50cd --- /dev/null +++ b/app/views/vips/sheets/copy_assignments_dialog.php @@ -0,0 +1,34 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int[] $assignment_ids + * @var Course[] $courses + * @var string $course_id + */ +?> +<form class="default" action="<?= $controller->copy_assignments() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($assignment_ids as $assignment_id): ?> + <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>"> + <? endforeach ?> + + <label> + <?= _('Ziel auswählen') ?> + + <select name="course_id" class="vips_nested_select"> + <option value=""> + <?= _('Persönliche Aufgabensammlung') ?> + </option> + + <? foreach ($courses as $course): ?> + <option value="<?= $course->id ?>" <?= $course->id == $course_id ? 'selected' : '' ?>> + <?= htmlReady($course->name) ?> (<?= htmlReady($course->start_semester->name) ?>) + </option> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/copy_exercise_dialog.php b/app/views/vips/sheets/copy_exercise_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..7eeec26986483f754a0ab68d87ec0ce14dac7f6c --- /dev/null +++ b/app/views/vips/sheets/copy_exercise_dialog.php @@ -0,0 +1,132 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var array $search_filter + * @var array $exercise_types + * @var string $sort + * @var bool $desc + * @var int $count + * @var Exercise[] $exercises + * @var int $page + * @var int $size + * + */ +?> +<form class="default" action="<?= $controller->copy_exercise() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="sort" value="<?= htmlReady($sort) ?>"> + <input type="hidden" name="desc" value="<?= htmlReady($desc) ?>"> + + <input type="text" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>" aria-label="<?= _('Suchbegriff eingeben') ?>" + placeholder="<?= _('Titel der Aufgabe oder Veranstaltung') ?>" style="max-width: 24em;"> + + <select name="search_filter[exercise_type]" class="inline_select" aria-label="<?= _('Aufgabentyp auswählen') ?>"> + <option value=""> + <?= _('Alle Aufgabentypen') ?> + </option> + <? foreach ($exercise_types as $type => $entry): ?> + <option value="<?= $type ?>" <?= $search_filter['exercise_type'] == $type ? 'selected' : '' ?>> + <?= htmlReady($entry['name']) ?> + </option> + <? endforeach ?> + </select> + + <select name="search_filter[range_type]" class="inline_select" aria-label="<?= _('Quelle auswählen') ?>" style="margin-left: 1em;"> + <option value="user" <?= $search_filter['range_type'] == 'user' ? 'selected' : '' ?>> + <?= _('Persönliche Aufgabensammlung') ?> + </option> + <option value="course" <?= $search_filter['range_type'] == 'course' ? 'selected' : '' ?>> + <?= _('Aufgaben in Veranstaltungen') ?> + </option> + </select> + + <span style="margin-left: 1em;"> + <?= Studip\Button::create(_('Suchen'), 'start_search', ['data-dialog' => 'size=big', 'formaction' => $controller->url_for('vips/sheets/copy_exercise_dialog')]) ?> + <?= Studip\Button::create(_('Zurücksetzen'), 'reset_search', ['data-dialog' => 'size=big', 'formaction' => $controller->url_for('vips/sheets/copy_exercise_dialog')]) ?> + </span> + + <? if ($count): ?> + <table class="default"> + <thead> + <tr class="sortable"> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <input type="checkbox" data-proxyfor=".batch_select_d" data-activates=".batch_action_d" aria-label="<?= _('Alle Aufgaben auswählen') ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog', + compact('assignment_id', 'search_filter') + ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>" data-dialog="size=big"> + <?= _('Titel der Aufgabe') ?> + </a> + </th> + <th style="width: 25%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog', + compact('assignment_id', 'search_filter') + ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc]) ?>" data-dialog="size=big"> + <?= _('Aufgabenblatt') ?> + </a> + </th> + <th style="width: 25%;" class="<?= $controller->sort_class($sort === 'course_name', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog', + compact('assignment_id', 'search_filter') + ['sort' => 'course_name', 'desc' => $sort === 'course_name' && !$desc]) ?>" data-dialog="size=big"> + <?= _('Veranstaltung') ?> + </a> + </th> + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog', + compact('assignment_id', 'search_filter') + ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc]) ?>" data-dialog="size=big"> + <?= _('Semester') ?> + </a> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($exercises as $exercise): ?> + <? $course_id = $exercise['range_type'] === 'course' ? $exercise['range_id'] : null ?> + <tr> + <td> + <label class="undecorated"> + <input class="batch_select_d" type="checkbox" name="exercise_ids[<?= $exercise['id'] ?>]" value="<?= $exercise['assignment_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>"> + <?= htmlReady($exercise['title']) ?> + + <a href="<?= $controller->link_for('vips/sheets/preview_exercise', ['assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]) ?>" + data-dialog="id=vips_preview;size=800x600" target="_blank"> + <?= Icon::create('question-circle')->asImg(['title' => _('Vorschau anzeigen')]) ?> + </a> + </label> + </td> + <td> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id']]) ?>"> + <?= htmlReady($exercise['test_title']) ?> + </a> + </td> + <td> + <? if ($course_id): ?> + <?= htmlReady($exercise['course_name']) ?> + <? endif ?> + </td> + <td> + <? if ($course_id && $exercise['start_time']): ?> + <?= htmlReady(Semester::findByTimestamp($exercise['start_time'])->name) ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="4" class="actions"> + <?= $controller->page_chooser($controller->url_for('vips/sheets/copy_exercise_dialog', ['page' => '%d'] + compact('assignment_id', 'search_filter', 'sort', 'desc')), + $count, $page, 'data-dialog="size=big"', $size) ?> + </td> + </tr> + </tfoot> + </table> + <? else: ?> + <?= MessageBox::info(_('Es wurden keine Aufgaben gefunden.')) ?> + <? endif ?> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Kopieren'), 'copy_exercise', ['class' => 'batch_action_d']) ?> + </footer> +</form> diff --git a/app/views/vips/sheets/copy_exercises_dialog.php b/app/views/vips/sheets/copy_exercises_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..17f66781edb86f367d180d0e7d85ced9d44c4c82 --- /dev/null +++ b/app/views/vips/sheets/copy_exercises_dialog.php @@ -0,0 +1,52 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var int[] $exercise_ids + * @var Course[] $courses + */ +?> +<form class="default" action="<?= $controller->copy_exercises() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <? foreach ($exercise_ids as $exercise_id): ?> + <input type="hidden" name="exercise_ids[]" value="<?= $exercise_id ?>"> + <? endforeach ?> + + <label> + <?= _('Aufgabenblatt auswählen') ?> + + <select name="target_assignment_id" class="vips_nested_select"> + <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + + <? foreach ($courses as $course): ?> + <? $assignments = VipsAssignment::findByRangeId($course->id) ?> + <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/edit_assignment.php b/app/views/vips/sheets/edit_assignment.php new file mode 100644 index 0000000000000000000000000000000000000000..1d2c66e8a4f498dbe6ff1113cd7df322c4eb57db --- /dev/null +++ b/app/views/vips/sheets/edit_assignment.php @@ -0,0 +1,329 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var VipsAssignment $assignment + * @var VipsTest $test + * @var array $assignment_types + * @var VipsBlock[] $blocks + * @var array $exam_rooms + * @var bool $locked + */ +?> + +<?= $contentbar->render() ?> + +<form class="default width-1200" action="<?= $controller->store_assignment() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <button hidden name="store"></button> + + <fieldset id="assignment" class="<?= htmlReady($assignment->type) ?>"> + <legend> + <?= _('Grunddaten') ?> + </legend> + + <? if ($this->locked): ?> + <?= MessageBox::info(_('Die Klausur kann nur eingeschränkt bearbeitet werden, da bereits Lösungen abgegeben wurden.')) ?> + <? endif ?> + + <label> + <span class="required"><?= _('Titel') ?></span> + <input type="text" name="assignment_name" class="character_input size-l" value="<?= htmlReady($test->title) ?>" data-secure required> + </label> + + <label> + <?= _('Beschreibung') ?> + <textarea name="assignment_description" class="character_input size-l wysiwyg" data-secure><?= wysiwygReady($test->description) ?></textarea> + </label> + + <fieldset class="undecorated"> + <legend> + <?= _('Bearbeitungsmodus') ?> + <?= tooltipIcon($this->render_partial('vips/sheets/assignment_type_tooltip'), false, true) ?> + </legend> + + <? foreach ($assignment_types as $type => $entry) : ?> + <label class="undecorated"> + <input type="radio" class="assignment_type" name="assignment_type" value="<?= $type ?>" <?= $assignment->type == $type ? 'checked' : '' ?> data-secure> + <?= htmlReady($entry['name']) ?> + </label> + <? endforeach ?> + </fieldset> + + <label class="formpart undecorated" id="start_date"> + <div class="label-text"> + <span class="required"><?= _('Startzeitpunkt') ?></span> + </div> + + <input type="text" name="start_date" class="has-date-picker size-s" value="<?= date('d.m.Y', $assignment->start) ?>" data-secure required> + <input type="text" name="start_time" class="has-time-picker size-s" value="<?= date('H:i', $assignment->start) ?>" data-secure required> + </label> + + <? $required = $assignment->type !== 'selftest' ? 'required' : '' ?> + + <label class="formpart undecorated" id="end_date"> + <div class="label-text"> + <span class="<?= $required ?>"><?= _('Endzeitpunkt') ?></span> + </div> + + <input type="text" name="end_date" class="has-date-picker size-s" value="<?= $assignment->isUnlimited() ? '' : date('d.m.Y', $assignment->end) ?>" data-secure <?= $required ?>> + <input type="text" name="end_time" class="has-time-picker size-s" value="<?= $assignment->isUnlimited() ? '' : date('H:i', $assignment->end) ?>" data-secure <?= $required ?>> + </label> + + <? $disabled = $assignment->type !== 'exam' ? 'disabled' : '' ?> + + <label id="exam_length" class="practice-hidden selftest-hidden"> + <span class="required"><?= _('Dauer in Minuten') ?></span> + <input type="number" name="exam_length" min="0" max="99999" value="<?= htmlReady($assignment->options['duration']) ?>" <?= $disabled ?> data-secure required> + </label> + + <section> + <input id="options-toggle" class="options-toggle" type="checkbox" value="on" <?= $assignment_id ? '' : 'checked' ?>> + <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="<?= $assignment_id ? 'false' : 'true' ?>"> + <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?> + <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?> + <?= _('Weitere Einstellungen') ?> + </a> + + <div class="toggle-box" id="options-panel"> + <? if ($assignment->range_type === 'course'): ?> + <label class="formpart undecorated"> + <div class="label-text"> + <?= _('Block') ?> + </div> + + <select name="assignment_block" style="max-width: 22.7em;" data-secure> + <option value="0"> + <?= _('Keinem Block zuweisen') ?> + </option> + <? foreach ($blocks as $block): ?> + <option value="<?= $block->id ?>" <?= $assignment->block_id == $block->id ? 'selected' : '' ?>> + <?= htmlReady($block->name) ?> + </option> + <? endforeach ?> + </select> + <?= _('oder') ?> + <input type="text" name="assignment_block_name" style="max-width: 22.7em;" placeholder="<?= _('Neuen Block anlegen') ?>" data-secure> + </label> + <? endif ?> + + <label class="exam-hidden selftest-hidden"> + <input type="checkbox" name="use_groups" value="1" <?= $assignment->options['use_groups'] !== 0 ? 'checked' : '' ?> data-secure> + <?= _('Aufgaben können in Ãœbungsgruppen bearbeitet werden') ?> + <?= tooltipIcon(_('Hat keine Auswirkungen, wenn keine Ãœbungsgruppen angelegt wurden.')) ?> + </label> + + <label class="practice-hidden selftest-hidden"> + <input type="checkbox" name="self_assessment" value="1" <?= $assignment->options['self_assessment'] ? 'checked' : '' ?> data-secure> + <?= _('Testklausur zur Selbsteinschätzung der Teilnehmenden') ?> + <?= tooltipIcon(_('Teilnehmende können beliebig oft neu starten, Ergebnisse können direkt nach Ablauf der Bearbeitungszeit zugänglich gemacht werden.')) ?> + </label> + + <label class="practice-hidden selftest-hidden"> + <input type="checkbox" name="shuffle_exercises" value="1" <?= $assignment->options['shuffle_exercises'] ? 'checked' : '' ?> data-secure> + <?= _('Zufällige Reihenfolge der Aufgaben bei Anzeige der Klausur') ?> + </label> + + <label class="practice-hidden selftest-hidden"> + <input type="checkbox" name="shuffle_answers" value="1" <?= $assignment->options['shuffle_answers'] !== 0 ? 'checked' : '' ?> data-secure> + <?= _('Zufällige Reihenfolge der Antworten in Multiple- und Single-Choice-Aufgaben') ?> + </label> + + <label class="exam-hidden practice-hidden"> + <input type="checkbox" name="resets" value="1" <?= $assignment->options['resets'] !== 0 ? 'checked' : '' ?> data-secure> + <?= _('Teilnehmende dürfen ihre Lösungen zurücksetzen und den Test neu starten') ?> + </label> + + <label class="exam-hidden practice-hidden"> + <input type="checkbox" value="1" <?= $assignment->options['max_tries'] !== 0 ? 'checked' : '' ?> data-activates=".max_tries" data-secure> + <?= _('Anzeige der Musterlösung nach eingesteller Anzahl von Fehlversuchen') ?> + </label> + + <label class="exam-hidden practice-hidden"> + <?= _('Anzahl der Lösungsversuche pro Aufgabe') ?> + <input type="number" name="max_tries" class="max_tries" min="1" value="<?= $assignment->options['max_tries'] ?: 3 ?>" data-secure> + </label> + + <label> + <?= _('Falsche Antworten in Multiple- und Single-Choice-Aufgaben') ?> + + <select name="evaluation_mode" data-secure> + <option value="0"> + <?= _('… geben keinen Punktabzug') ?> + </option> + <option value="1" <?= $assignment->options['evaluation_mode'] == VipsAssignment::SCORING_NEGATIVE_POINTS ? 'selected' : '' ?>> + <?= _('… geben Punktabzug (Gesamtpunktzahl Aufgabe mind. 0)') ?> + </option> + <option value="2" <?= $assignment->options['evaluation_mode'] == VipsAssignment::SCORING_ALL_OR_NOTHING ? 'selected' : '' ?>> + <?= _('… führen zur Bewertung der Aufgabe mit 0 Punkten') ?> + </option> + </select> + </label> + + <label> + <?= _('Notizen (für Teilnehmende unsichtbar)') ?> + <textarea name="assignment_notes" class="character_input" data-secure><?= htmlReady($assignment->options['notes']) ?></textarea> + </label> + + <label class="practice-hidden selftest-hidden"> + <?= _('Zugangscode zur Klausur (optional)') ?> + <input type="text" name="access_code" value="<?= htmlReady($assignment->options['access_code']) ?>" data-secure> + </label> + + <label class="practice-hidden selftest-hidden"> + <?= _('Zugriff auf Prüfungsräume oder IP-Bereiche beschränken (optional)') ?> + <?= tooltipIcon($this->render_partial('vips/sheets/ip_range_tooltip'), false, true) ?> + <input type="text" name="ip_range" class="validate_ip_range" value="<?= htmlReady($assignment->options['ip_range']) ?>" data-secure> + </label> + + <? if ($exam_rooms): ?> + <div class="practice-hidden selftest-hidden smaller"> + <?= _('Raum hinzufügen:') ?> + <? foreach (array_keys($exam_rooms) as $room_name): ?> + <a href="#" class="add_ip_range" data-value="#<?= htmlReady($room_name) ?>"> + <?= htmlReady($room_name) ?> + </a> + <? endforeach ?> + </div> + <? endif?> + </div> + + <div class="practice-hidden exam-hidden"> + <input id="feedback-toggle" class="options-toggle" type="checkbox" value="on"> + <a class="caption" href="#" role="button" data-toggles="#feedback-toggle" aria-controls="feedback-panel" aria-expanded="false"> + <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?> + <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?> + <?= _('Automatisches Feedback') ?> + </a> + + <div class="toggle-box" id="feedback-panel"> + <table class="default description fixed"> + <thead> + <tr> + <th style="width: 16%;"> + <?= _('Erforderliche Punkte') ?> + </th> + <th style="width: 76%;"> + <?= _('Kommentar zur Bewertung') ?> + </th> + <th class="actions" style="width: 8%;"> + <?= _('Löschen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list" style="vertical-align: top;"> + <? if (isset($assignment->options['feedback'])): ?> + <? foreach ($assignment->options['feedback'] as $threshold => $feedback): ?> + <tr class="dynamic_row"> + <td> + <input type="number" name="threshold[]" min="0" max="100" value="<?= htmlReady($threshold) ?>" data-secure> % + </td> + <td> + <textarea name="feedback[]" class="character_input size-l wysiwyg" data-secure><?= wysiwygReady($feedback) ?></textarea> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?> + </td> + </tr> + <? endforeach ?> + <? endif ?> + + <tr class="dynamic_row template"> + <td> + <input type="number" name="threshold[]" min="0" max="100" data-secure> % + </td> + <td> + <textarea name="feedback[]" class="character_input size-l wysiwyg-hidden" data-secure></textarea> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?> + </td> + </tr> + + <tr> + <th colspan="3"> + <?= Studip\Button::create(_('Eintrag hinzufügen'), 'add_feedback', ['class' => 'add_dynamic_row']) ?> + </th> + </tr> + </tbody> + </table> + </div> + </div> + </section> + + </fieldset> + + <table class="default" id="exercises"> + <? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + + <? if (count($test->exercise_refs)): ?> + <thead> + <tr> + <th style="padding-left: 2ex;"> + <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgaben auswählen') ?>"> + </th> + <th></th> + <th style="width: 60%;"> + <?= _('Aufgaben') ?> + </th> + <th style="width: 22%;"> + <?= _('Aufgabentyp') ?> + </th> + <th style="width: 5em;"> + <span class="required"><?= _('Punkte') ?></span> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody id="list" class="dynamic_list" data-assignment="<?= $assignment_id ?>" role="list"> + <?= $this->render_partial('vips/sheets/list_exercises') ?> + </tbody> + <? endif ?> + + <tfoot> + <tr> + <td colspan="4"> + <?= Studip\Button::createAccept(_('Speichern'), 'store') ?> + <? if ($assignment_id && !$locked): ?> + <?= Studip\LinkButton::create(_('Neue Aufgabe erstellen'), + $controller->url_for('vips/sheets/add_exercise_dialog', compact('assignment_id')), + ['data-dialog' => 'size=auto']) ?> + <? endif ?> + <? if (count($test->exercise_refs)): ?> + <?= Studip\Button::create(_('Kopieren'), 'copy_exercises', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/copy_exercises_dialog'), + 'data-dialog' => 'size=auto' + ]) ?> + <? if (!$locked): ?> + <?= Studip\Button::create(_('Verschieben'), 'move_exercises', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/move_exercises_dialog'), + 'data-dialog' => 'size=auto' + ]) ?> + <?= Studip\Button::create(_('Löschen'), 'delete_exercises', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/delete_exercises'), + 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgaben löschen?') + ]) ?> + <? endif ?> + <? endif ?> + </td> + <td colspan="2" style="padding-left: 0;"> + <? if (count($test->exercise_refs) > 0): ?> + <div class="points"> + <?= sprintf('%g', $test->getTotalPoints()) ?> + </div> + <? endif ?> + </td> + </tr> + </tfoot> + + <? setlocale(LC_NUMERIC, 'C') ?> + </table> +</form> diff --git a/app/views/vips/sheets/edit_exercise.php b/app/views/vips/sheets/edit_exercise.php new file mode 100644 index 0000000000000000000000000000000000000000..156d789e67df6a8735be115edae67fc4712135f4 --- /dev/null +++ b/app/views/vips/sheets/edit_exercise.php @@ -0,0 +1,161 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var VipsAssignment $assignment + * @var Exercise $exercise + * @var int $exercise_position + * @var int $max_points + */ +?> + +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<?= $contentbar->render() ?> + +<form class="default width-1200" action="<?= $controller->store_exercise() ?>" data-secure method="POST" enctype="multipart/form-data"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="exercise_type" value="<?= htmlReady($exercise->type) ?>"> + <? if ($exercise->id) : ?> + <input type="hidden" name="exercise_id" value="<?= $exercise->id ?>"> + <? endif ?> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <button hidden name="store_exercise"></button> + + <fieldset> + <legend> + <? if ($exercise->id): ?> + <?= $exercise_position ?>. + <? endif ?> + <?= htmlReady($exercise->getTypeName()) ?> + <? if ($exercise->id): ?> + <div style="float: right;"> + <? if ($max_points == (int) $max_points): ?> + <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?> + <? else: ?> + <?= sprintf(_('%g Punkte'), $max_points) ?> + <? endif ?> + </div> + <? endif ?> + </legend> + + <label> + <span class="required"><?= _('Titel') ?></span> + <input type="text" name="exercise_name" class="character_input size-l" value="<?= htmlReady($exercise->title) ?>" required> + </label> + + <label> + <?= _('Aufgabentext') ?> + <textarea name="exercise_question" class="character_input size-l wysiwyg" rows="<?= $exercise->textareaSize($exercise->description) ?>"><?= wysiwygReady($exercise->description) ?></textarea> + </label> + + <table class="default"> + <? if ($exercise->folder && count($exercise->folder->file_refs)): ?> + <thead> + <tr> + <th style="width: 60%;"> + <?= _('Dateien zur Aufgabe') ?> + </th> + <th style="width: 10%;"> + <?= _('Vorschau') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 15%;"> + <?= _('Datum') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list"> + <? foreach ($exercise->folder->file_refs as $file_ref): ?> + <tr class="dynamic_row"> + <td> + <input type="hidden" name="file_ids[]" value="<?= $file_ref->id ?>"> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>" <?= $file_ref->getContentDisposition() === 'inline' ? 'target="_blank"' : '' ?>> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <? if ($file_ref->isImage()): ?> + <img alt="<?= htmlReady($file_ref->name) ?>" src="<?= htmlReady($file_ref->getDownloadURL()) ?>" + style="max-height: 20px; vertical-align: bottom;"> + <? endif ?> + </td> + <td> + <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + + <tfoot> + <tr> + <td colspan="5"> + <?= Studip\Button::create(_('Dateien zur Aufgabe hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?> + <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span> + <?= tooltipIcon(sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576)) ?> + <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple> + </td> + </tr> + </tfoot> + </table> + + <? if ($exercise->folder && count($exercise->folder->file_refs)): ?> + <label> + <input type="checkbox" name="files_visible" value="1" <?= !$exercise->options['files_hidden'] ? 'checked' : '' ?>> + <?= _('Liste der Dateien unter dem Aufgabentext anzeigen') ?> + </label> + <? endif ?> + + <?= $this->render_partial($exercise->getEditTemplate($assignment)) ?> + + <input id="options-toggle" class="options-toggle" type="checkbox" value="on"> + <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="false"> + <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?> + <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?> + <?= _('Weitere Einstellungen') ?> + </a> + + <div class="toggle-box" id="options-panel"> + <label> + <?= _('Hinweise zur Bearbeitung der Aufgabe') ?> + <textarea name="exercise_hint" class="character_input size-l wysiwyg"><?= wysiwygReady($exercise->options['hint']) ?></textarea> + </label> + + <label> + <? if ($assignment->type === 'selftest') : ?> + <?= _('Automatisches Feedback bei falscher Antwort') ?> + <? else : ?> + <?= _('Vorlage für den Bewertungskommentar (manuelle Korrektur)') ?> + <? endif ?> + <textarea name="feedback" class="character_input size-l wysiwyg"><?= wysiwygReady($exercise->options['feedback']) ?></textarea> + </label> + + <? if ($assignment->type !== 'selftest') : ?> + <label> + <input type="checkbox" name="exercise_comment" value="1" <?= $exercise->options['comment'] ? 'checked' : '' ?>> + <?= _('Eingabe eines Kommentars durch Studierende erlauben') ?> + </label> + <? endif ?> + </div> + </fieldset> + + <footer> + <?= Studip\Button::createAccept(_('Speichern'), 'store_exercise') ?> + </footer> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/sheets/export_assignment.php b/app/views/vips/sheets/export_assignment.php new file mode 100644 index 0000000000000000000000000000000000000000..1a24f3c57eedf3b3295dcafa5803e73d60fc5fa2 --- /dev/null +++ b/app/views/vips/sheets/export_assignment.php @@ -0,0 +1,82 @@ +<?php +/** + * @var VipsAssignment $assignment + * @var array $files + */ +?><?= '<?xml version="1.0" encoding="UTF-8"?>' ?> + +<test xmlns="urn:vips:test:v1.0" id="test-<?= $assignment->id ?>" type="<?= $assignment->type ?>" + start="<?= date('c', $assignment->start) ?>" + <? if (!$assignment->isUnlimited()): ?> + end="<?= date('c', $assignment->end) ?>" + <? endif ?> + <? if ($assignment->type === 'exam' && $assignment->options['duration']): ?> + duration="<?= $assignment->options['duration'] ?>" + <? endif ?> + <? if ($assignment->block_id): ?> + block="<?= htmlReady($assignment->block->name) ?>" + <? endif ?> + > + <title> + <?= htmlReady($assignment->test->title) ?> + </title> + <description> + <?= htmlReady($assignment->test->description) ?> + </description> + <? if ($assignment->options['notes'] != ''): ?> + <notes> + <?= htmlReady($assignment->options['notes']) ?> + </notes> + <? endif ?> + <limit + <? if (isset($assignment->options['access_code'])): ?> + access-code="<?= htmlReady($assignment->options['access_code']) ?>" + <? endif ?> + <? if (isset($assignment->options['ip_range'])): ?> + ip-ranges="<?= htmlReady($assignment->options['ip_range']) ?>" + <? endif ?> + <? if ($assignment->options['resets'] === 0): ?> + resets="0" + <? endif ?> + <? if (isset($assignment->options['max_tries'])): ?> + tries="<?= $assignment->options['max_tries'] ?>" + <? endif ?> + /> + <option + <? if ($assignment->options['evaluation_mode'] == VipsAssignment::SCORING_NEGATIVE_POINTS): ?> + scoring-mode="negative_points" + <? elseif ($assignment->options['evaluation_mode'] == VipsAssignment::SCORING_ALL_OR_NOTHING): ?> + scoring-mode="all_or_nothing" + <? endif ?> + <? if ($assignment->isShuffled()): ?> + shuffle-answers="true" + <? endif ?> + <? if ($assignment->isExerciseShuffled()): ?> + shuffle-exercises="true" + <? endif ?> + > + </option> + <? if (isset($assignment->options['feedback'])): ?> + <feedback-items> + <? foreach ($assignment->options['feedback'] as $threshold => $feedback): ?> + <feedback score="<?= (float) $threshold / 100 ?>"> + <?= htmlReady($feedback) ?> + </feedback> + <? endforeach ?> + </feedback-items> + <? endif ?> + <exercises> + <? foreach ($assignment->test->exercise_refs as $exercise_ref): ?> + <?= $this->render_partial($exercise_ref->exercise->getXMLTemplate($assignment), ['points' => $exercise_ref->points]) ?> + <? endforeach ?> + </exercises> + <? if ($files): ?> + <files> + <? foreach ($files as $file): ?> + <file id="file-<?= $file->id ?>" name="<?= htmlReady($file->name) ?>"> + <?= base64_encode(file_get_contents($file->getPath())) ?> + </file> + <? endforeach ?> + </files> + <? endif ?> +</test> diff --git a/app/views/vips/sheets/import_assignment_dialog.php b/app/views/vips/sheets/import_assignment_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..f8a478b0048e99fb8afb6b8c4b3ba81d8984ac16 --- /dev/null +++ b/app/views/vips/sheets/import_assignment_dialog.php @@ -0,0 +1,21 @@ +<?php +/** + * @var Vips_SheetsController $controller + */ +?> +<form class="default" action="<?= $controller->link_for('vips/sheets/import_test') ?>" method="POST" enctype="multipart/form-data"> + <?= CSRFProtection::tokenTag() ?> + + <h4> + <?= _('Aufgabenblätter aus Datei(en) importieren') ?> + </h4> + + <label> + <?= _('Datei(en):') ?> + <input type="file" name="upload[]" multiple style="min-width: 40em;"> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Importieren'), 'import') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/ip_range_tooltip.php b/app/views/vips/sheets/ip_range_tooltip.php new file mode 100644 index 0000000000000000000000000000000000000000..24a407f0dbc6af2fedea99a7b4a513082c19cda5 --- /dev/null +++ b/app/views/vips/sheets/ip_range_tooltip.php @@ -0,0 +1,26 @@ +<?= _('Beispiele:') ?> + +<dl> + <dt>131.173.73.42</dt> + <dd> + <?= _('Gibt nur diese IP-Adresse frei.') ?> + </dd> + <dt>131.173.73 <?= _('oder') ?> 131.173.73.0/24</dt> + <dd> + <?= _('Gibt alle IP-Adressen frei, die so beginnen.') ?> + </dd> + <dt>131.173.73-131.173.75</dt> + <dd> + <?= _('Gibt alle IP-Adressen aus dem Bereich 131.173.73 bis 131.173.75 frei.') ?> + </dd> + <? if (!empty($exam_rooms)): ?> + <dt>#94/E01</dt> + <dd> + <?= _('Gibt alle IP-Adressen in diesem Raum frei.') ?> + </dd> + <? endif?> +</dl> + +<span class="smaller"> + <?= _('Außerdem können Listen aller genannten Fälle eingetragen werden (durch Komma oder Leerzeichen getrennt).') ?> +</span> diff --git a/app/views/vips/sheets/list_assignments.php b/app/views/vips/sheets/list_assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..29e85b3ccb7b82bc87d0db478fc2b98574fc7440 --- /dev/null +++ b/app/views/vips/sheets/list_assignments.php @@ -0,0 +1,27 @@ +<?php +/** + * @var int $num_assignments + * @var array $assignment_data + */ +?> +<? if ($num_assignments == 0): ?> + <div class="vips-teaser"> + <header><?= _('Aufgaben und Prüfungen') ?></header> + <p> + <?= _('Mit diesem Werkzeug können Ãœbungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' . + 'Die Lehrenden erhalten eine Ãœbersicht darüber, welche Teilnehmenden eine Ãœbung oder einen ' . + 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Ãœbungszetteln ' . + 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' . + 'erhalten sofort einen Ãœberblick darüber, was noch zu korrigieren ist. Neben allgemein ' . + 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' . + 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.') ?> + </p> + <?= Studip\LinkButton::create(_('Aufgabenblatt erstellen'), $controller->url_for('vips/sheets/edit_assignment')) ?> + </div> +<? endif ?> + +<? foreach ($assignment_data as $i => $assignment_list): ?> + <? if (count($assignment_list['assignments']) > 0 || isset($assignment_list['block']->id)): ?> + <?= $this->render_partial('vips/sheets/list_assignments_list', ['i' => $i] + $assignment_list) ?> + <? endif ?> +<? endforeach ?> diff --git a/app/views/vips/sheets/list_assignments_list.php b/app/views/vips/sheets/list_assignments_list.php new file mode 100644 index 0000000000000000000000000000000000000000..bc3fc1e1e0baabcb49122015002aead8e0b6dfbd --- /dev/null +++ b/app/views/vips/sheets/list_assignments_list.php @@ -0,0 +1,207 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var VipsBlock $block + * @var string $title + * @var string $sort + * @var bool $desc + * @var int $i + * @var VipsGroup $group + * @var VipsAssignment[] $assignments + * @var VipsBlock[] $blocks + */ +?> +<form action="" method="POST"> + <?= CSRFProtection::tokenTag() ?> + + <table class="default"> + <caption> + <?= htmlReady($title) ?> + + <? if (isset($block->id)): ?> + <? if (!$block->visible): ?> + <?= _('(für Teilnehmende unsichtbar)') ?> + <? elseif ($block->group_id): ?> + <?= sprintf(_('(sichtbar für Gruppe „%s“)'), htmlReady($block->group->name)) ?> + <? else: ?> + <?= _('(für alle sichtbar)') ?> + <? endif ?> + + <div class="actions"> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink( + $controller->url_for('vips/admin/edit_block', ['block_id' => $block->id]), + _('Block bearbeiten'), + Icon::create('edit'), + ['data-dialog' => 'size=auto'] + ) ?> + <? $menu->addButton( + 'delete', + _('Block löschen'), + Icon::create('trash'), + [ + 'formaction' => $controller->url_for('vips/admin/delete_block', ['block_id' => $block->id]), + 'data-confirm' => sprintf(_('Wollen Sie wirklich den Block „%s“ löschen?'), $title) + ] + ) ?> + <?= $menu->render() ?> + </div> + <? endif ?> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 20px;"> + <input type="checkbox" data-proxyfor=".batch_select_<?= $i ?>" data-activates=".batch_action_<?= $i ?>" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>"> + </th> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>"> + <?= _('Titel') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>"> + <?= _('Start') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>"> + <?= _('Ende') ?> + </a> + </th> + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc]) ?>"> + <?= _('Modus') ?> + </a> + </th> + <th style="width: 10%;"> + <? if ($group == 1): ?> + <?= _('Status') ?> + <? else: ?> + <?= _('Block') ?> + <? endif ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignments as $assignment) : ?> + <tr> + <? $halted = $assignment->isRunning() && !$assignment->active ?> + <? $style = $halted ? 'color: red;' : '' ?> + <td> + <input class="batch_select_<?= $i ?>" type="checkbox" name="assignment_ids[]" value="<?= $assignment->id ?>" aria-label="<?= _('Zeile auswählen') ?>"> + </td> + <td style="<?= $style ?>"> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment->id]) ?>"> + <?= $assignment->getTypeIcon() ?> + <?= htmlReady($assignment->test->title) ?> + </a> + <? if ($halted): ?> + (<?= _('unterbrochen') ?>) + <? endif ?> + </td> + <td> + <?= date('d.m.Y, H:i', $assignment->start) ?> + </td> + <td> + <? if (!$assignment->isUnlimited()) : ?> + <?= date('d.m.Y, H:i', $assignment->end) ?> + <? endif ?> + </td> + <td> + <?= htmlReady($assignment->getTypeName()) ?> + </td> + <td> + <? if ($group == 1): ?> + <? if ($assignment->isFinished()): ?> + <?= _('beendet') ?> + <? elseif ($assignment->isRunning()): ?> + <?= _('aktiv') ?> + <? endif ?> + <? elseif ($assignment->block_id): ?> + <?= htmlReady($assignment->block->name) ?> + <? endif ?> + </td> + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? if ($assignment->isRunning()): ?> + <? if (!$assignment->active): ?> + <? $menu->addButton('go', _('Bearbeitung fortsetzen'), Icon::create('play'), [ + 'formaction' => $controller->url_for('vips/sheets/stopgo_assignment', ['assignment_id' => $assignment->id]) + ]) ?> + <? else : ?> + <? $menu->addButton('stop', _('Bearbeitung anhalten'), Icon::create('pause'), [ + 'formaction' => $controller->url_for('vips/sheets/stopgo_assignment', ['assignment_id' => $assignment->id]) + ]) ?> + <? endif ?> + <? elseif (!$assignment->isFinished()) : ?> + <? $menu->addLink($controller->url_for('vips/sheets/start_assignment_dialog', ['assignment_id' => $assignment->id]), + _('Aufgabenblatt starten'), Icon::create('play'), ['data-dialog' => 'size=auto'] + ) ?> + <? endif ?> + + <? $menu->addLink($controller->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment->id]), + _('Studierendensicht anzeigen'), Icon::create('community') + ) ?> + <? $menu->addLink($controller->url_for('vips/solutions/assignment_solutions', ['assignment_id' => $assignment->id]), + _('Aufgaben korrigieren'), Icon::create('accept') + ) ?> + <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment->id]), + _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank'] + ) ?> + <? $menu->addButton('copy', _('Aufgabenblatt duplizieren'), Icon::create('copy'), [ + 'formaction' => $controller->url_for('vips/sheets/copy_assignment', ['assignment_id' => $assignment->id]) + ]) ?> + <? if ($assignment->isLocked()): ?> + <? $menu->addButton('reset', _('Alle Lösungen zurücksetzen'), Icon::create('refresh'), [ + 'formaction' => $controller->url_for('vips/sheets/reset_assignment', ['assignment_id' => $assignment->id]), + 'data-confirm' => _('Achtung: Wenn Sie die Lösungen zurücksetzen, werden die Lösungen aller Teilnehmenden archiviert!') + ]) ?> + <? else: ?> + <? $menu->addButton('delete', _('Aufgabenblatt löschen'), Icon::create('trash'), [ + 'formaction' => $controller->url_for('vips/sheets/delete_assignment', ['assignment_id' => $assignment->id]), + 'data-confirm' => sprintf(_('Wollen Sie wirklich das Aufgabenblatt „%s“ löschen?'), $assignment->test->title) + ]) ?> + <? endif ?> + <?= $menu->render() ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <? if (count($assignments)): ?> + <tfoot> + <tr> + <td colspan="7"> + <? if (count($blocks) > 1): ?> + <?= Studip\Button::create(_('Block zuweisen'), 'assign_block', [ + 'class' => 'batch_action_' . $i, + 'formaction' => $controller->url_for('vips/sheets/assign_block_dialog'), + 'data-dialog' => 'size=auto' + ]) ?> + <? endif ?> + <?= Studip\Button::create(_('Kopieren'), 'copy_assignments', [ + 'class' => 'batch_action_' . $i, + 'formaction' => $controller->url_for('vips/sheets/copy_assignments_dialog'), + 'data-dialog' => 'size=auto' + ]) ?> + <?= Studip\Button::create(_('Verschieben'), 'move_assignments', [ + 'class' => 'batch_action_' . $i, + 'formaction' => $controller->url_for('vips/sheets/move_assignments_dialog'), + 'data-dialog' => 'size=auto' + ]) ?> + <?= Studip\Button::create(_('Löschen'), 'delete_assignments', [ + 'class' => 'batch_action_' . $i, + 'formaction' => $controller->url_for('vips/sheets/delete_assignments'), + 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgabenblätter löschen?') + ]) ?> + </td> + </tr> + </tfoot> + <? endif ?> + </table> +</form> diff --git a/app/views/vips/sheets/list_assignments_stud.php b/app/views/vips/sheets/list_assignments_stud.php new file mode 100644 index 0000000000000000000000000000000000000000..647755701ceeacd7b07b706a956222c132432b2b --- /dev/null +++ b/app/views/vips/sheets/list_assignments_stud.php @@ -0,0 +1,117 @@ +<?php +/** + * @var VipsBlock[] $blocks + * @var Vips_SheetsController $controller + * @var string $sort + * @var bool $desc + * @var string $user_id + */ +?> + +<? if (count($blocks) == 0): ?> + <?= MessageBox::info(_('Es gibt aktuell keine laufenden Aufgabenblätter.')) ?> +<? endif ?> + +<? foreach ($blocks as $block_id => $block): ?> + <table class="default"> + <caption> + <? if (count($blocks) > 1 || $block_id): ?> + <?= htmlReady($block['title']) ?> + <? else: ?> + <?= _('Laufende Aufgabenblätter') ?> + <? endif ?> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>"> + <?= _('Titel') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>"> + <?= _('Start') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>"> + <?= _('Ende') ?> + </a> + </th> + <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>"> + <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc]) ?>"> + <?= _('Modus') ?> + </a> + </th> + <th style="width: 15%;"> + <?= _('Status') ?> + </th> + <th class="actions"> + <?= _('Aktion') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($block['assignments'] as $assignment) : ?> + <tr> + <td> + <a href="<?= $controller->link_for('vips/sheets/show_assignment', ['assignment_id' => $assignment->id]) ?>"> + <?= $assignment->getTypeIcon() ?> + <?= htmlReady($assignment->test->title) ?> + </a> + <? if (!$assignment->active): ?> + <span style="color: red;"> + (<?= _('unterbrochen') ?>) + </span> + <? endif ?> + </td> + <td> + <?= date('d.m.Y, H:i', $assignment->start) ?> + </td> + <td> + <? if (!$assignment->isUnlimited()) : ?> + <?= date('d.m.Y, H:i', $assignment->end) ?> + <? endif ?> + </td> + <td> + <?= htmlReady($assignment->getTypeName()) ?> + </td> + <td> + <? if ($assignment->type === 'exam'): ?> + <? $assignment_attempt = $assignment->getAssignmentAttempt($user_id) ?> + <? if ($assignment_attempt === null): ?> + – + <? elseif ($assignment_attempt->end < time()): ?> + <?= _('beendet') ?> + <? else: ?> + <?= _('angefangen') ?> + <? endif ?> + <? elseif ($assignment->isFinished($user_id)): ?> + <?= _('beendet') ?> + <? else: ?> + <? $num_solutions = $assignment->countSolutions($user_id) ?> + <? if ($num_solutions == 0): ?> + – + <? elseif ($num_solutions == count($assignment->test->exercise_refs)): ?> + <?= _('bearbeitet') ?> + <? else: ?> + <?= _('angefangen') ?> + <? endif ?> + <? endif ?> + </td> + <td class="actions"> + <? if ($assignment->active && $assignment->type !== 'exam'): ?> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment->id]), + _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endforeach ?> diff --git a/app/views/vips/sheets/list_exercises.php b/app/views/vips/sheets/list_exercises.php new file mode 100644 index 0000000000000000000000000000000000000000..45911636996b079ef4c4a7216ff5b418f770515f --- /dev/null +++ b/app/views/vips/sheets/list_exercises.php @@ -0,0 +1,67 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var VipsTest $test + * @var Exercise[] $exercises + * @var int $assignment_id + * @var bool $locked + */ +?> +<? foreach ($test->exercise_refs as $i => $exercise_ref): ?> + <? $exercise = $exercises[$i] ?> + + <tr id="item_<?= $exercise->id ?>" role="listitem" tabindex="0"> + <td class="drag-handle"> + <input type="checkbox" class="batch_select" name="exercise_ids[]" value="<?= $exercise->id ?>" aria-label="<?= _('Zeile auswählen') ?>"> + </td> + <td class="dynamic_counter" style="text-align: right;"> + <!-- position --> + </td> + <td> + <!-- exercise title --> + <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) ?>"> + <?= htmlReady($exercise->title) ?> + </a> + </td> + <td> + <!-- exercise type --> + <?= htmlReady($exercise->getTypeName()) ?> + </td> + <td> + <!-- max points --> + <input name="exercise_points[<?= $exercise->id ?>]" type="text" class="points" value="<?= sprintf('%g', $exercise_ref->points) ?>" data-secure required> + </td> + + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <!-- display button --> + <? $menu->addLink( + $controller->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]), + _('Studierendensicht anzeigen'), + Icon::create('community') + ) ?> + + <? if (!$locked): ?> + <!-- copy button --> + <? $menu->addButton( + 'copy', + _('Aufgabe duplizieren'), + Icon::create('copy'), + ['formaction' => $controller->url_for('vips/sheets/copy_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id])] + ) ?> + + <!-- delete button --> + <? $menu->addButton( + 'delete', + _('Aufgabe löschen'), + Icon::create('trash'), + [ + 'formaction' => $controller->url_for('vips/sheets/delete_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]), + 'data-confirm' => sprintf(_('Wollen Sie wirklich die Aufgabe „%s“ löschen?'), $exercise->title) + ] + ) ?> + <? endif ?> + <?= $menu->render() ?> + </td> + </tr> +<? endforeach ?> diff --git a/app/views/vips/sheets/move_assignments_dialog.php b/app/views/vips/sheets/move_assignments_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..c022fc18e54b8f3c909ba34e1b156229d3be8ccb --- /dev/null +++ b/app/views/vips/sheets/move_assignments_dialog.php @@ -0,0 +1,34 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int[] $assignment_ids + * @var Course[] $courses + * @var string $course_id + */ +?> +<form class="default" action="<?= $controller->link_for('vips/sheets/move_assignments') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($assignment_ids as $assignment_id): ?> + <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>"> + <? endforeach ?> + + <label> + <?= _('Ziel auswählen') ?> + + <select name="course_id" class="vips_nested_select"> + <option value=""> + <?= _('Persönliche Aufgabensammlung') ?> + </option> + + <? foreach ($courses as $course): ?> + <option value="<?= $course->id ?>" <?= $course->id == $course_id ? 'selected' : '' ?>> + <?= htmlReady($course->name) ?> (<?= htmlReady($course->start_semester->name) ?>) + </option> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/move_exercises_dialog.php b/app/views/vips/sheets/move_exercises_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..49015647f4e7085c7ac1bea1e914ccc914b1cdc0 --- /dev/null +++ b/app/views/vips/sheets/move_exercises_dialog.php @@ -0,0 +1,52 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var int[] $exercise_ids + * @var Course[] $courses + */ +?> +<form class="default" action="<?= $controller->link_for('vips/sheets/move_exercises') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <? foreach ($exercise_ids as $exercise_id): ?> + <input type="hidden" name="exercise_ids[]" value="<?= $exercise_id ?>"> + <? endforeach ?> + + <label> + <?= _('Aufgabenblatt auswählen') ?> + + <select name="target_assignment_id" class="vips_nested_select"> + <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + + <? foreach ($courses as $course): ?> + <? $assignments = VipsAssignment::findByRangeId($course->id) ?> + <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?> + <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?> + <? if ($assignments): ?> + <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>"> + <? foreach ($assignments as $assignment): ?> + <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>> + <?= htmlReady($assignment->test->title) ?> + </option> + <? endforeach ?> + </optgroup> + <? endif ?> + <? endforeach ?> + </select> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?> + </footer> +</form> diff --git a/app/views/vips/sheets/print_assignment.php b/app/views/vips/sheets/print_assignment.php new file mode 100644 index 0000000000000000000000000000000000000000..9e7b5e6ba4b9c9591964b388e598863c366b0427 --- /dev/null +++ b/app/views/vips/sheets/print_assignment.php @@ -0,0 +1,106 @@ +<?php +/** + * @var VipsAssignment $assignment + * @var string[] $lecturers + * @var string[]|null $students + * @var bool $print_student_ids + * @var string $user_id + * @var bool $print_sample_solution + * @var bool $print_correction + */ +?> +<div class="assignment"> + <h1> + <?= htmlReady($assignment->test->title) ?> + </h1> + + <div class="description"> + <?= formatReady($assignment->test->description) ?> + </div> + + <p class="description"> + <?= _('Beginn') ?>: <?= date('d.m.Y, H:i', $assignment->start) ?><br> + <? if (!$assignment->isUnlimited()): ?> + <?= _('Ende') ?>: <?= date('d.m.Y, H:i', $assignment->end) ?> + <? endif ?> + </p> + + <? if ($assignment->range_type === 'course'): ?> + <p> + <?= _('Kurs') ?>: <?= htmlReady($assignment->course->name) ?> + <? if ($assignment->course->veranstaltungsnummer): ?> + (<?= htmlReady($assignment->course->veranstaltungsnummer) ?>) + <? endif ?> + <br> + <?= _('Semester') ?>: <?= htmlReady($assignment->course->start_semester->name) ?><br> + <?= _('Lehrende') ?>: <?= htmlReady(join(', ', $lecturers)) ?> + </p> + + <p class="label-text"> + <? if (isset($students)): ?> + <?= _('Name') ?>: <?= htmlReady(join(', ', $students)) ?><br> + <? else :?> + <?= _('Name') ?>: ________________________________________<br> + <? endif ?> + <? if ($assignment->type == 'exam'): ?> + <? if (isset($stud_ids) && $print_student_ids): ?> + <?= _('Matrikelnummer') ?>: <?= htmlReady(join(', ', $stud_ids)) ?> + <? else :?> + <br> + <?= _('Matrikelnummer') ?>: _______________________________ + <? endif ?> + <? endif ?> + </p> + <? endif ?> + + <? foreach ($assignment->getExerciseRefs($user_id) as $i => $exercise_ref): ?> + <? $exercise = $exercise_ref->exercise ?> + <? $solution = null ?> + + <? if ($user_id): ?> + <? $solution = $assignment->getSolution($user_id, $exercise->id); ?> + <? endif ?> + + <? if (!$solution): ?> + <? $solution = new VipsSolution(); ?> + <? $solution->assignment = $assignment; ?> + <? endif ?> + + <?= $this->render_partial('vips/exercises/print_exercise', [ + 'exercise' => $exercise, + 'exercise_position' => $i + 1, + 'max_points' => $exercise_ref->points, + 'solution' => $solution, + 'show_solution' => $print_sample_solution + ]) ?> + <? endforeach ?> + + <? if ($print_correction): ?> + <? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + <? $max_points = $assignment->test->getTotalPoints(); ?> + <? $reached_points = $assignment->getUserPoints($user_id); ?> + <? $feedback = $assignment->getUserFeedback($user_id); ?> + <div class="exercise"> + <h2> + <?= _('Gesamtpunktzahl') ?> + + <div class="points"> + <?= sprintf(_('%g Punkte'), $max_points) ?> + </div> + </h2> + + <div class="label-text"> + <?= sprintf(_('Erreichte Punkte: %g / %g'), $reached_points, $max_points) ?> + </div> + + <? if ($feedback != ''): ?> + <div class="label-text"> + <?= _('Kommentar zur Bewertung') ?> + </div> + + <?= formatReady($feedback) ?> + <? endif ?> + </div> + <? setlocale(LC_NUMERIC, 'C') ?> + <? endif ?> +</div> diff --git a/app/views/vips/sheets/print_assignments.php b/app/views/vips/sheets/print_assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..071e13c32fde49807b6df9c5b7bd8a9a6f5202de --- /dev/null +++ b/app/views/vips/sheets/print_assignments.php @@ -0,0 +1,43 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var VipsAssignment $assignment + * @var string[] $user_ids + * @var bool $print_files + * @var bool $print_correction + * @var bool $print_sample_solution + * @var array $assignment_data + */ +?> +<? if ($assignment->checkEditPermission()): ?> + <form class="print_settings" action="<?= $controller->link_for('vips/sheets/print_assignments') ?>" method="POST"> + <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>"> + + <? foreach ($user_ids as $user_id): ?> + <input type="hidden" name="user_ids[]" value="<?= htmlReady($user_id) ?>"> + <? endforeach ?> + + <?= _('Einstellungen:') ?> + + <? if ($user_ids): ?> + <label> + <input type="checkbox" name="print_files" value="1" <?= $print_files ? 'checked' : '' ?> onchange="this.form.submit();"> + <?= _('Dateiabgaben drucken') ?> + </label> + + <label> + <input type="checkbox" name="print_correction" value="1" <?= $print_correction ? 'checked' : '' ?> onchange="this.form.submit();"> + <?= _('Korrekturen drucken') ?> + </label> + <? endif ?> + + <label> + <input type="checkbox" name="print_sample_solution" value="1" <?= $print_sample_solution ? 'checked' : '' ?> onchange="this.form.submit();"> + <?= _('Musterlösung drucken') ?> + </label> + </form> +<? endif ?> + +<? foreach ($assignment_data as $data): ?> + <?= $this->render_partial('vips/sheets/print_assignment', $data) ?> +<? endforeach ?> diff --git a/app/views/vips/sheets/print_layout.php b/app/views/vips/sheets/print_layout.php new file mode 100644 index 0000000000000000000000000000000000000000..866fa1681989b55f29fac0a0881c7f4fdbe04ddd --- /dev/null +++ b/app/views/vips/sheets/print_layout.php @@ -0,0 +1,9 @@ +<?php +/** + * @var string $content_for_layout + */ +?> + +<? include 'lib/include/html_head.inc.php' ?> +<?= $content_for_layout ?> +<? include 'lib/include/html_end.inc.php' ?> diff --git a/app/views/vips/sheets/show_assignment.php b/app/views/vips/sheets/show_assignment.php new file mode 100644 index 0000000000000000000000000000000000000000..4f75521eeef32baa1deca1c772d53e9538e68b2a --- /dev/null +++ b/app/views/vips/sheets/show_assignment.php @@ -0,0 +1,179 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var VipsAssignment $assignment + * @var int $remaining_time + * @var string $exam_terms + * @var int $user_end_time + * @var string $preview_exam_terms + * @var bool $needs_code + * @var string $access_code + * @var string $solver_id + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<? if ($assignment->type === 'exam' && isset($assignment_attempt) && $remaining_time > 0) : ?> + <div id="exam_timer" data-time="<?= $remaining_time ?>"> + <?= _('Restzeit') ?>: <span class="time"><?= round($remaining_time / 60) ?></span> min + </div> +<? endif ?> + +<?= $contentbar->render() ?> + +<h1 class="width-1200"> + <?= htmlReady($assignment->test->title) ?> +</h1> + +<div class="width-1200" style="margin: 10px 0;"> + <?= formatReady($assignment->test->description) ?> +</div> + +<? if ($assignment->isUnlimited()) : ?> + <?= _('Start:') ?> + <?= date('d.m.Y, H:i', $assignment->start) ?> +<? else: ?> + <?= _('Zeitraum:') ?> + <?= date('d.m.Y, H:i', $assignment->start) ?> – + <?= date('d.m.Y, H:i', $assignment->end) ?> +<? endif ?> + +<? if ($assignment->type === 'exam'): ?> + <p style="font-weight: bold;"> + <? if ($exam_terms): ?> + <?= sprintf(_('Bearbeitungszeit: %d Minuten.'), round($remaining_time / 60)) ?> + <? elseif ($remaining_time > 0): ?> + <?= sprintf(_('Sie haben noch %d Minuten Zeit.'), round($remaining_time / 60)) ?> + <? else: ?> + <?= _('Ihre Bearbeitungszeit ist abgelaufen.') ?> + <? endif ?> + </p> +<? elseif ($user_end_time && $remaining_time <= 0): ?> + <p style="font-weight: bold;"> + <?= _('Die Bearbeitung ist bereits abgeschlossen.') ?> + </p> +<? endif ?> + +<? if ($preview_exam_terms): ?> + <form class="default width-1200" style="margin-bottom: 1.5ex;"> + <input id="options-toggle" class="options-toggle" type="checkbox" value="on"> + <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="false"> + <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?> + <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?> + <?= _('Teilnahmebedingungen') ?> + </a> + + <div class="toggle-box" id="options-panel"> + <div class="exercise_hint" style="display: block;"> + <?= formatReady($preview_exam_terms) ?> + + <label> + <input type="checkbox" value="1" disabled> + <?= _('Ich bestätige die vorstehenden Bedingungen zur Teilnahme an der Klausur') ?> + </label> + </div> + </div> + </form> +<? endif ?> + +<? if ($exam_terms || $needs_code): ?> + <form class="default width-1200" action="<?= $controller->link_for('vips/sheets/begin_assignment') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>"> + + <div class="exercise_hint" style="display: block;"> + <? if ($exam_terms): ?> + <?= formatReady($exam_terms) ?> + + <label> + <input type="checkbox" name="terms_accepted" value="1" required> + <?= _('Ich bestätige die vorstehenden Bedingungen zur Teilnahme an der Klausur') ?> + </label> + <? endif ?> + + <? if ($needs_code): ?> + <label> + <?= _('Es ist ein Zugangscode für den Zugriff auf die Klausur erforderlich:') ?> + <input type="text" name="access_code" value="<?= htmlReady($access_code) ?>" required> + </label> + <? endif ?> + + <?= Studip\Button::createAccept(_('Klausur starten'), 'begin_assignment') ?> + </div> + </form> +<? else: ?> + <? if (count($assignment->test->exercise_refs)): ?> + <table class="default dynamic_list width-1200"> + <thead> + <tr> + <th style="width: 2em;"> + </th> + <th style="width: 50%;"> + <?= _('Aufgaben') ?> + </th> + <th style="width: 15%;"> + <?= _('Abgabedatum') ?> + </th> + <th style="width: 15%;"> + <?= _('Teilnehmende') ?> + </th> + <th style="width: 10%; text-align: center;"> + <?= _('Bearbeitet') ?> + </th> + <th style="width: 10%; text-align: center;"> + <?= _('Max. Punkte') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignment->getExerciseRefs($solver_id) as $exercise_ref): ?> + <? $exercise = $exercise_ref->exercise ?> + <? $solution = $assignment->getSolution($solver_id, $exercise->id) ?> + <tr> + <td class="dynamic_counter" style="text-align: right;"> + </td> + <td> + <a href="<?= $controller->link_for('vips/sheets/show_exercise', ['assignment_id' => $assignment->id, 'exercise_id' => $exercise->id, 'solver_id' => $solver_id]) ?>"> + <?= htmlReady($exercise->title) ?> + </a> + </td> + <td> + <? if ($solution): ?> + <?= date('d.m.Y, H:i', $solution->mkdate) ?> + <? endif ?> + </td> + <td> + <? if ($solution): ?> + <?= htmlReady(get_fullname($solution->user_id, 'no_title')) ?> + <? endif ?> + </td> + <td style="text-align: center;"> + <? if ($solution): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?> + <? else : ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?> + <? endif ?> + </td> + <td style="text-align: center;"> + <?= sprintf('%g', $exercise_ref->points) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="5"></td> + <td style="text-align: center;"> + <?= sprintf('%g', $assignment->test->getTotalPoints()) ?> + </td> + </tr> + </tfoot> + </table> + <? else : ?> + <?= MessageBox::info(_('Keine Aufgaben gefunden.')) ?> + <? endif ?> +<? endif ?> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/sheets/show_exercise.php b/app/views/vips/sheets/show_exercise.php new file mode 100644 index 0000000000000000000000000000000000000000..b680450bd425d81d1e82d528ba3f22ec6e679b38 --- /dev/null +++ b/app/views/vips/sheets/show_exercise.php @@ -0,0 +1,109 @@ +<?php +/** + * @var int $assignment_id + * @var VipsAssignment $assignment + * @var int $exercise_id + * @var Exercise $exercise + * @var VipsSolution $solution + * @var int $remaining_time + * @var int $user_end_time + * @var Vips_SheetsController $controller + * @var string|null $solver_id + * @var bool $show_solution + * @var float $max_points + * @var int|null $exercise_position + * @var int $tries_left + */ +?> + +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<? if ($assignment->type == 'exam' && !$assignment->checkEditPermission()) : ?> + <div id="exam_timer" data-time="<?= $remaining_time ?>"> + <?= _('Restzeit') ?>: <span class="time"><?= round($remaining_time / 60) ?></span> min + </div> + + <div class="width-1200" style="font-weight: bold; text-align: center;"> + <?= _('Abgabezeitpunkt:') ?> + <?= sprintf(_('%s Uhr'), date('H:i', $user_end_time)) ?> + </div> +<? endif ?> + +<?= $contentbar->render() ?> + +<? if ($show_solution) : ?> + <form class="default width-1200"> + <!-- show feedback for selftest --> + <?= $this->render_partial('vips/exercises/correct_exercise') ?> + + <fieldset> + <legend> + <?= sprintf(_('Bewertung der Aufgabe „%s“'), htmlReady($exercise->title)) ?> + </legend> + + <? if ($solution->feedback != '') : ?> + <div class="label-text"> + <?= _('Anmerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <div class="description"> + <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> + </fieldset> + </form> +<? else : ?> + <!-- solve and submit exercise --> + <form class="default width-1200" name="jsfrm" action="<?= $controller->link_for('vips/sheets/submit_exercise') ?>" autocomplete="off" data-secure method="POST" enctype="multipart/form-data"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="solver_id" value="<?= $solver_id ?>"> + <input type="hidden" name="exercise_id" value="<?= $exercise_id ?>"> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="forced" value="0"> + + <fieldset> + <legend> + <?= $exercise_position ?>. + <?= htmlReady($exercise->title) ?> + <div style="float: right;"> + <? if ($max_points == (int) $max_points): ?> + <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?> + <? else: ?> + <?= sprintf(_('%g Punkte'), $max_points) ?> + <? endif ?> + </div> + </legend> + + <? if ($tries_left > 0): ?> + <?= MessageBox::warning(sprintf(ngettext( + 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weiteren Versuch.', + 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weitere Versuche.', $tries_left), $tries_left)) ?> + <? endif ?> + + <div class="description"> + <?= formatReady($exercise->description) ?> + </div> + + <?= $this->render_partial('vips/exercises/show_exercise_hint') ?> + <?= $this->render_partial('vips/exercises/show_exercise_files') ?> + + <?= $this->render_partial($exercise->getSolveTemplate($solution, $assignment, $solver_id)) ?> + + <? if (!empty($exercise->options['comment'])) : ?> + <label> + <?= _('Bemerkungen zur Lösung (optional)') ?> + <textarea name="student_comment"><?= $solution ? htmlReady($solution->student_comment) : '' ?></textarea> + </label> + <? endif ?> + </fieldset> + + <footer> + <?= Studip\Button::createAccept(_('Speichern'), 'submit_exercise', $exercise->itemCount() ? [] : ['disabled' => 'disabled']) ?> + </footer> + </form> +<? endif ?> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/sheets/show_exercise_link.php b/app/views/vips/sheets/show_exercise_link.php new file mode 100644 index 0000000000000000000000000000000000000000..6f41a6a5a2066dbe618bbd9ca176953de075f4a9 --- /dev/null +++ b/app/views/vips/sheets/show_exercise_link.php @@ -0,0 +1,23 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var VipsAssignment $assignment + * @var int $assignment_id + * @var int $position + * @var string $solver_id + * @var Exercise $item + */ +?> +<a href="<?= $controller->link_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $item->task_id, 'solver_id' => $solver_id]) ?>"> + <div class="sidebar_exercise_label"> + <?= sprintf(_('Aufgabe %d'), $position) ?> + </div> + <div class="sidebar_exercise_points"> + <?= sprintf(_('%g Punkte'), $item->points) ?> + </div> + <div class="sidebar_exercise_state"> + <? if ($assignment->getSolution($solver_id, $item->task_id)): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('Aufgabe bearbeitet')]) ?> + <? endif ?> + </div> +</a> diff --git a/app/views/vips/sheets/start_assignment_dialog.php b/app/views/vips/sheets/start_assignment_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..4dd47d99830c970bcf91d25eb7f612a663c0278b --- /dev/null +++ b/app/views/vips/sheets/start_assignment_dialog.php @@ -0,0 +1,40 @@ +<?php +/** + * @var Vips_SheetsController $controller + * @var int $assignment_id + * @var VipsAssignment $assignment + */ +?> +<form class="default" action="<?= $controller->link_for('vips/sheets/start_assignment') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>"> + + <div class="description"> + <?= _('Bitte bestätigen Sie den Endzeitpunkt:') ?> + </div> + + <label class="undecorated"> + <div class="label-text"> + <span class="required"><?= _('Startzeitpunkt') ?></span> + </div> + + <input type="text" name="start_date" class="size-s" value="<?= date('d.m.Y') ?>" disabled> + <input type="text" name="start_time" class="size-s" value="<?= date('H:i') ?>" disabled> + </label> + + <? $required = $assignment->type !== 'selftest' ? 'required' : '' ?> + + <label class="undecorated"> + <div class="label-text"> + <span class="<?= $required ?>"><?= _('Endzeitpunkt') ?></span> + </div> + + <input type="text" name="end_date" class="size-s" value="<?= $assignment->isUnlimited() ? '' : date('d.m.Y', $assignment->end) ?>" <?= $required ?>> + <input type="text" name="end_time" class="size-s" value="<?= $assignment->isUnlimited() ? '' : date('H:i', $assignment->end) ?>" <?= $required ?>> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'submit') ?> + <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/assignment_solutions.php b/app/views/vips/solutions/assignment_solutions.php new file mode 100644 index 0000000000000000000000000000000000000000..86b4e8a2a3daf67414c60019321ea41b7546bb77 --- /dev/null +++ b/app/views/vips/solutions/assignment_solutions.php @@ -0,0 +1,308 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var VipsAssignment $assignment + * @var int $overall_uncorrected_solutions + * @var int $assignment_id + * @var array $first_uncorrected_solution + * @var string $expand + * @var string $view + * @var array $solvers + * @var int $overall_max_points + * @var array $exercises + * + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<form action="" method="POST" id="post_form"> + <?= CSRFProtection::tokenTag() ?> +</form> + +<form action="<?= $controller->link_for('vips/solutions/assignment_solutions') ?>"> + <input type="hidden" name="cid" value="<?= htmlReady($assignment->range_id) ?>"> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + + <table class="default dynamic_list"> + <caption> + <?= sprintf(_('Aufgabenblatt „%s“'), htmlReady($assignment->test->title)) ?> + <?= tooltipIcon($this->render_partial('vips/solutions/solution_color_tooltip'), false, true) ?> + + <span class="actions"> + <label> + <?= _('Anzeigefilter:') ?> + + <select name="view" class="submit-upon-select"> + <? if ($assignment->type !== 'exam') : ?> + <option value=""> + <?= _('Studierende mit abgegebenen Lösungen') ?> + </option> + <option value="todo" <?= $view == 'todo' ? 'selected' : '' ?>> + <?= _('Studierende mit unkorrigierten Lösungen') ?> + </option> + <option value="all" <?= $view == 'all' ? 'selected' : '' ?>> + <?= _('Alle Studierende') ?> + </option> + <? else : ?> + <option value=""> + <?= _('Beendete Klausuren') ?> + </option> + <option value="working" <?= $view == 'working' ? 'selected' : '' ?>> + <?= _('Laufende Klausuren') ?> + </option> + <option value="pending" <?= $view == 'pending' ? 'selected' : '' ?>> + <?= _('Noch nicht begonnene Klausuren') ?> + </option> + <? endif ?> + </select> + </label> + </span> + </caption> + + <thead> + <tr> + <th style="width: 20px;"> + <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Teilnehmenden auswählen') ?>"> + </th> + <th style="width: 1em;"></th> + <th> + <a href="#" class="solution-toggle"> + <?= Icon::create('arr_1right')->asImg(['class' => 'arrow_all', 'title' => _('Aufgaben aller Teilnehmenden anzeigen')]) ?> + <?= Icon::create('arr_1down')->asImg(['class' => 'arrow_all', 'title' => _('Aufgaben aller Teilnehmenden verstecken'), 'style' => 'display: none;']) ?> + <?= _('Teilnehmende') ?> + </a> + </th> + <th style="text-align: center;"> + <?= _('Punkte') ?> + </th> + <th style="text-align: center;"> + <?= _('Prozent') ?> + </th> + <th style="text-align: center;"> + <?= _('Fortschritt') ?> + </th> + <th style="text-align: center;"> + <?= _('Unkorrigierte Lösungen') ?> + </th> + <th style="text-align: center;"> + <?= _('Unbearbeitete Aufgaben') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($solvers as $solver) : ?> + <? /* extra info */ ?> + <? $reached_points = $solver['extra_info']['points']; ?> + <? $progress = $solver['extra_info']['progress']; ?> + <? $uncorrected_solutions = $solver['extra_info']['uncorrected']; ?> + <? $unanswered_exercises = $solver['extra_info']['unanswered']; ?> + <? $uploaded_files = $solver['extra_info']['files']; ?> + <tr id="row_<?= $solver['id'] ?>" class="solution <?= $expand == $solver['id'] ? '' : 'solution-closed' ?>"> + <td> + <input class="batch_select" type="checkbox" name="user_ids[]" value="<?= $solver['user_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>"> + </td> + <td class="dynamic_counter" style="text-align: right;"> + </td> + + <td> + <a href="#" class="solution-toggle"> + <?= Icon::create('arr_1right')->asImg(['class' => 'solution-open', 'title' => _('Aufgaben anzeigen')]) ?> + <?= Icon::create('arr_1down')->asImg(['class' => 'solution-close', 'title' => _('Aufgaben verstecken')]) ?> + <?= htmlReady($solver['name']) ?> + </a> + + <? if ($solver['type'] == 'single') : ?> + <? /* running info */ ?> + <? if ($assignment->type == 'exam' && $view === 'working') : ?> + <? $ip = $solver['running_info']['ip'] ?> + <? $start = $solver['running_info']['start'] ?> + <? $remaining = $solver['running_info']['remaining'] ?> + <div class="smaller"> + <?= _('IP-Adresse') ?>: <?= htmlReady($ip) ?> (<?= htmlReady(gethostbyaddr($ip)) ?>)<br> + <?= _('Start') ?>: <span title="<?= strftime('%A, %d.%m.%Y', $start) ?>"><?= sprintf(_('%s Uhr'), date('H:i', $start)) ?></span> + <? if ($remaining > 0): ?> + (<?= sprintf(ngettext('noch %d Minute', 'noch %d Minuten', $remaining), $remaining) ?>) + <? endif ?> + </div> + <? endif ?> + <? elseif ($solver['type'] == 'group') : ?> + <? /* list members in group */ ?> + <? foreach ($solver['members'] as $member) : ?> + <div class="smaller" style="padding-left: 20px;"> + <?= htmlReady($member['name']) ?> + </div> + <? endforeach ?> + <? endif ?> + </td> + + <? /* reached points */ ?> + <td style="text-align: center;"> + <?= sprintf('%g / %g', $reached_points, $overall_max_points) ?> + </td> + + <? /* percent */ ?> + <td style="text-align: center;"> + <? if ($overall_max_points != 0) : ?> + <?= sprintf('%.1f %%', round($reached_points / $overall_max_points * 100, 1)) ?> + <? else : ?> + – + <? endif ?> + </td> + + <? /* progress */ ?> + <td style="text-align: center;"> + <? if ($overall_max_points != 0) : ?> + <? $value = round($progress / $overall_max_points * 100) ?> + <progress class="assignment" value="<?= $value ?>" max="100" title="<?= $value ?> %"><?= $value ?> %</progress> + <? else : ?> + – + <? endif ?> + </td> + + <? /* uncorrected solutions */ ?> + <td style="text-align: center;"> + <? if ($uncorrected_solutions > 0) : ?> + <?= $uncorrected_solutions ?> + <? else : ?> + – + <? endif ?> + </td> + + <? /* unanswered exercises */ ?> + <td style="text-align: center;"> + <? if ($unanswered_exercises > 0) : ?> + <?= $unanswered_exercises ?> + <? else : ?> + – + <? endif ?> + </td> + + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? if ($assignment->type === 'exam' && $view !== 'pending') : ?> + <? if ($assignment->isRunning()) : ?> + <? $menu->addLink($controller->url_for('vips/solutions/edit_assignment_attempt', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + _('Abgabezeitpunkt bearbeiten'), Icon::create('edit'), ['data-dialog' => 'size=auto'] + ) ?> + <? $menu->addButton('reset', _('Teilnahme und Lösungen zurücksetzen'), Icon::create('refresh'), [ + 'form' => 'post_form', + 'formaction' => $controller->url_for('vips/solutions/delete_solutions', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + 'data-confirm' => _('Achtung: Wenn Sie die Teilnahme zurücksetzen, werden alle Lösungen der teilnehmenden Person archiviert!') + ]) ?> + <? endif ?> + <? $menu->addLink($controller->url_for('vips/solutions/show_assignment_log', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]), + _('Abgabeprotokoll anzeigen'), Icon::create('log'), ['data-dialog' => 'size=auto'] + ) ?> + <? endif ?> + <? if ($uploaded_files > 0): ?> + <? $menu->addLink($controller->url_for('vips/solutions/download_uploads', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]), + _('Abgegebene Dateien herunterladen'), Icon::create('download') + ) ?> + <? endif ?> + <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'user_ids[]' => $solver['user_id'], 'print_files' => 1, 'print_correction' => !$view]), + _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank'] + ) ?> + <? if ($solver['type'] == 'single') : ?> + <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $solver['username']]), + sprintf(_('Nachricht an „%s“ schreiben'), $solver['name']), Icon::create('mail'), ['data-dialog' => ''] + ) ?> + <? elseif ($solver['type'] == 'group') : ?> + <? $receivers = array_column($solver['members'], 'username') ?> + <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $receivers]), + _('Nachricht an die Gruppe schreiben'), Icon::create('mail'), ['data-dialog' => ''] + ) ?> + <? if ($assignment->isFinished()) : ?> + <? $menu->addLink($controller->url_for('vips/solutions/edit_group_dialog', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + _('Personen aus der Gruppe entfernen'), Icon::create('community'), ['data-dialog' => 'size=auto'] + ) ?> + <? endif ?> + <? endif ?> + <?= $menu->render() ?> + </td> + </tr> + + <tr class="nohover"> + <td colspan="2"></td> + <td colspan="7"> + <table class="smaller" style="width: 100%;"> + <tr> + <? $col_count = 0; ?> + <? foreach ($exercises as $exercise) : ?> + <td class="solution-col-5" style="padding: 2px;"> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise['id'], 'solver_id' => $solver['user_id'], 'view' => $view]) ?>"> + <? if (!isset($solutions[$solver['id']][$exercise['id']])) : ?> + <? $class = 'solution-none'; ?> + <? elseif (!$solutions[$solver['id']][$exercise['id']]['corrected']) : ?> + <? $class = 'solution-uncorrected'; ?> + <? elseif (!isset($solutions[$solver['id']][$exercise['id']]['grader_id'])) : ?> + <? $class = 'solution-autocorrected'; ?> + <? else : ?> + <? $class = 'solution-corrected'; ?> + <? endif ?> + <span class="<?= $class ?>"> + <?= $exercise['position'] ?>. + <?= htmlReady($exercise['title']) ?> + </span> + </a> + <br> + + <? /* reached / max points */ ?> + <? $max_points = $exercises[$exercise['id']]['points'] ?> + <? if (isset($solutions[$solver['id']][$exercise['id']])) : ?> + <? $points = $solutions[$solver['id']][$exercise['id']]['points'] ?> + <? $title = sprintf('Punkte: %g von %g', $points, $max_points) ?> + <? if ($points > $max_points || $points < 0) : ?> + <span style="color: red;" title="<?= htmlReady($title) ?>"> + (<?= sprintf('%g/%g', $points, $max_points) ?>) + </span> + <? else : ?> + <span title="<?= htmlReady($title) ?>"> + (<?= sprintf('%g/%g', $points, $max_points) ?>) + </span> + <? endif ?> + <? else : ?> + <span class="solution-none"> + (<?= sprintf('%g/%g', 0, $max_points) ?>) + </span> + <? endif ?> + </td> + <? if (++$col_count % 5 == 0): ?> + </tr> + <tr> + <? endif ?> + <? endforeach ?> + </tr> + </table> + </td> + </tr> + <? endforeach ?> + </tbody> + + <? if (count($solvers)): ?> + <tfoot> + <tr> + <td colspan="9"> + <?= Studip\Button::create(_('Drucken'), 'print', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/print_assignments', ['print_files' => 1, 'print_correction' => !$view]), + 'formmethod' => 'post', + 'formtarget' => '_blank' + ]) ?> + <?= Studip\Button::create(_('Nachricht schreiben'), 'message', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/solutions/write_message'), + 'formmethod' => 'post', + 'data-dialog' => '' + ]) ?> + </td> + </tr> + </tfoot> + <? endif ?> + </table> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/assignments.php b/app/views/vips/solutions/assignments.php new file mode 100644 index 0000000000000000000000000000000000000000..d1629cfbaf8a570883f376187b775f015ee55f95 --- /dev/null +++ b/app/views/vips/solutions/assignments.php @@ -0,0 +1,18 @@ +<?php +/** + * @var array $test_data + * @var string $course_id + */ +?> +<? if (count($test_data['assignments'])): ?> + <? if (VipsModule::hasStatus('tutor', $course_id)): ?> + <?= $this->render_partial('vips/solutions/assignments_list', $test_data) ?> + <? else: ?> + <?= $this->render_partial('vips/solutions/assignments_list_student', $test_data) ?> + <? if (isset($overview_data)): ?> + <?= $this->render_partial('vips/solutions/student_grade', $overview_data) ?> + <? endif ?> + <? endif ?> +<? else: ?> + <?= MessageBox::info(_('Es ist kein beendetes Aufgabenblatt vorhanden.')) ?> +<? endif ?> diff --git a/app/views/vips/solutions/assignments_list.php b/app/views/vips/solutions/assignments_list.php new file mode 100644 index 0000000000000000000000000000000000000000..b9865d10f390163656e59b259f39f45fcd8d9dea --- /dev/null +++ b/app/views/vips/solutions/assignments_list.php @@ -0,0 +1,190 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var string $sort + * @var bool $desc + * @var VipsBlock[] $blocks + * @var bool $use_weighting + * @var float $sum_max_points + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<form class="default" action="<?= $controller->link_for('vips/admin/store_weight') ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <button hidden name="store_weight"></button> + + <table class="default collapsable"> + <caption> + <?= _('Aufgabenblätter') ?> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 20px;"> + <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>"> + </th> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->assignments(['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>"> + <?= _('Titel') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>"> + <a href="<?= $controller->assignments(['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>"> + <?= _('Start') ?> + </a> + </th> + <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>"> + <a href="<?= $controller->assignments(['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>"> + <?= _('Ende') ?> + </a> + </th> + <th style="width: 5%; text-align: center;"> + <?= _('Korrigiert') ?> + </th> + <th style="width: 5%; text-align: center;"> + <?= _('Freigabe') ?> + </th> + <th style="width: 5%; text-align: right;"> + <?= _('Punkte') ?> + </th> + <th style="width: 10%; text-align: right;"> + <?= _('Gewichtung') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <? foreach ($blocks as $block) :?> + <? if (isset($block_assignments[$block->id]) || $block->weight !== null): ?> + <tbody> + <? if (count($blocks) > 1): ?> + <tr class="header-row"> + <th class="toggle-indicator" colspan="7"> + <a class="toggler" href="#"> + <?= htmlReady($block->name) ?> + <? if (!$block->visible): ?> + <?= _('(für Teilnehmende unsichtbar)') ?> + <? elseif ($block->group_id): ?> + <?= sprintf(_('(sichtbar für Gruppe „%s“)'), htmlReady($block->group->name)) ?> + <? elseif ($block->id): ?> + <?= _('(für alle sichtbar)') ?> + <? endif ?> + </a> + </th> + <th class="dont-hide" style="text-align: right;"> + <? if ($block->weight !== null): ?> + <input type="text" class="percent_input" name="block_weight[<?= $block->id ?>]" + value="<?= $use_weighting ? sprintf('%g', $block->weight) : '' ?>"> % + <? endif ?> + </th> + <th class="actions"> + </th> + </tr> + <? endif ?> + + <? if (isset($block_assignments[$block->id])): ?> + <? foreach ($block_assignments[$block->id] as $ass): ?> + <tr> + <td> + <input type="checkbox" class="batch_select" name="assignment_ids[]" value="<?= $ass['assignment']->id ?>" + aria-label="<?= _('Zeile auswählen') ?>"> + </td> + <td> + <a href="<?= $controller->assignment_solutions(['assignment_id' => $ass['assignment']->id]) ?>"> + <?= $ass['assignment']->getTypeIcon() ?> + <?= htmlReady($ass['assignment']->test->title) ?> + </a> + </td> + <td> + <?= date('d.m.Y, H:i', $ass['assignment']->start) ?> + </td> + <td> + <? if (!$ass['assignment']->isUnlimited()): ?> + <?= date('d.m.Y, H:i', $ass['assignment']->end) ?> + <? endif ?> + </td> + + <td style="text-align: center;"> + <? if (!isset($ass['uncorrected_solutions'])): ?> + – + <? elseif ($ass['uncorrected_solutions'] == 0): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?> + <? else : ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?> + <? endif ?> + </td> + + <td style="text-align: center;"> + <? if ($ass['released'] == VipsAssignment::RELEASE_STATUS_POINTS): ?> + <?= _('Punkte') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_COMMENTS): ?> + <?= _('Kommentare') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?> + <?= _('Korrektur') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS): ?> + <?= _('Lösungen') ?> + <? else : ?> + – + <? endif ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%g', $ass['max_points']) ?> + </td> + <td style="text-align: right;"> + <? if ($ass['assignment']->type !== 'selftest' && $block->weight === null): ?> + <input type="text" class="percent_input" name="assignment_weight[<?= $ass['assignment']->id ?>]" + value="<?= $use_weighting ? sprintf('%g', $ass['assignment']->weight) : '' ?>"> % + <? endif ?> + </td> + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink( + $controller->url_for('vips/solutions/update_released_dialog', ['assignment_ids[]' => $ass['assignment']->id]), + _('Freigabe ändern'), + Icon::create('lock-locked'), + ['data-dialog' => 'size=auto'] + ) ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/edit_assignment', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt bearbeiten'), + Icon::create('edit') + ) ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt drucken'), + Icon::create('print'), + ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + </td> + </tr> + <? endforeach ?> + <? endif ?> + </tbody> + <? endif ?> + <? endforeach ?> + + <tfoot> + <tr> + <td colspan="6"> + <?= Studip\Button::create(_('Freigabe ändern'), 'change_released', [ + 'class' => 'batch_action', + 'formaction' => $controller->update_released_dialogURL(), + 'data-dialog' => 'size=auto' + ]) ?> + </td> + <td style="padding-right: 5px; text-align: right;"> + <?= sprintf('%g', $sum_max_points) ?> + </td> + <td colspan="2" style="text-align: center;"> + <?= Studip\Button::create(_('Speichern'), 'store_weight') ?> + </td> + </tr> + </tfoot> + </table> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/assignments_list_student.php b/app/views/vips/solutions/assignments_list_student.php new file mode 100644 index 0000000000000000000000000000000000000000..a19a361165960b4eaf5d6091868ed134bf96afb5 --- /dev/null +++ b/app/views/vips/solutions/assignments_list_student.php @@ -0,0 +1,135 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var string $sort + * @var bool $desc + * @var VipsBlock[] $blocks + * @var float $sum_reached_points + * @var float $sum_max_points + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<table class="default collapsable"> + <caption> + <?= _('Freigegebene Ergebnisse') ?> + </caption> + + <thead> + <tr class="sortable"> + <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>"> + <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>"> + <?= _('Titel') ?> + </a> + </th> + <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>"> + <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>"> + <?= _('Start') ?> + </a> + </th> + <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>"> + <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>"> + <?= _('Ende') ?> + </a> + </th> + <th colspan="3" style="width: 5%; text-align: right;"> + <?= _('Punkte') ?> + </th> + <th style="width: 10%; text-align: right;"> + <?= _('Prozent') ?> + </th> + <th class="actions"> + <?= _('Aktion') ?> + </th> + </tr> + </thead> + + <? foreach ($blocks as $block) :?> + <? if (isset($block_assignments[$block->id])): ?> + <tbody> + <? if (count($block_assignments) > 1): ?> + <tr class="header-row"> + <th class="toggle-indicator" colspan="8"> + <a class="toggler" href="#"> + <?= htmlReady($block->name) ?> + </a> + </th> + </tr> + <? endif ?> + + <? foreach ($block_assignments[$block->id] as $ass): ?> + <tr> + <td> + <a href="<?= $controller->student_assignment_solutions(['assignment_id' => $ass['assignment']->id]) ?>"> + <?= $ass['assignment']->getTypeIcon() ?> + <?= htmlReady($ass['assignment']->test->title) ?> + </a> + </td> + <td> + <?= date('d.m.Y, H:i', $ass['assignment']->start) ?> + </td> + <td> + <? if (!$ass['assignment']->isUnlimited()) : ?> + <?= date('d.m.Y, H:i', $ass['assignment']->end) ?> + <? endif ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%g', $ass['reached_points']) ?> + </td> + <td style="text-align: center;"> + / + </td> + <td style="text-align: right;"> + <?= sprintf('%g', $ass['max_points']) ?> + </td> + <td style="text-align: right;"> + <? if ($ass['max_points'] != 0) : ?> + <?= sprintf('%.1f %%', round(100 * $ass['reached_points'] / $ass['max_points'], 1)) ?> + <? else : ?> + – + <? endif ?> + </td> + <td class="actions"> + <? if ($ass['released'] >= VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt drucken'), + Icon::create('print'), + ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + <? endforeach ?> + + <tfoot> + <tr> + <td colspan="3"></td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%g', $sum_reached_points) ?> + </td> + <td style="padding: 5px; text-align: center;"> + / + </td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%g', $sum_max_points) ?> + </td> + <td style="padding: 5px; text-align: right;"> + <? if ($sum_max_points != 0) : ?> + <?= sprintf('%.1f %%', round(100 * $sum_reached_points / $sum_max_points, 1)) ?> + <? else : ?> + – + <? endif ?> + </td> + <td> + </td> + </tr> + </tfoot> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/autocorrect_dialog.php b/app/views/vips/solutions/autocorrect_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..29392e94799d1441d4e6b1a61d74d63636c84566 --- /dev/null +++ b/app/views/vips/solutions/autocorrect_dialog.php @@ -0,0 +1,29 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var int $assignment_id + * @var string $view + * @var string $expand + */ +?> +<form class="default" action="<?= $controller->autocorrect_solutions() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + <input type="hidden" name="expand" value="<?= htmlReady($expand) ?>"> + + <h4> + <?= _('Manuell durchgeführte Korrekturen werden durch diese Aktion nicht überschrieben.') ?> + </h4> + + <label> + <input type="checkbox" name="corrected" value="1"> + <?= _('Unbekannte Eingaben als sicher falsch bewerten') ?> + <?= tooltipIcon(_('Wird diese Option nicht ausgewält, bleiben die betroffenen Aufgaben als unkorrigiert markiert.')) ?> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Autokorrektur starten'), 'autocorrect_solutions') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/edit_assignment_attempt.php b/app/views/vips/solutions/edit_assignment_attempt.php new file mode 100644 index 0000000000000000000000000000000000000000..55f13ec46be5b052c09e2973b419bc79adb4345b --- /dev/null +++ b/app/views/vips/solutions/edit_assignment_attempt.php @@ -0,0 +1,34 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var VipsAssignment $assignment + * @var string $solver_id + * @var string $view + * @var VipsAssignmentAttempt $assignment_attempt + */ +?> +<form class="default" action="<?= $controller->store_assignment_attempt() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>"> + <input type="hidden" name="solver_id" value="<?= htmlReady($solver_id) ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + + <label> + <?= _('Teilnehmer/-in') ?> + <input type="text" disabled value="<?= htmlReady(get_fullname($solver_id, 'no_title_rev')) ?>"> + </label> + + <label> + <?= _('Startzeitpunkt') ?> + <input type="text" disabled value="<?= date('H:i:s', $assignment_attempt->start) ?>"> + </label> + + <label> + <span class="required"><?= _('Abgabezeitpunkt') ?></span> + <input type="text" name="end_time" value="<?= date('H:i:s', $assignment->getUserEndTime($solver_id)) ?>" required> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'submit') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/edit_group_dialog.php b/app/views/vips/solutions/edit_group_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..378aa73050f5f8a7ed3eefe85f841cf8d13c747a --- /dev/null +++ b/app/views/vips/solutions/edit_group_dialog.php @@ -0,0 +1,30 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var VipsAssignment $assignment + * @var VipsGroup $group + * @var string $view + * @var VipsGroupMember[] $members + */ +?> +<form class="default" action="<?= $controller->edit_group() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>"> + <input type="hidden" name="group_id" value="<?= htmlReady($group->id) ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + + <div class="description"> + <?= _('Wählen Sie aus, wen Sie aus der Gruppe entfernen möchten:') ?> + </div> + + <? foreach ($members as $member): ?> + <label> + <input type="checkbox" name="user_ids[]" value="<?= $member->user_id ?>"> + <?= htmlReady($member->user->getFullName('no_title_rev')) ?> + </label> + <? endforeach ?> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Entfernen'), 'edit') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/edit_solution.php b/app/views/vips/solutions/edit_solution.php new file mode 100644 index 0000000000000000000000000000000000000000..955fd4053744c1475d6da7712d055c0650c6d829 --- /dev/null +++ b/app/views/vips/solutions/edit_solution.php @@ -0,0 +1,216 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var int $assignment_id + * @var VipsAssignment $assignment + * @var string $view + * @var int $exercise_id + * @var string $solver_or_group_id + * @var string $solver_name + * @var string $solver_id + * @var Exercise $exercise + * @var VipsSolution $solution + * @var float $max_points + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<? /* breadcrumb navigation */ ?> +<div class="breadcrumb width-1200"> + <? /* overview */ ?> + <a href="<?= $controller->assignment_solutions(['assignment_id' => $assignment_id, 'view' => $view]) ?>"> + <?= htmlReady($assignment->test->title) ?> + </a> + + / + + <? /* previous solver */ ?> + <? if (isset($prev_solver)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $prev_solver['user_id'], 'view' => $view]) ?>"> + <?= Icon::create('arr_1left')->asImg(['title' => _('Voriger Teilnehmer / vorige Teilnehmerin')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der vorhergehenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?> + <? endif ?> + + <? /* overview */ ?> + <a href="<?= $controller->assignment_solutions(['assignment_id' => $assignment_id, 'expand' => $solver_or_group_id, 'view' => $view]) ?>#row_<?= $solver_or_group_id ?>"> + <?= htmlReady($solver_name) ?> + </a> + + <? /* next solver */ ?> + <? if (isset($next_solver)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $next_solver['user_id'], 'view' => $view]) ?>"> + <?= Icon::create('arr_1right')->asImg(['title' => _('Nächster Teilnehmer / nächste Teilnehmerin')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der nachfolgenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?> + <? endif ?> + + / + + <? /* previous exercise */ ?> + <? if (isset($prev_exercise)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $prev_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>"> + <?= Icon::create('arr_1left')->asImg(['title' => _('Vorige Aufgabe')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der vorhergehenden Aufgaben bearbeitet')]) ?> + <? endif ?> + + <? /* exercise name */ ?> + <?= htmlReady($exercise->title) ?> + + <? /* next exercise */ ?> + <? if (isset($next_exercise)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $next_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>"> + <?= Icon::create('arr_1right')->asImg(['title' => _('Nächste Aufgabe')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der nachfolgenden Aufgaben bearbeitet')]) ?> + <? endif ?> +</div> + +<form class="default width-1200" action="<?= $controller->store_correction() ?>" data-secure method="POST" enctype="multipart/form-data"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="solution_id" value="<?= $solution->id ?>"> + <input type="hidden" name="exercise_id" value="<?= $exercise_id ?>"> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="solver_id" value="<?= htmlReady($solver_id) ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + <input type="hidden" name="max_points" value="<?= $max_points ?>"> + + <?= Studip\Button::createAccept(_('Speichern'), 'store_solution', ['style' => 'display: none;']) ?> + + <?= $this->render_partial('vips/exercises/correct_exercise') ?> + + <fieldset> + <legend> + <?= sprintf(_('Bewertung der Lösung von „%s“'), htmlReady($solver_name)) ?> + <div style="float: right;"> + <? if (isset($solution->grader_id)): ?> + <?= _('Manuell korrigiert') ?> + <? elseif ($solution->state): ?> + <?= _('Automatisch korrigiert') ?> + <? elseif ($solution->id): ?> + <?= _('Unkorrigiert') ?> + <? else: ?> + <?= _('Nicht abgegeben') ?> + <? endif ?> + </div> + </legend> + + <? if ($solution->isArchived()): ?> + <? if ($solution->feedback) : ?> + <div class="label-text"> + <?= _('Anmerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <?= $this->render_partial('vips/solutions/feedback_files_table') ?> + + <div class="description"> + <?= sprintf(_('Vergebene Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> + <? else: ?> + <label> + <?= _('Anmerkungen zur Lösung') ?> + <textarea name="feedback" class="character_input size-l wysiwyg"><?= wysiwygReady($solution->feedback) ?></textarea> + </label> + + <table class="default"> + <? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs)): ?> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Dateien zur Korrektur') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 20%;"> + <?= _('Autor/-in') ?> + </th> + <th style="width: 15%;"> + <?= _('Datum') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list"> + <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?> + <tr class="dynamic_row"> + <td> + <input type="hidden" name="file_ids[]" value="<?= htmlReady($file_ref->id) ?>"> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <?= relsize($file_ref->file->size) ?> + </td> + <td> + <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + + <tfoot> + <tr> + <td colspan="5"> + <?= Studip\Button::create(_('Dateien zur Korrektur hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?> + <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span> + <?= tooltipIcon(sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576)) ?> + <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple> + </td> + </tr> + </tfoot> + </table> + + <? if ($solution->feedback != '' && !Studip\Markup::editorEnabled()): ?> + <div class="label-text"> + <?= _('Textvorschau') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <label> + <span class="required"><?= sprintf(_('Vergebene Punkte (von %g)'), $max_points) ?></span> + <input name="reached_points" type="text" class="size-s" pattern="-?[0-9,.]+" data-message="<?= _('Bitte geben Sie eine Zahl ein') ?>" + value="<?= isset($solution->points) ? sprintf('%g', $solution->points) : '' ?>" required> + </label> + <? endif ?> + </fieldset> + + <footer> + <? if ($solution->isArchived()): ?> + <?= Studip\Button::create(_('Als aktuelle Lösung speichern'), 'restore_solution', ['formaction' => $controller->url_for('vips/solutions/restore_solution')]) ?> + <? else: ?> + <?= Studip\Button::createAccept(_('Speichern'), 'store_solution') ?> + <? endif ?> + + <label style="float: right; margin-top: 0.5ex;"> + <input type="checkbox" name="corrected" value="1" <?= !$solution->grader_id || $solution->state ? 'checked' : ''?>> + <?= _('Lösung als korrigiert markieren') ?> + </label> + </footer> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/feedback_files.php b/app/views/vips/solutions/feedback_files.php new file mode 100644 index 0000000000000000000000000000000000000000..4630bb587d2a9d8b68b2154506ce0099dcf296be --- /dev/null +++ b/app/views/vips/solutions/feedback_files.php @@ -0,0 +1,20 @@ +<?php +/** + * @var VipsSolution $solution + */ +?> +<? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs) > 0): ?> + <div class="label-text"> + <?= _('Dateien zur Korrektur:') ?> + </div> + + <ul> + <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?> + <li> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= htmlReady($file_ref->name) ?> + </a> + </li> + <? endforeach ?> + </ul> +<? endif ?> diff --git a/app/views/vips/solutions/feedback_files_table.php b/app/views/vips/solutions/feedback_files_table.php new file mode 100644 index 0000000000000000000000000000000000000000..dff986963e1cd447ba06bfeffc5908a080140644 --- /dev/null +++ b/app/views/vips/solutions/feedback_files_table.php @@ -0,0 +1,51 @@ +<?php +/** + * @var VipsSolution $solution + */ +?> +<? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs)): ?> + <div class="label-text"> + <?= _('Dateien zur Korrektur') ?> + </div> + + <table class="default"> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Name') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 20%;"> + <?= _('Autor/-in') ?> + </th> + <th style="width: 20%;"> + <?= _('Datum') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?> + <tr> + <td> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <?= relsize($file_ref->file->size) ?> + </td> + <td> + <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endif ?> diff --git a/app/views/vips/solutions/gradebook_dialog.php b/app/views/vips/solutions/gradebook_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..9ebc68422512ea59f03f3e4c2682479e5f823746 --- /dev/null +++ b/app/views/vips/solutions/gradebook_dialog.php @@ -0,0 +1,39 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var int $assignment_id + * @var string $view + * @var string $expand + * @var VipsAssignment $assignment + * @var int $weights + */ +?> +<form class="default gradebook-lecturer-weights" action="<?= $controller->gradebook_publish() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + <input type="hidden" name="expand" value="<?= htmlReady($expand) ?>"> + + <label> + <span class="required"><?= _('Name im Gradebook') ?></span> + <input name="title" type="text" required value="<?= htmlReady($assignment->test->title) ?>"> + </label> + + <div hidden> + <input type="number" disabled value="<?= $weights ?>"> + <output></output> + </div> + + <label class="gradebook-weight"> + <span class="required"><?= _('Gewichtung') ?></span> + <div> + <input name="weight" type="number" required min="0" value="1"> + <output><?= round(100 / ($weights + 1), 1) ?></output> + </div> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Eintragen'), 'publish') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/participants_overview.php b/app/views/vips/solutions/participants_overview.php new file mode 100644 index 0000000000000000000000000000000000000000..72bd316156d9c562bcf696647e884893f62a4971 --- /dev/null +++ b/app/views/vips/solutions/participants_overview.php @@ -0,0 +1,225 @@ +<?php +/** + * @var string $display + * @var Vips_SolutionsController $controller + * @var string $course_id + * @var string $view + * @var array $items + * @var bool $has_grades + * @var string $sort + * @var bool $desc + * @var array $overall + * @var array $participants + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<table class="default"> + <caption> + <? if ($display === 'points') : ?> + <?= _('Punkteübersicht') ?> + + <span class="actions"> + <form action="<?= $controller->participants_overview() ?>"> + <input type="hidden" name="cid" value="<?= htmlReady($course_id) ?>"> + <input type="hidden" name="display" value="points"> + + <label> + <?= _('Anzeigefilter:') ?> + + <select name="view" class="submit-upon-select"> + <option value=""> + <?= _('Ãœbungen und Klausuren') ?> + </option> + <option value="selftest" <?= $view === 'selftest' ? 'selected' : '' ?>> + <?= _('Selbsttests') ?> + </option> + </select> + </label> + </form> + </span> + <? else : ?> + <?= _('Notenübersicht') ?> + <? endif ?> + </caption> + + <colgroup> + <col> + + <? if (count($items['tests']) > 0) : ?> + <col style="border-left: 1px dotted gray;"> + <? endif ?> + <? if (count($items['tests']) > 1) : ?> + <col span="<?= count($items['tests']) - 1 ?>"> + <? endif ?> + + <? if (count($items['blocks']) > 0) : ?> + <col style="border-left: 1px dotted gray;"> + <? endif ?> + <? if (count($items['blocks']) > 1) : ?> + <col span="<?= count($items['blocks']) - 1 ?>"> + <? endif ?> + + <? if (count($items['exams']) > 0) : ?> + <col style="border-left: 1px dotted gray;"> + <? endif ?> + <? if (count($items['exams']) > 1) : ?> + <col span="<?= count($items['exams']) - 1 ?>"> + <? endif ?> + + <col style="border-left: 1px dotted gray;"> + <? if ($display == 'weighting' && $has_grades) : ?> + <col> + <? endif ?> + </colgroup> + + <thead> + <tr> + <th><? /* participant */ ?></th> + + <? if (count($items['tests']) > 0) : ?> + <th colspan="<?= count($items['tests']) ?>" style="text-align: center;"> + <?= $view === 'selftest' ? _('Selbsttests') : _('Ãœbungen') ?> + </th> + <? endif ?> + + <? if (count($items['blocks']) > 0) : ?> + <th colspan="<?= count($items['blocks']) ?>" style="text-align: center;"> + <?= _('Blöcke') ?> + </th> + <? endif ?> + + <? if (count($items['exams']) > 0) : ?> + <th colspan="<?= count($items['exams']) ?>" style="text-align: center;"> + <?= _('Klausuren') ?> + </th> + <? endif ?> + + <th><? /* sum */ ?></th> + <? if ($display == 'weighting' && $has_grades) : ?> + <th><? /* grade */ ?></th> + <? endif ?> + </tr> + + <tr class="sortable"> + <th class="nowrap <?= $controller->sort_class($sort === 'name', $desc) ?>"> + <a href="<?= $controller->participants_overview(['display' => $display, 'view' => $view, 'sort' => 'name', 'desc' => $sort === 'name' && !$desc]) ?>"> + <?= _('Nachname, Vorname') ?> + </a> + </th> + + <? foreach ($items as $category => $list) : ?> + <? foreach ($list as $item) : ?> + <th class="gradebook_header" title="<?= htmlReady($item['tooltip']) ?>"> + <?= htmlReady($item['name']) ?> + </th> + <? endforeach ?> + <? endforeach ?> + + <th class="nowrap <?= $controller->sort_class($sort === 'sum', $desc) ?>"> + <a href="<?= $controller->participants_overview(['display' => $display, 'view' => $view, 'sort' => 'sum', 'desc' => $sort !== 'sum' || !$desc]) ?>"> + <?= _('Summe') ?> + </a> + </th> + + <? if ($display == 'weighting' && $has_grades) : ?> + <th class="nowrap <?= $controller->sort_class($sort === 'grade', $desc) ?>"> + <a href="<?= $controller->participants_overview(['display' => $display, 'sort' => 'grade', 'desc' => $sort !== 'grade' || !$desc]) ?>"> + <?= _('Note') ?> + </a> + </th> + <? endif ?> + </tr> + + <? if ($display == 'points' || $this->overall['weighting']): ?> + <tr class="smaller" style="background-color: #D1D1D1;"> + <td> + <? if ($display == 'points') : ?> + <?= _('Maximalpunktzahl') ?> + <? else : ?> + <?= _('Gewichtung') ?> + <? endif ?> + </td> + + <? foreach ($items as $category => $list) : ?> + <? foreach ($list as $item) : ?> + <td style="text-align: right; white-space: nowrap;"> + <? if ($display == 'points') : ?> + <?= sprintf('%g', $item['points']) ?> + <? else : ?> + <?= sprintf('%d %%', round($item['weighting'], 1)) ?> + <? endif ?> + </td> + <? endforeach ?> + <? endforeach ?> + + <td style="text-align: right; white-space: nowrap;"> + <? if ($display == 'points') : ?> + <?= sprintf('%g', $overall['points']) ?> + <? else : ?> + 100 % + <? endif ?> + </td> + + <? if ($display == 'weighting' && $has_grades) : ?> + <td></td> + <? endif ?> + </tr> + <? endif ?> + </thead> + + <tbody> + <? /* each participant */ ?> + <? foreach ($participants as $p) : ?> + <tr> + <td> + <?= htmlReady($p['name']) ?> + </td> + + <? foreach ($items as $category => $list) : ?> + <? foreach ($list as $item) : ?> + <td style="text-align: right; white-space: nowrap;"> + <? if ($display == 'points') : ?> + <? if (isset($p['items'][$category][$item['id']]['points'])) : ?> + <?= sprintf('%.1f', $p['items'][$category][$item['id']]['points']) ?> + <? else : ?> + – + <? endif ?> + <? else : ?> + <? if (isset($p['items'][$category][$item['id']]['percent'])) : ?> + <?= sprintf('%.1f %%', $p['items'][$category][$item['id']]['percent']) ?> + <? else : ?> + – + <? endif ?> + <? endif ?> + </td> + <? endforeach ?> + <? endforeach ?> + + <td style="text-align: right; white-space: nowrap;"> + <? if ($display == 'points') : ?> + <? if (isset($p['overall']['points'])): ?> + <?= sprintf('%.1f', $p['overall']['points']) ?> + <? else: ?> + – + <? endif ?> + <? else : ?> + <? if (isset($p['overall']['weighting'])): ?> + <?= sprintf('%.1f %%', $p['overall']['weighting']) ?> + <? else: ?> + – + <? endif ?> + <? endif ?> + </td> + + <? if ($display == 'weighting' && $has_grades) : ?> + <td style="text-align: right;"> + <?= htmlReady($p['grade']) ?> + </td> + <? endif ?> + </tr> + <? endforeach ?> + </tbody> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/show_assignment_log.php b/app/views/vips/solutions/show_assignment_log.php new file mode 100644 index 0000000000000000000000000000000000000000..0b1b5c81ef95889d7231b315aaa67f5da6a13a2b --- /dev/null +++ b/app/views/vips/solutions/show_assignment_log.php @@ -0,0 +1,56 @@ +<?php +/** + * @var User $user + * @var array $logs + */ +?> +<table class="default" style="min-width: 960px;"> + <caption> + <?= sprintf(_('Abgabeprotokoll für %s, %s (%s)'), $user->nachname, $user->vorname, $user->username) ?> + </caption> + + <thead> + <tr> + <th> + <?= _('Ereignis') ?> + </th> + <th> + <?= _('Zeit') ?> + </th> + <th> + <?= _('IP-Adresse') ?> + </th> + <th> + <?= _('Rechnername') ?> + </th> + <th> + <?= _('Sitzungs-ID') ?> + <?= tooltipIcon(_('Die Sitzungs-ID wird beim Login in Stud.IP vergeben und bleibt bis zum Abmelden gültig.')) ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($logs as $log): ?> + <tr> + <td class="<?= $log['archived'] ? 'quiet' : '' ?>"> + <?= htmlReady($log['label']) ?> + </td> + <td> + <?= date('d.m.Y, H:i:s', strtotime($log['time'])) ?> + </td> + <td> + <?= htmlReady($log['ip_address']) ?> + </td> + <td> + <? if ($log['ip_address']): ?> + <?= htmlReady($controller->gethostbyaddr($log['ip_address'])) ?> + <? endif ?> + </td> + <td> + <?= htmlReady($log['session_id']) ?> + </td> + </tr> + <? endforeach ?> + </tbody> +</table> diff --git a/app/views/vips/solutions/solution_color_tooltip.php b/app/views/vips/solutions/solution_color_tooltip.php new file mode 100644 index 0000000000000000000000000000000000000000..41c43370623b460e82b6a910a99445d5b077e8e5 --- /dev/null +++ b/app/views/vips/solutions/solution_color_tooltip.php @@ -0,0 +1,4 @@ +<?= sprintf(_('%sTürkis dargestellte Aufgaben%s wurden automatisch und sicher korrigiert.'), '<span class="solution-autocorrected">', '</span>') ?><br> +<?= sprintf(_('%sGrün dargestellte Aufgaben%s wurden von Hand korrigiert.'), '<span class="solution-corrected">', '</span>') ?><br> +<?= sprintf(_('%sRot dargestellte Aufgaben%s wurden noch nicht fertig korrigiert.'), '<span class="solution-uncorrected">', '</span>') ?><br> +<?= sprintf(_('%sAusgegraute Aufgaben%s wurden nicht bearbeitet.'), '<span class="solution-none">', '</span>') ?><br> diff --git a/app/views/vips/solutions/statistics.php b/app/views/vips/solutions/statistics.php new file mode 100644 index 0000000000000000000000000000000000000000..d0327b3a1b5132da5e903a785198c64d7ca482b1 --- /dev/null +++ b/app/views/vips/solutions/statistics.php @@ -0,0 +1,95 @@ +<?php +/** + * @var array $assignments + * @var Vips_SolutionsController $controller + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<? if (count($assignments)) : ?> + <table class="default"> + <caption> + <?= _('Statistik der Aufgabenblätter') ?> + </caption> + + <thead> + <tr> + <th> + <?= _('Titel / Aufgabe') ?> + </th> + <th style="text-align: right;"> + <?= _('Erreichbare Punkte') ?> + </th> + <th style="text-align: right;"> + <?= _('Durchschn. Punkte') ?> + </th> + <th style="text-align: right;"> + <?= _('Korrekte Lösungen') ?> + </th> + </tr> + </thead> + + <tbody> + <? foreach ($assignments as $assignment): ?> + <? if (count($assignment['exercises'])): ?> + <tr style="font-weight: bold;"> + <td style="width: 70%;"> + <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment['assignment']->id]) ?>"> + <?= $assignment['assignment']->getTypeIcon() ?> + <?= htmlReady($assignment['assignment']->test->title) ?> + </a> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $assignment['points']) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $assignment['average']) ?> + </td> + <td> + </td> + </tr> + + <? foreach ($assignment['exercises'] as $exercise): ?> + <tr> + <td style="width: 70%; padding-left: 2em;"> + <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment['assignment']->id, 'exercise_id' => $exercise['id']]) ?>"> + <?= $exercise['position'] ?>. <?= htmlReady($exercise['name']) ?> + </a> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $exercise['points']) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $exercise['average']) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f %%', $exercise['correct'] * 100) ?> + </td> + </tr> + + <? if (count($exercise['items']) > 1): ?> + <? foreach ($exercise['items'] as $index => $item): ?> + <tr> + <td style="width: 70%; padding-left: 4em;"> + <?= sprintf(_('Item %d'), $index + 1) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $exercise['points'] / count($exercise['items'])) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f', $item) ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%.1f %%', $exercise['items_c'][$index] * 100) ?> + </td> + </tr> + <? endforeach ?> + <? endif ?> + <? endforeach ?> + <? endif ?> + <? endforeach ?> + </tbody> + </table> +<? endif ?> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/student_assignment_solutions.php b/app/views/vips/solutions/student_assignment_solutions.php new file mode 100644 index 0000000000000000000000000000000000000000..5a4238b632ca231abfb7af115a9b0fc52d3f823e --- /dev/null +++ b/app/views/vips/solutions/student_assignment_solutions.php @@ -0,0 +1,125 @@ +<?php +/** + * @var VipsAssignment $assignment + * @var string $user_id + * @var int $released + * @var Vips_SolutionsController $controller + * @var string $feedback + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<h1 class="width-1200"> + <?= htmlReady($assignment->test->title) ?> +</h1> + +<div class="width-1200" style="margin: 10px 0;"> + <?= formatReady($assignment->test->description) ?> +</div> + +<table class="default dynamic_list collapsable width-1200"> + <caption> + <?= _('Ergebnisse des Aufgabenblatts') ?> + </caption> + + <thead> + <tr> + <th style="width: 2em;"> + </th> + + <th style="width: 60%;"> + <?= _('Aufgaben') ?> + </th> + + <th style="width: 10%; text-align: center;"> + <?= _('Bearbeitet') ?> + </th> + + <th style="width: 15%; text-align: center;"> + <?= _('Erreichte Punkte') ?> + </th> + + <th style="width: 15%; text-align: center;"> + <?= _('Max. Punkte') ?> + </th> + </tr> + </thead> + + <? foreach ($assignment->getExerciseRefs($user_id) as $exercise_ref) : ?> + <? $solution = $assignment->getSolution($user_id, $exercise_ref->task_id); ?> + <tbody class="collapsed"> + <tr class="header-row"> + <td class="dynamic_counter" style="text-align: right;"> + </td> + <td> + <? if ($released >= VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?> + <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $exercise_ref->task_id]) ?>"> + <?= htmlReady($exercise_ref->exercise->title) ?> + </a> + <? elseif ($released == VipsAssignment::RELEASE_STATUS_COMMENTS && $solution && $solution->hasFeedback()) : ?> + <a class="toggler" href="#"> + <?= htmlReady($exercise_ref->exercise->title) ?> + <a> + <? else: ?> + <?= htmlReady($exercise_ref->exercise->title) ?> + <? endif ?> + </td> + <td style="text-align: center;"> + <? if ($solution): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?> + <? else : ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?> + <? endif ?> + </td> + <td style="text-align: center;"> + <?= sprintf('%g', $solution ? $solution->points : 0) ?> + </td> + <td style="text-align: center;"> + <?= sprintf('%g', $exercise_ref->points) ?> + </td> + </tr> + + <? if ($released == VipsAssignment::RELEASE_STATUS_COMMENTS && $solution && $solution->hasFeedback()): ?> + <tr> + <td> + </td> + <td colspan="4"> + <?= formatReady($solution->feedback) ?> + <?= $this->render_partial('vips/solutions/feedback_files', compact('solution')) ?> + </td> + </tr> + <? endif ?> + </tbody> + <? endforeach ?> + + <tfoot> + <tr style="font-weight: bold;"> + <td> + </td> + + <td colspan="2" style="padding: 5px;"> + <?= _('Gesamtpunktzahl') ?> + </td> + + <td style="text-align: center;"> + <?= sprintf('%g', $assignment->getUserPoints($user_id)) ?> + </td> + + <td style="text-align: center;"> + <?= sprintf('%g', $assignment->test->getTotalPoints()) ?> + </td> + </tr> + </tfoot> +</table> + +<? if ($released >= VipsAssignment::RELEASE_STATUS_COMMENTS && $feedback != ''): ?> + <div class="width-1200"> + <h3> + <?= _('Kommentar zur Bewertung') ?> + </h3> + + <?= formatReady($feedback) ?> + </div> +<? endif ?> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/student_grade.php b/app/views/vips/solutions/student_grade.php new file mode 100644 index 0000000000000000000000000000000000000000..421434f02532fcb0bac7baac3b58851418a4b446 --- /dev/null +++ b/app/views/vips/solutions/student_grade.php @@ -0,0 +1,109 @@ +<?php +/** + * @var bool $use_weighting + * @var array $participants + * @var array $items + * @var string $user_id + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<table class="default"> + <caption> + <?= _('Note') ?> + </caption> + + <thead> + <tr> + <th> + <?= _('Titel') ?> + </th> + <th colspan="3" style="text-align: center; width: 1%;"> + <?= _('Punkte') ?> + </th> + <th style="text-align: right;"> + <?= _('Prozent') ?> + </th> + <? if ($use_weighting) : ?> + <th style="text-align: right;"> + <?= _('Gewichtung') ?> + </th> + <? endif ?> + </tr> + </thead> + + <? /* here, $participants contains only one entry */ ?> + <? foreach ($participants as $me) : ?> + + <tbody> + <? foreach (['tests', 'blocks', 'exams'] as $category) : ?> + <? foreach ($items[$category] as $item) : ?> + <? if ($item['item']->isVisible($user_id) && $item['weighting']) : ?> + <tr> + <td> + <?= htmlReady($item['name']) ?> + </td> + + <td style="text-align: right;"> + <? if (isset($me['items'][$category][$item['id']]['points'])) : ?> + <?= sprintf('%g', $me['items'][$category][$item['id']]['points']) ?> + <? else : ?> + – + <? endif ?> + </td> + + <td style="text-align: center;"> + / + </td> + + <td style="text-align: right;"> + <?= sprintf('%g', $item['points']) ?> + </td> + + <td style="text-align: right;"> + <? if (isset($me['items'][$category][$item['id']]['percent'])) : ?> + <?= sprintf('%.1f %%', $me['items'][$category][$item['id']]['percent']) ?> + <? else : ?> + – + <? endif ?> + </td> + + <? if ($use_weighting) : ?> + <td style="text-align: right;"> + <?= sprintf('%.1f %%', $item['weighting']) ?> + </td> + <? endif ?> + </tr> + <? endif ?> + <? endforeach ?> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="4" style="padding: 5px;"> + <?= _('Prozent, gesamt') ?> + </td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%.1f %%', $me['overall']['weighting']) ?> + </td> + <? if ($use_weighting) : ?> + <td></td> + <? endif ?> + </tr> + + <tr style="font-weight: bold;"> + <td colspan="<?= $use_weighting ? 6 : 5 ?>" style="text-align: center;"> + <?= _('Note:') ?> + <?= htmlReady($me['grade']) ?> + <? if ($me['grade_comment'] != '') : ?> + (<?= htmlReady($me['grade_comment']) ?>) + <? endif ?> + </td> + </tr> + </tfoot> + + <? endforeach ?> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/update_released_dialog.php b/app/views/vips/solutions/update_released_dialog.php new file mode 100644 index 0000000000000000000000000000000000000000..a8a88adc9517ee0aa4a0bcdbca4b758ab3514680 --- /dev/null +++ b/app/views/vips/solutions/update_released_dialog.php @@ -0,0 +1,42 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var int[] $assignment_ids + * @var int $default + */ +?> +<form class="default" action="<?= $controller->update_released() ?>" method="POST"> + <?= CSRFProtection::tokenTag() ?> + <? foreach ($assignment_ids as $assignment_id): ?> + <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>"> + <? endforeach ?> + + <label> + <input type="radio" name="released" value="0" <?= $default == VipsAssignment::RELEASE_STATUS_NONE ? 'checked' : '' ?>> + <?= _('Nichts') ?> + </label> + + <label> + <input type="radio" name="released" value="1" <?= $default == VipsAssignment::RELEASE_STATUS_POINTS ? 'checked' : '' ?>> + <?= _('Vergebene Punkte') ?> + </label> + + <label> + <input type="radio" name="released" value="2" <?= $default == VipsAssignment::RELEASE_STATUS_COMMENTS ? 'checked' : '' ?>> + <?= _('Punkte und Kommentare') ?> + </label> + + <label> + <input type="radio" name="released" value="3" <?= $default == VipsAssignment::RELEASE_STATUS_CORRECTIONS ? 'checked' : '' ?>> + <?= _('… zusätzlich Aufgaben und Korrektur') ?> + </label> + + <label> + <input type="radio" name="released" value="4" <?= $default == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS ? 'checked' : '' ?>> + <?= _('… zusätzlich Musterlösungen') ?> + </label> + + <footer data-dialog-button> + <?= Studip\Button::createAccept(_('Speichern'), 'save') ?> + </footer> +</form> diff --git a/app/views/vips/solutions/view_solution.php b/app/views/vips/solutions/view_solution.php new file mode 100644 index 0000000000000000000000000000000000000000..bac4a8969311b560f36651484f2ea9c3e8c4b3e3 --- /dev/null +++ b/app/views/vips/solutions/view_solution.php @@ -0,0 +1,77 @@ +<?php +/** + * @var Vips_SolutionsController $controller + * @var VipsAssignment $assignment + * @var Exercise $exercise + * @var VipsSolution $solution + * @var float $max_points + */ +?> +<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?> + +<div class="breadcrumb width-1200"> + <div style="display: inline-block; width: 20%;"> + <? if (isset($prev_exercise_id)) : ?> + <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $prev_exercise_id]) ?>"> + <?= Icon::create('arr_1left') ?> + <?= _('Vorige Aufgabe') ?> + </a> + <? endif ?> + </div><!-- + --><div style="display: inline-block; text-align: center; width: 60%;"> + <a href="<?= $controller->student_assignment_solutions(['assignment_id' => $assignment->id]) ?>"> + • <?= htmlReady($assignment->test->title) ?> • + </a> + </div><!-- + --><div style="display: inline-block; text-align: right; width: 20%;"> + <? if (isset($next_exercise_id)) : ?> + <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $next_exercise_id]) ?>"> + <?= _('Nächste Aufgabe') ?> + <?= Icon::create('arr_1right') ?> + </a> + <? endif ?> + </div> +</div> + +<form class="default width-1200"> + <?= $this->render_partial('vips/exercises/correct_exercise') ?> + + <fieldset> + <legend> + <?= sprintf(_('Bewertung der Aufgabe „%s“'), htmlReady($exercise->title)) ?> + <div style="float: right;"> + <? if ($solution->state): ?> + <?= _('Korrigiert') ?> + <? elseif ($solution->id): ?> + <?= _('Unkorrigiert') ?> + <? else: ?> + <?= _('Nicht abgegeben') ?> + <? endif ?> + </div> + </legend> + + <? if ($solution->feedback != '') : ?> + <div class="label-text"> + <?= _('Anmerkung des Korrektors') ?> + + <? if (isset($solution->grader_id) && $assignment->type === 'practice') : ?> + <? $corrector_full_name = get_fullname($solution->grader_id); ?> + (<a href="<?= URLHelper::getLink('dispatch.php/messages/write', ['rec_uname' => get_username($solution->grader_id)]) ?>" + title="<?= htmlReady(sprintf(_('Nachricht an „%s“ schreiben'), $corrector_full_name)) ?>" data-dialog><?= htmlReady($corrector_full_name) ?></a>) + <? endif ?> + </div> + + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <?= $this->render_partial('vips/solutions/feedback_files_table') ?> + + <div class="description"> + <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> + </fieldset> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/composer.json b/composer.json index 410e14d1cae37dbedc49acff664b7934f7ad915b..ea95764e32a883ab031f3140cc5da8b15f7fea30 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "lib/models/", "lib/models/calendar/", "lib/models/resources/", + "lib/models/vips/", "lib/modules/", "lib/navigation/", "lib/plugins/core/", @@ -100,6 +101,7 @@ "ext-mbstring": "*", "ext-dom": "*", "ext-iconv": "*", + "ext-simplexml": "*", "opis/json-schema": "2.3.0", "slim/slim": "4.13.0", "php-di/php-di": "7.0.0", diff --git a/db/migrations/6.0.40_add_vips_module.php b/db/migrations/6.0.40_add_vips_module.php new file mode 100644 index 0000000000000000000000000000000000000000..8fc50c842b8e4bd9e68cb2e0316b7b841596f21d --- /dev/null +++ b/db/migrations/6.0.40_add_vips_module.php @@ -0,0 +1,485 @@ +<?php + +class AddVipsModule extends Migration +{ + public function description() + { + return 'initial database setup for Vips'; + } + + public function up() + { + $db = DBManager::get(); + + // install as core plugin + $sql = "INSERT INTO plugins (pluginclassname, pluginname, plugintype, enabled, navigationpos) + VALUES ('VipsModule', 'Aufgaben', 'StudipModule,SystemPlugin,PrivacyPlugin,Courseware\\\\CoursewarePlugin', 'yes', 1)"; + $db->exec($sql); + $id = $db->lastInsertId(); + + $sql = "INSERT INTO roles_plugins (roleid, pluginid) + SELECT roleid, ? FROM roles WHERE `system` = 'y'"; + $db->execute($sql, [$id]); + + // copy tool activations from Vips plugin + $sql = "INSERT INTO tools_activated + SELECT range_id, range_type, ?, position, metadata, mkdate, chdate FROM tools_activated + WHERE plugin_id = (SELECT pluginid FROM plugins WHERE pluginname = 'Vips')"; + $db->execute($sql, [$id]); + + // update etask tables + $sql = "ALTER TABLE etask_assignments + CHANGE type type varchar(64) COLLATE latin1_bin NOT NULL, + CHANGE active active tinyint UNSIGNED NOT NULL DEFAULT 1, + ADD weight float NOT NULL DEFAULT 0 AFTER active, + ADD block_id int DEFAULT NULL AFTER weight, + ADD KEY test_id (test_id), + ADD KEY range_id (range_id)"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_assignment_attempts + ADD ip_address varchar(39) COLLATE latin1_bin NOT NULL AFTER end, + CHANGE options options text DEFAULT NULL, + ADD UNIQUE KEY assignment_id (assignment_id,user_id)"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_responses + CHANGE response response mediumtext NOT NULL, + ADD student_comment text DEFAULT NULL AFTER response, + ADD ip_address varchar(39) COLLATE latin1_bin NOT NULL AFTER student_comment, + ADD commented_solution text DEFAULT NULL AFTER feedback, + ADD KEY assignment_id (assignment_id,task_id,user_id), + ADD KEY user_id (user_id), + ADD KEY task_id (task_id)"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_tasks + CHANGE type type varchar(64) COLLATE latin1_bin NOT NULL, + CHANGE description description mediumtext NOT NULL, + CHANGE task task mediumtext NOT NULL, + ADD KEY user_id (user_id)"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_tests + CHANGE description description mediumtext NOT NULL, + CHANGE options options text DEFAULT NULL, + ADD KEY user_id (user_id)"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_test_tasks + ADD part int NOT NULL DEFAULT 0 AFTER position, + ADD KEY task_id (task_id)"; + $db->exec($sql); + + // add new tables + $sql = "CREATE TABLE etask_blocks ( + id int NOT NULL AUTO_INCREMENT, + name varchar(255) NOT NULL, + range_id char(32) COLLATE latin1_bin NOT NULL, + group_id char(32) COLLATE latin1_bin DEFAULT NULL, + visible tinyint NOT NULL DEFAULT 1, + weight float DEFAULT NULL, + PRIMARY KEY (id), + KEY range_id (range_id) + )"; + $db->exec($sql); + + $sql = "CREATE TABLE etask_group_members ( + group_id char(32) COLLATE latin1_bin NOT NULL, + user_id char(32) COLLATE latin1_bin NOT NULL, + start int unsigned NOT NULL, + end int unsigned DEFAULT NULL, + PRIMARY KEY (group_id,user_id,start), + KEY user_id (user_id) + )"; + $db->exec($sql); + + // add settings (unless already present) + $sql = 'INSERT IGNORE INTO `config` (`field`, `value`, `type`, `range`, `mkdate`, `chdate`, `description`) + VALUES (:name, :value, :type, :range, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)'; + $statement = DBManager::get()->prepare($sql); + $statement->execute([ + ':name' => 'VIPS_COURSE_GRADES', + ':description' => 'Kursbezogenes Schema zur Notenverteilung in Vips', + ':range' => 'course', + ':type' => 'array', + ':value' => '[]' + ]); + $statement->execute([ + ':name' => 'VIPS_EXAM_RESTRICTIONS', + ':description' => 'Sperrt während einer Klausur andere Bereiche von Stud.IP für die Teilnehmenden', + ':range' => 'global', + ':type' => 'boolean', + ':value' => '0' + ]); + $statement->execute([ + ':name' => 'VIPS_EXAM_ROOMS', + ':description' => 'Zentral verwaltete IP-Adressen für PC-Räume', + ':range' => 'global', + ':type' => 'array', + ':value' => '[]' + ]); + $statement->execute([ + ':name' => 'VIPS_EXAM_TERMS', + ':description' => 'Teilnahmebedingungen, die vor Beginn einer Klausur zu akzeptieren sind', + ':range' => 'global', + ':type' => 'string', + ':value' => '' + ]); + + // copy data from Vips plugin + $result = $db->query("SHOW TABLES LIKE 'vips_assignment'"); + + if ($result->rowCount() > 0) { + $this->copyVipsData(); + } + } + + private function copyVipsData() + { + $db = DBManager::get(); + $now = time(); + + $task_id = []; + $test_id = []; + $assignment_id = []; + $response_id = []; + $group_id = []; + $folder_id = []; + + $task_mapping = [ + 'sc_exercise' => 'SingleChoiceTask', + 'mc_exercise' => 'MultipleChoiceTask', + 'mco_exercise' => 'MatrixChoiceTask', + 'lt_exercise' => 'TextLineTask', + 'tb_exercise' => 'TextTask', + 'cloze_exercise' => 'ClozeTask', + 'rh_exercise' => 'MatchingTask', + 'seq_exercise' => 'SequenceTask' + ]; + + // etask_tasks + $sql = 'INSERT INTO etask_tasks (type, title, description, task, user_id, mkdate, chdate, options) + VALUES (:type, :title, :description, :task, :user_id, :mkdate, :chdate, :options)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_exercise'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + $values = [ + 'type' => $task_mapping[$row['type']] ?? $row['type'], + 'title' => $row['title'], + 'description' => $row['description'], + 'task' => $row['task_json'], + 'user_id' => $row['user_id'], + 'mkdate' => strtotime($row['created']), + 'chdate' => $now, + 'options' => $row['options'] ?: '[]' + ]; + $stmt->execute($values); + $task_id[$row['id']] = $db->lastInsertId(); + } + + // etask_tests + $sql = 'INSERT INTO etask_tests (title, description, user_id, mkdate, chdate, options) + VALUES (:title, :description, :user_id, :mkdate, :chdate, :options)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_test'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + $values = [ + 'title' => $row['title'], + 'description' => $row['description'], + 'user_id' => $row['user_id'], + 'mkdate' => strtotime($row['created']), + 'chdate' => $now, + 'options' => null + ]; + $stmt->execute($values); + $test_id[$row['id']] = $db->lastInsertId(); + } + + // etask_test_tasks + $sql = 'INSERT INTO etask_test_tasks (test_id, task_id, position, part, points, options, mkdate, chdate) + VALUES (:test_id, :task_id, :position, :part, :points, :options, :mkdate, :chdate)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_exercise_ref'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if (isset($test_id[$row['test_id']]) && isset($task_id[$row['exercise_id']])) { + $values = [ + 'test_id' => $test_id[$row['test_id']], + 'task_id' => $task_id[$row['exercise_id']], + 'position' => $row['position'], + 'part' => $row['part'], + 'points' => $row['points'], + 'mkdate' => $now, + 'chdate' => $now, + 'options' => '', + ]; + $stmt->execute($values); + } + } + + // etask_assignments + $sql = 'INSERT INTO etask_assignments (test_id, range_type, range_id, type, start, end, active, weight, block_id, options, mkdate, chdate) + VALUES (:test_id, :range_type, :range_id, :type, :start, :end, :active, :weight, :block_id, :options, :mkdate, :chdate)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_assignment'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if (isset($test_id[$row['test_id']])) { + $options = json_decode($row['options'], true); + unset($options['shuffle_answers']); + unset($options['printable']); + + $values = [ + 'test_id' => $test_id[$row['test_id']], + 'range_type' => $row['context'], + 'range_id' => $row['course_id'], + 'type' => $row['type'], + 'start' => strtotime($row['start']), + 'end' => strtotime($row['end']), + 'active' => $row['active'], + 'weight' => $row['weight'], + 'block_id' => $row['block_id'], + 'options' => json_encode($options), + 'mkdate' => $now, + 'chdate' => $now + ]; + $stmt->execute($values); + $assignment_id[$row['id']] = $db->lastInsertId(); + } + } + + // etask_assignment_attempts + $sql = 'INSERT INTO etask_assignment_attempts (assignment_id, user_id, start, end, ip_address, options, mkdate, chdate) + VALUES (:assignment_id, :user_id, :start, :end, :ip_address, :options, :mkdate, :chdate)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_assignment_attempt'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if (isset($assignment_id[$row['assignment_id']])) { + $values = [ + 'assignment_id' => $assignment_id[$row['assignment_id']], + 'user_id' => $row['user_id'], + 'start' => strtotime($row['start']), + 'end' => $row['end'] ? strtotime($row['end']) : null, + 'ip_address' => $row['ip_address'], + 'options' => $row['options'], + 'mkdate' => $now, + 'chdate' => $now + ]; + $stmt->execute($values); + } + } + + // etask_responses + $sql = 'INSERT INTO etask_responses (assignment_id, task_id, user_id, response, student_comment, ip_address, state, points, feedback, commented_solution, grader_id, mkdate, chdate, options) + SELECT :assignment_id, :task_id, user_id, response, student_comment, ip_address, corrected, points, corrector_comment, commented_solution, corrector_id, UNIX_TIMESTAMP(time), UNIX_TIMESTAMP(correction_time), options + FROM :table WHERE id = :id'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT id, exercise_id, assignment_id, 0 as archive FROM vips_solution UNION SELECT id, exercise_id, assignment_id, 1 as archive FROM vips_solution_archive ORDER BY id'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if (isset($assignment_id[$row['assignment_id']]) && isset($task_id[$row['exercise_id']])) { + $stmt->bindValue(':assignment_id', $assignment_id[$row['assignment_id']]); + $stmt->bindValue(':task_id', $task_id[$row['exercise_id']]); + $stmt->bindValue(':table', $row['archive'] ? 'vips_solution_archive' : 'vips_solution', StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':id', $row['id']); + $stmt->execute(); + $response_id[$row['id']] = $db->lastInsertId(); + } + } + + // statusgruppen + $sql = 'INSERT INTO statusgruppen (statusgruppe_id, name, range_id, position, size, mkdate, chdate) + VALUES (:statusgruppe_id, :name, :range_id, :position, :size, :mkdate, :chdate)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_group'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + $id = md5($row['id'] . ':' . uniqid('statusgruppen', true)); + $position = $db->fetchColumn('SELECT MAX(position) FROM statusgruppen WHERE range_id = ?', [$row['course_id']]); + + $values = [ + 'statusgruppe_id' => $id, + 'name' => $row['name'], + 'range_id' => $row['course_id'], + 'position' => $position + 1, + 'size' => $row['size'], + 'mkdate' => $now, + 'chdate' => $now + ]; + $stmt->execute($values); + $group_id[$row['id']] = $id; + } + + // etask_blocks + $sql = 'INSERT INTO etask_blocks (id, name, range_id, group_id, visible, weight) + SELECT id, name, course_id, group_id, visible, weight FROM vips_block'; + $db->exec($sql); + + // etask_group_members + $sql = 'INSERT INTO etask_group_members (group_id, user_id, start, end) + VALUES (:group_id, :user_id, :start, :end)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_group_member'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if (isset($group_id[$row['group_id']])) { + $values = [ + 'group_id' => $group_id[$row['group_id']], + 'user_id' => $row['user_id'], + 'start' => strtotime($row['start']), + 'end' => strtotime($row['end']) + ]; + $stmt->execute($values); + } + } + + // files + $sql = 'INSERT INTO files (id, user_id, mime_type, name, size, mkdate, chdate) + VALUES (:id, :user_id, :mime_type, :name, :size, :mkdate, :chdate)'; + $stmt = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_file'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + $values = [ + 'id' => $row['id'], + 'user_id' => $row['user_id'], + 'mime_type' => $row['mime_type'], + 'name' => $row['name'], + 'size' => $row['size'], + 'mkdate' => strtotime($row['created']), + 'chdate' => $now + ]; + $stmt->execute($values); + } + + // folders and file_refs + $sql = 'INSERT INTO folders (id, user_id, parent_id, range_id, range_type, folder_type, name, data_content, description, mkdate, chdate) + VALUES (:id, :user_id, :parent_id, :range_id, :range_type, :folder_type, :name, :data_content, :description, :mkdate, :chdate)'; + $stmt_folder = $db->prepare($sql); + $sql = "INSERT INTO file_refs (id, file_id, folder_id, description, content_terms_of_use_id, user_id, name, mkdate, chdate) + VALUES (:id, :file_id, :folder_id, :description, 'UNDEF_LICENSE', :user_id, :name, :mkdate, :chdate)"; + $stmt_file_ref = $db->prepare($sql); + $data = $db->query('SELECT * FROM vips_file_ref JOIN vips_file ON vips_file_ref.file_id = vips_file.id'); + + while ($row = $data->fetch(PDO::FETCH_ASSOC)) { + if ($row['type'] === 'exercise') { + $range_id = $task_id[$row['object_id']] ?? null; + $range_type = 'task'; + $folder_type = 'ExerciseFolder'; + } else { + $range_id = $response_id[$row['object_id']] ?? null; + $range_type = 'response'; + $folder_type = $row['type'] === 'solution' ? 'ResponseFolder' : 'FeedbackFolder'; + } + + if (isset($range_id)) { + if (!isset($folder_id[$row['object_id'] . ':' . $row['type']])) { + $new_folder_id = md5($row['object_id'] . ':' . uniqid('folders', true)); + $values = [ + 'id' => $new_folder_id, + 'user_id' => $row['user_id'], + 'parent_id' => '', + 'range_id' => $range_id, + 'range_type' => $range_type, + 'folder_type' => $folder_type, + 'name' => '', + 'data_content' => '', + 'description' => '', + 'mkdate' => strtotime($row['created']), + 'chdate' => $now + ]; + $stmt_folder->execute($values); + $folder_id[$row['object_id'] . ':' . $row['type']] = $new_folder_id; + } + + $file_ref_id = md5($row['file_id'] . ':' . $row['object_id'] . ':' . uniqid('file_refs' , true)); + $values = [ + 'id' => $file_ref_id, + 'file_id' => $row['file_id'], + 'folder_id' => $folder_id[$row['object_id'] . ':' . $row['type']], + 'description' => '', + 'user_id' => $row['user_id'], + 'name' => $row['name'], + 'mkdate' => strtotime($row['created']), + 'chdate' => $now + ]; + $stmt_file_ref->execute($values); + } + } + } + + public function down() + { + $db = DBManager::get(); + + // unregister core plugin + $sql = "DELETE plugins, roles_plugins, tools_activated FROM plugins + LEFT JOIN roles_plugins USING (pluginid) + LEFT JOIN tools_activated ON plugin_id = pluginid + WHERE pluginclassname = 'VipsModule'"; + $db->exec($sql); + + // update etask tables + $sql = "ALTER TABLE etask_assignments + CHANGE type type varchar(64) NOT NULL, + CHANGE active active tinyint UNSIGNED NOT NULL, + DROP weight, + DROP block_id, + DROP KEY test_id, + DROP KEY range_id"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_assignment_attempts + DROP ip_address, + CHANGE options options text NOT NULL, + DROP KEY assignment_id"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_responses + CHANGE response response text NOT NULL, + DROP student_comment, + DROP ip_address, + DROP commented_solution, + DROP KEY assignment_id, + DROP KEY user_id, + DROP KEY task_id"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_tasks + CHANGE type type varchar(64) NOT NULL, + CHANGE description description text NOT NULL, + CHANGE task task text NOT NULL, + DROP KEY user_id"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_tests + CHANGE description description text NOT NULL, + CHANGE options options text NOT NULL, + DROP KEY user_id"; + $db->exec($sql); + + $sql = "ALTER TABLE etask_test_tasks + DROP part, + DROP KEY task_id"; + $db->exec($sql); + + // drop new tables + $db->exec('DROP TABLE etask_blocks, etask_group_members'); + + // remove config entries + $sql = "DELETE config, config_values + FROM config + LEFT JOIN config_values USING (field) + WHERE field IN ( + 'VIPS_COURSE_GRADES', + 'VIPS_EXAM_RESTRICTIONS', + 'VIPS_EXAM_ROOMS', + 'VIPS_EXAM_TERMS' + )"; + $db->exec($sql); + } +} diff --git a/lib/classes/SimpleORMap.php b/lib/classes/SimpleORMap.php index d8cdb8eca06affeee1b7d21acda18fe994c1be11..bea9595890bee46576fbc7f3065d99b427d5103e 100644 --- a/lib/classes/SimpleORMap.php +++ b/lib/classes/SimpleORMap.php @@ -536,7 +536,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate /** * build object with given data * - * @param array $data assoc array of record + * @param iterable $data assoc array of record * @param ?bool $is_new set object to new state * @return static */ @@ -551,7 +551,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate /** * build object with given data and mark it as existing * - * @param array $data assoc array of record + * @param iterable $data assoc array of record * @return static */ public static function buildExisting($data) diff --git a/lib/classes/sidebar/VipsSearchWidget.php b/lib/classes/sidebar/VipsSearchWidget.php new file mode 100644 index 0000000000000000000000000000000000000000..dbfdea6e0647cc20c7704a2f716a4f85d27c3ee0 --- /dev/null +++ b/lib/classes/sidebar/VipsSearchWidget.php @@ -0,0 +1,42 @@ +<?php +/* + * VipsSearchWidget.php - Sidebar SearchWidget for Vips + * Copyright (c) 2024 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +class VipsSearchWidget extends SearchWidget +{ + /** + * Renders the widget. + * + * @param Array $variables Unused variables parameter + * @return String containing the html output of the widget + */ + public function render($variables = []) + { + $needles = []; + + foreach ($this->needles as $needle) { + if ($needle['quick_search']) { + $quick_search = QuickSearch::get($needle['name'], $needle['quick_search']); + $quick_search->noSelectbox(); + if (isset($needle['value'])) { + $quick_search->defaultValue(null, $needle['value']); + } + if (isset($needle['js_func'])) { + $quick_search->fireJSFunctionOnSelect($needle['js_func']); + } + + $needle['quick_search'] = $quick_search; + $needles[] = $needle; + } + } + + return parent::render($variables + compact('needles')); + } +} diff --git a/lib/filesystem/ExerciseFolder.php b/lib/filesystem/ExerciseFolder.php new file mode 100644 index 0000000000000000000000000000000000000000..e400bbcecf49ff556f4f4580c94de278a12e63eb --- /dev/null +++ b/lib/filesystem/ExerciseFolder.php @@ -0,0 +1,111 @@ +<?php +/* + * ExerciseFolder.php - Vips exercise folder class for Stud.IP + * Copyright (c) 2024 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +class ExerciseFolder extends StandardFolder +{ + /** + * @param string|Object $range_id_or_object + * @param string $user_id + * @return bool + */ + public static function availableInRange($range_id_or_object, $user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isReadable($user_id) + { + $exercise = Exercise::find($this->range_id); + + foreach ($exercise->tests as $test) { + foreach ($test->assignments as $assignment) { + if ($assignment->checkEditPermission($user_id) || + $assignment->checkViewPermission($user_id) && + ($assignment->checkAccess($user_id) || $assignment->releaseStatus($user_id) >= 3)) { + return true; + } + } + } + + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isWritable($user_id) + { + $exercise = Exercise::find($this->range_id); + + foreach ($exercise->tests as $test) { + foreach ($test->assignments as $assignment) { + if ($assignment->checkEditPermission($user_id)) { + return true; + } + } + } + + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isEditable($user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isSubfolderAllowed($user_id) + { + return false; + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileDownloadable($fileref_or_id, $user_id) + { + return $this->isReadable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileEditable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileWritable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } +} diff --git a/lib/filesystem/FeedbackFolder.php b/lib/filesystem/FeedbackFolder.php new file mode 100644 index 0000000000000000000000000000000000000000..17511b8de7d369d37468362b98a44c46e0c23dd1 --- /dev/null +++ b/lib/filesystem/FeedbackFolder.php @@ -0,0 +1,96 @@ +<?php +/* + * ExerciseFolder.php - Vips feedback folder class for Stud.IP + * Copyright (c) 2024 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +class FeedbackFolder extends StandardFolder +{ + /** + * @param string|Object $range_id_or_object + * @param string $user_id + * @return bool + */ + public static function availableInRange($range_id_or_object, $user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isReadable($user_id) + { + $solution = VipsSolution::find($this->range_id); + $assignment = $solution->assignment; + + return $assignment->checkEditPermission() || + $assignment->checkViewPermission() && $assignment->releaseStatus($user_id) >= 2; + } + + /** + * @param string $user_id + * @return bool + */ + public function isWritable($user_id) + { + $solution = VipsSolution::find($this->range_id); + $assignment = $solution->assignment; + + return $assignment->checkEditPermission(); + } + + /** + * @param string $user_id + * @return bool + */ + public function isEditable($user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isSubfolderAllowed($user_id) + { + return false; + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileDownloadable($fileref_or_id, $user_id) + { + return $this->isReadable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileEditable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileWritable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } +} diff --git a/lib/filesystem/ResponseFolder.php b/lib/filesystem/ResponseFolder.php new file mode 100644 index 0000000000000000000000000000000000000000..598bf284bde6befbd53281383411d532c8434e1a --- /dev/null +++ b/lib/filesystem/ResponseFolder.php @@ -0,0 +1,107 @@ +<?php +/* + * ExerciseFolder.php - Vips response folder class for Stud.IP + * Copyright (c) 2024 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +class ResponseFolder extends StandardFolder +{ + /** + * @param string|Object $range_id_or_object + * @param string $user_id + * @return bool + */ + public static function availableInRange($range_id_or_object, $user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isReadable($user_id) + { + $solution = VipsSolution::find($this->range_id); + $assignment = $solution->assignment; + + if (!$assignment->checkViewPermission()) { + return false; + } + + if ($assignment->checkEditPermission() || $solution->user_id === $user_id) { + return true; + } + + $group = $assignment->getUserGroup($solution->user_id); + $group2 = $assignment->getUserGroup($user_id); + + return isset($group, $group2) + && $group->id === $group2->id; + } + + /** + * @param string $user_id + * @return bool + */ + public function isWritable($user_id) + { + $solution = VipsSolution::find($this->range_id); + $assignment = $solution->assignment; + + return $assignment->checkEditPermission(); + } + + /** + * @param string $user_id + * @return bool + */ + public function isEditable($user_id) + { + return false; + } + + /** + * @param string $user_id + * @return bool + */ + public function isSubfolderAllowed($user_id) + { + return false; + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileDownloadable($fileref_or_id, $user_id) + { + return $this->isReadable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileEditable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } + + /** + * @param FileRef|string $fileref_or_id + * @param string $user_id + * @return bool + */ + public function isFileWritable($fileref_or_id, $user_id) + { + return $this->isWritable($user_id); + } +} diff --git a/lib/models/Courseware/BlockTypes/TestBlock.php b/lib/models/Courseware/BlockTypes/TestBlock.php new file mode 100644 index 0000000000000000000000000000000000000000..181fff641298697bda6f2f7ac2f74a98b27443cf --- /dev/null +++ b/lib/models/Courseware/BlockTypes/TestBlock.php @@ -0,0 +1,125 @@ +<?php +/* + * TestBlock.php - Courseware Vips test block + * Copyright (c) 2022 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +namespace Courseware\BlockTypes; + +use VipsAssignment; +use VipsModule; + +class TestBlock extends BlockType +{ + /** + * Get a short string describing this type of block. + */ + public static function getType(): string + { + return 'test'; + } + + /** + * Get the title of this type of block. + */ + public static function getTitle(): string + { + return _('Aufgabenblatt'); + } + + /** + * Get the description of this type of block. + */ + public static function getDescription(): string + { + return _('Stellt ein vorhandenes Aufgabenblatt bereit.'); + } + + /** + * Get the initial payload of every instance of this block. + */ + public function initialPayload(): array + { + return ['assignment' => '']; + } + + /** + * Get the JSON schema for the payload of this block type. + */ + public static function getJsonSchema(): string + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'assignment' => [ + 'type' => 'string' + ] + ] + ]; + + return json_encode($schema); + } + + /** + * Get the list of categories for this block type. + */ + public static function getCategories(): array + { + return ['interaction']; + } + + /** + * Get the list of content types for this block type. + */ + public static function getContentTypes(): array + { + return ['rich']; + } + + /** + * Get the list of file types for this block type. + */ + public static function getFileTypes(): array + { + return []; + } + + /** + * Copy the payload of this block into the given range id. + */ + public function copyPayload(string $rangeId = ''): array + { + static $assignments = []; + + $context = $rangeId === $GLOBALS['user']->id ? 'user' : 'course'; + $payload = $this->getPayload(); + + if ($payload['assignment']) { + $assignment = VipsAssignment::find($payload['assignment']); + } + + if (!$assignment || !$assignment->checkEditPermission()) { + return $this->initialPayload(); + } + + if ($context === 'course' && !VipsModule::hasStatus('tutor', $rangeId)) { + return $this->initialPayload(); + } + + if ($assignment->range_id !== $rangeId) { + if (!isset($assignments[$assignment->id])) { + $copy = $assignment->copyIntoCourse($rangeId, $context); + $assignments[$assignment->id] = $copy->id; + } + + $payload['assignment'] = $assignments[$assignment->id]; + } + + return $payload; + } +} diff --git a/lib/models/FileRef.php b/lib/models/FileRef.php index 2a7f48574ac17da05f1d1296da4585131f50f3f7..4196367c9f219c266aae74c432a59c27333c4d39 100644 --- a/lib/models/FileRef.php +++ b/lib/models/FileRef.php @@ -299,7 +299,6 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange return mb_strpos($this->mime_type, 'audio/') === 0; } - /** * Determines if the FileRef references a video file. * @@ -310,6 +309,22 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange return mb_strpos($this->mime_type, 'video/') === 0; } + /** + * Get the preferred content disposition of this file. + */ + public function getContentDisposition(): string + { + if ($this->isImage() || $this->isAudio() || $this->isVideo()) { + return 'inline'; + } + + if (in_array($this->mime_type, ['application/pdf', 'text/plain'])) { + return 'inline'; + } + + return 'attachment'; + } + /** * Export available data of a given user into a storage object * (an instance of the StoredUserData class) for that user. diff --git a/lib/models/Folder.php b/lib/models/Folder.php index 1c7a13ea49a201968e92cfb030b730c7fd3c1f8d..111decf0d95ebf6bae6610327f3f42ef9c8dadce 100644 --- a/lib/models/Folder.php +++ b/lib/models/Folder.php @@ -273,6 +273,17 @@ class Folder extends SimpleORMap implements FeedbackRange return $ret; } + /** + * Retrieves folders by range id and folder type. + * + * @param string $range_id range id of the folder + * @param string $folder_type folder type name + */ + public static function findByRangeIdAndFolderType(?string $range_id, string $folder_type) + { + return self::findBySQL('range_id = ? AND folder_type = ?', [$range_id, $folder_type]); + } + /** * This callback is called before storing a Folder object. * In case the name field is changed this callback assures that the @@ -381,11 +392,15 @@ class Folder extends SimpleORMap implements FeedbackRange * * @param string range_id The ID of the Stud.IP object whose top folder shall be found. * @param string folder_type The expected folder type related to the Stud.IP object (defaults to RootFolder, use MessageFolder for the top folder of a message) + * @param string range_type The expected range type of the Stud.IP object (defaults to auto detect) * * @returns Folder|null Folder object on success or null, if no folder can be created. **/ - public static function findTopFolder($range_id, $folder_type = 'RootFolder') - { + public static function findTopFolder( + string $range_id, + string $folder_type = 'RootFolder', + ?string $range_type = null + ) { $top_folder = self::findOneBySQL( "range_id = ? AND folder_type = ? AND parent_id=''", [$range_id, $folder_type] @@ -395,10 +410,12 @@ class Folder extends SimpleORMap implements FeedbackRange if (!$top_folder) { //top_folder doest not exist: create it //determine range type: - $range_type = self::findRangeTypeById($range_id); if (!$range_type) { - //no range type means we can't create a folder! - return null; + $range_type = self::findRangeTypeById($range_id); + if (!$range_type) { + //no range type means we can't create a folder! + return null; + } } $top_folder = self::createTopFolder($range_id, $range_type, $folder_type); diff --git a/lib/models/vips/ClozeTask.php b/lib/models/vips/ClozeTask.php new file mode 100644 index 0000000000000000000000000000000000000000..b5c8069938e04dce215ba921be4c0f6fa1befe8a --- /dev/null +++ b/lib/models/vips/ClozeTask.php @@ -0,0 +1,505 @@ +<?php +/* + * ClozeTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class ClozeTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('log', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Lückentext mit Eingabe oder Auswahl'); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (!isset($id)) { + $this->task['text'] = ''; + } + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + $this->parseClozeText(trim($request['cloze_text'])); + $this->task['compare'] = $request['compare']; + + if ($this->task['compare'] === 'numeric') { + $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100; + } + + if (isset($request['input_width'])) { + $this->task['input_width'] = (int) $request['input_width']; + } + + if ($request['layout']) { + $this->task['layout'] = $request['layout']; + } + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers. + */ + public function itemCount(): int + { + return count($this->task['answers']); + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ["L'text", 'Eingabehilfe', 'Abgleich']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + $this->parseClozeText($this->description); + $this->description = ''; + + foreach ($exercise as $tag) { + if (key($tag) === 'Abgleich') { + if (current($tag) === 'Kleinbuchstaben') { + $this->task['compare'] = 'ignorecase'; + } + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + $this->task['text'] = ''; + $select = null; + + foreach ($exercise->items->item->description->children() as $name => $elem) { + if ($name == 'text') { + $this->task['text'] .= (string) $elem; + } else if ($name == 'answers') { + $answers = []; + + foreach ($elem->answer as $answer) { + $answers[] = [ + 'text' => (string) $answer, + 'score' => (string) $answer['score'] + ]; + } + + if ($elem['select'] == 'true') { + $select[] = $this->itemCount(); + } + + $this->task['answers'][] = $answers; + $this->task['text'] .= '[[]]'; + } + } + + $this->task['text'] = Studip\Markup::purifyHtml($this->task['text']); + + switch ($exercise->items->item['type']) { + case 'cloze-input': + $this->task['select'] = $select; + break; + case 'cloze-select': + $this->task['layout'] = 'select'; + break; + case 'cloze-drag': + $this->task['layout'] = 'drag'; + } + + if ($exercise->items->item->{'submission-hints'}) { + if ($exercise->items->item->{'submission-hints'}->input['width']) { + $this->task['input_width'] = (int) $exercise->items->item->{'submission-hints'}->input['width']; + } + } + + if ($exercise->items->item->{'evaluation-hints'}) { + switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) { + case 'ignorecase': + $this->task['compare'] = 'ignorecase'; + break; + case 'numeric': + $this->task['compare'] = 'numeric'; + $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'}; + } + } + } + + /** + * Creates a template for editing a cloze exercise. NOTE: As a cloze + * exercise has no special fields (it consists only of the question), + * normally, an empty template will be returned. The only elements it can + * contain are message boxes alerting that for the same cloze an answer + * alternative has been set repeatedly. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + $duplicate_alternatives = $this->findDuplicateAlternatives(); + + foreach ($duplicate_alternatives as $alternative) { + $message = sprintf(_('Achtung: Sie haben bei der %d. Lücke die Antwort „%s“ mehrfach eingetragen.'), + $alternative['index'] + 1, htmlReady($alternative['text'])); + PageLayout::postWarning($message); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate($view, $solution, $assignment, $user_id): \Flexi\Template + { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if ($solution && $solution->id) { + $template->results = $this->evaluateItems($solution); + } + + return $template; + } + + /** + * Return the interaction type of this task (input, select or drag). + */ + public function interactionType(): string + { + return $this->task['layout'] ?? 'input'; + } + + /** + * Check if selection should be offered for the given item. + */ + public function isSelect(string $item, bool $use_default = true): bool + { + if ($use_default && $this->interactionType() === 'select') { + return true; + } + + if (isset($this->task['select'])) { + return in_array($item, $this->task['select']); + } + + return false; + } + + /** + * Returns all currently unassigned answers for the given solution. + */ + public function availableAnswers(?VipsSolution $solution): array + { + $answers = []; + $response = $solution->response ?? []; + + foreach ($this->task['answers'] as $answer) { + foreach ($answer as $option) { + $i = array_search($option['text'], $response); + + if ($i !== false) { + unset($response[$i]); + } else if ($option['text'] !== '') { + $answers[] = $option['text']; + } + } + } + + sort($answers, SORT_LOCALE_STRING); + return $answers; + } + + /** + * Returns all the correct answers for an item in an array. + */ + public function correctAnswers($item): array + { + $answers = []; + + foreach ($this->task['answers'][$item] as $answer) { + if ($answer['score'] == 1) { + $answers[] = $answer['text']; + } + } + + return $answers; + } + + /** + * Calculate the optimal input field size for text exercises. + * + * @param int $item item number + * @return int length of input field in characters + */ + public function getInputWidth($item): int + { + if (isset($this->task['input_width'])) { + return 5 << $this->task['input_width']; + } + + $max = 0; + + foreach ($this->task['answers'][$item] as $option) { + $length = mb_strlen($option['text']); + + if ($length > $max) { + $max = $length; + } + } + + $length = $max ? min(max($max, 6), 48) : 12; + + // possible sizes: 5, 10, 20, 40 + return 5 << ceil(log($length / 6) / log(2)); + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $ignorecase = isset($this->task['compare']) && $this->task['compare'] === 'ignorecase'; + $numeric = isset($this->task['compare']) && $this->task['compare'] === 'numeric'; + + foreach ($this->task['answers'] as $blank => $answer) { + $student_answer = $this->normalizeText($response[$blank] ?? '', $ignorecase); + $options = ['' => 0]; + $points = 0; + $safe = $this->interactionType() !== 'input'; + + foreach ($answer as $option) { // different answer options + if ($numeric && $student_answer !== '') { + $correct_unit = $student_unit = null; + $correct = $this->normalizeFloat($option['text'], $correct_unit); + $student = $this->normalizeFloat($response[$blank], $student_unit); + + if ($correct_unit === $student_unit) { + if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) { + $options[$student_answer] = max($option['score'], $options[$student_answer]); + } else { + $safe = true; + } + } + } else { + $content = $this->normalizeText($option['text'], $ignorecase); + $options[$content] = $option['score']; + } + } + + if (isset($options[$student_answer])) { + $points = $options[$student_answer]; + $safe = true; + } + + $result[] = ['points' => $points, 'safe' => $safe]; + } + + return $result; + } + + + + ####################################### + # # + # h e l p e r f u n c t i o n s # + # # + ####################################### + + + + /** + * Returns the exercise for the lecturer. Clozes are represented by square brackets. + */ + public function getClozeText(): string + { + $is_html = Studip\Markup::isHtml($this->task['text']); + $result = ''; + + foreach (explode('[[]]', $this->task['text']) as $blank => $text) { + $result .= $text; + + if (isset($this->task['answers'][$blank])) { // blank + $answers = []; + $select = $this->isSelect($blank, false) ? ':' : ''; + + foreach ($this->task['answers'][$blank] as $answer) { + $answer_text = $answer['text']; + + if (preg_match('/^$|^[":*~ ]|\||\]\]|[] ]$/', $answer_text)) { + $answer_text = '"' . $answer_text . '"'; + } + + if ($answer['score'] == 0) { + $answers[] = '*' . $answer_text; + } else if ($answer['score'] == 0.5) { + $answers[] = '~' . $answer_text; + } else { + $answers[] = $answer_text; + } + } + + $blank = '[[' . $select . implode('|', $answers) . ']]'; + + if ($is_html) { + $blank = htmlReady($blank); + } + + $result .= $blank; + } + } + + return $result; + } + + + + /** + * Converts plain text ("foo bar [blank] text...") to array. + */ + public function parseClozeText(string $question): void + { + $is_html = Studip\Markup::isHtml($question); + $question = Studip\Markup::purifyHtml($question); + $this->task['text'] = ''; + + // $question_array contains text elements and blanks (surrounded by [[ and ]]). + $parts = preg_split('/(\[\[(?:".*?"|.)*?\]\])/s', $question, -1, PREG_SPLIT_DELIM_CAPTURE); + $select = null; + + foreach ($parts as $part) { + if (preg_match('/^\[\[(.*)\]\]$/s', $part, $matches)) { + $part = preg_replace("/[\t\n\r\xA0]/", ' ', $matches[1]); + $answers = []; + + if ($is_html) { + $part = Studip\Markup::markAsHtml($part); + $part = Studip\Markup::removeHtml($part); + } + + if ($part[0] === ':') { + $select[] = $this->itemCount(); + $part = substr($part, 1); + } + + if ($part !== '') { + preg_match_all('/((?:".*?"|[^|])*)\|/', $part . '|', $matches); + + foreach ($matches[1] as $answer) { + $answer = trim($answer); + $points = 1; + + if ($answer !== '') { + if ($answer[0] === '*') { + $points = 0; + $answer = substr($answer, 1); + } else if ($answer[0] === '~') { + $points = 0.5; + $answer = substr($answer, 1); + } + } + + if (preg_match('/^"(.*)"$/', $answer, $matches)) { + $answer = $matches[1]; + } + + $answers[] = ['text' => $answer, 'score' => $points]; + } + } + + $this->task['answers'][] = $answers; + $this->task['text'] .= '[[]]'; + } else { + $this->task['text'] .= $part; + } + } + + $this->task['select'] = $select; + } + + /** + * Searches in each cloze if an answer alternative is given repatedly. + * + * @return array Either an empty array or an array of arrays, each containing the + * elements 'index' (index of the cloze where the duplicate + * entry was found) and 'text' (text of the duplicate entry). + */ + private function findDuplicateAlternatives(): array + { + $duplicate_alternatives = []; + + foreach ($this->task['answers'] as $index => $answers) { + $alternatives = []; + + foreach ($answers as $answer) { + if (in_array($answer['text'], $alternatives, true)) { + $duplicate_alternatives[] = [ + 'index' => $index, + 'text' => $answer['text'] + ]; + } + + $alternatives[] = $answer['text']; + } + } + + return $duplicate_alternatives; + } +} diff --git a/lib/models/vips/DummyExercise.php b/lib/models/vips/DummyExercise.php new file mode 100644 index 0000000000000000000000000000000000000000..daa9dc5ca33ed360baffb60476dfd79ddc19bbca --- /dev/null +++ b/lib/models/vips/DummyExercise.php @@ -0,0 +1,83 @@ +<?php +/* + * DummyExercise.php - Vips plugin for Stud.IP + * Copyright (c) 2021 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class DummyExercise extends Exercise +{ + /** + * Get the name of this exercise type. + */ + public function getTypeName(): string + { + return _('Unbekannter Aufgabentyp'); + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + */ + public function evaluateItems($solution): array + { + return []; + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers (defaults to 1). + */ + public function itemCount(): int + { + return 0; + } + + /** + * Create a template for editing an exercise. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + $template = $GLOBALS['template_factory']->open('shared/string'); + $template->content = ''; + + return $template; + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate( + string $view, + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + $template = $GLOBALS['template_factory']->open('shared/string'); + $template->content = ''; + + return $template; + } +} diff --git a/lib/models/vips/Exercise.php b/lib/models/vips/Exercise.php new file mode 100644 index 0000000000000000000000000000000000000000..a4ef00a1cbe00aec870324202995562bf44992da --- /dev/null +++ b/lib/models/vips/Exercise.php @@ -0,0 +1,855 @@ +<?php +/* + * Exercise.php - base class for all exercise types + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +abstract class Exercise extends SimpleORMap +{ + /** + * The unpacked value from the "task" column in the SORM instance. + * This is an array, but type hinting does not work due to SORM + * writing the JSON string into this property on restore(). + */ + public $task = []; + + /** + * @var array<class-string<static>, array> + */ + private static array $exercise_types = []; + + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_tasks'; + + $config['serialized_fields']['options'] = JSONArrayObject::class; + + $config['has_and_belongs_to_many']['tests'] = [ + 'class_name' => VipsTest::class, + 'thru_table' => 'etask_test_tasks', + 'thru_key' => 'task_id', + 'thru_assoc_key' => 'test_id' + ]; + + $config['has_many']['exercise_refs'] = [ + 'class_name' => VipsExerciseRef::class, + 'assoc_foreign_key' => 'task_id' + ]; + $config['has_many']['solutions'] = [ + 'class_name' => VipsSolution::class, + 'assoc_foreign_key' => 'task_id', + 'on_delete' => 'delete' + ]; + + $config['has_one']['folder'] = [ + 'class_name' => Folder::class, + 'assoc_foreign_key' => 'range_id', + 'assoc_func' => 'findByRangeIdAndFolderType', + 'foreign_key' => fn($record) => [$record->id, 'ExerciseFolder'], + 'on_delete' => 'delete' + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (!isset($id)) { + $this->type = get_class($this); + $this->task = ['answers' => []]; + } + + if (is_null($this->options)) { + $this->options = []; + } + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + $this->title = trim($request['exercise_name']); + $this->description = trim($request['exercise_question']); + $this->description = Studip\Markup::purifyHtml($this->description); + $exercise_hint = trim($request['exercise_hint']); + $exercise_hint = Studip\Markup::purifyHtml($exercise_hint); + $feedback = trim($request['feedback']); + $feedback = Studip\Markup::purifyHtml($feedback); + $this->task = ['answers' => []]; + $this->options = []; + + if ($this->title === '') { + $this->title = _('Aufgabe'); + } + + if ($exercise_hint !== '') { + $this->options['hint'] = $exercise_hint; + } + + if ($feedback !== '') { + $this->options['feedback'] = $feedback; + } + + if ($request['exercise_comment']) { + $this->options['comment'] = 1; + } + + if ($request['file_ids'] && !$request['files_visible']) { + $this->options['files_hidden'] = 1; + } + } + + /** + * Filter input from flexible input with HTMLPurifier (if required). + */ + public static function purifyFlexibleInput(string $html): string + { + if (Studip\Markup::isHtml($html)) { + $text = Studip\Markup::removeHtml($html); + + if (substr_count($html, '<') > 1 || kill_format($text) !== $text) { + $html = Studip\Markup::purifyHtml($html); + } else { + $html = $text; + } + } + + return $html; + } + + /** + * Load a specific exercise from the database. + */ + public static function find($id) + { + $db = DBManager::get(); + + $stmt = $db->prepare('SELECT * FROM etask_tasks WHERE id = ?'); + $stmt->execute([$id]); + $data = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($data) { + return self::buildExisting($data); + } + + return null; + } + + /** + * Load an array of exercises filtered by given sql from the database. + * + * @param string $sql clause to use on the right side of WHERE + * @param array $params for query + */ + public static function findBySQL($sql, $params = []) + { + $db = DBManager::get(); + + $has_join = stripos($sql, 'JOIN '); + if ($has_join === false || $has_join > 10) { + $sql = 'WHERE ' . $sql; + } + $stmt = $db->prepare('SELECT etask_tasks.* FROM etask_tasks ' . $sql); + $stmt->execute($params); + $stmt->setFetchMode(PDO::FETCH_ASSOC); + $result = []; + + while ($data = $stmt->fetch()) { + $result[] = self::buildExisting($data); + } + + return $result; + } + + /** + * Find related records for an n:m relation (has_and_belongs_to_many) + * using a combination table holding the keys. + * + * @param string $foreign_key_value value of foreign key to find related records + * @param array $options relation options from other side of relation + */ + public static function findThru($foreign_key_value, $options) + { + $thru_table = $options['thru_table']; + $thru_key = $options['thru_key']; + $thru_assoc_key = $options['thru_assoc_key']; + + $sql = "JOIN `$thru_table` ON `$thru_table`.`$thru_assoc_key` = etask_tasks.id + WHERE `$thru_table`.`$thru_key` = ? " . $options['order_by']; + + return self::findBySQL($sql, [$foreign_key_value]); + } + + /** + * Create a new exercise object from a data array. + */ + public static function create($data) + { + $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class; + + if (static::class === self::class) { + return $class::create($data); + } else { + return parent::create($data); + } + } + + /** + * Build an exercise object from a data array. + */ + public static function buildExisting($data) + { + $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class; + + return $class::build($data, false); + } + + /** + * Initialize task structure from JSON string. + */ + public function setTask(mixed $value): void + { + if (is_string($value)) { + $this->content['task'] = $value; + $value = json_decode($value, true) ?: []; + } + + $this->task = $value; + } + + /** + * Restore this exercise from the database. + */ + public function restore() + { + $result = parent::restore(); + $this->setTask($this->task); + + return $result; + } + + /** + * Store this exercise into the database. + */ + public function store() + { + $this->content['task'] = json_encode($this->task); + + return parent::store(); + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers (defaults to 1). + */ + public function itemCount(): int + { + return 1; + } + + /** + * Overwrite this function for each exercise type where shuffling answer + * alternatives makes sense. + * + * @param string $user_id A value for initialising the randomizer. + */ + public function shuffleAnswers(string $user_id): void + { + } + + /** + * Returns true if this exercise type is considered as multiple choice. + * In this case, the evaluation mode set on the assignment is applied. + */ + public function isMultipleChoice(): bool + { + return false; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param VipsSolution $solution The solution object returned by getSolutionFromRequest(). + */ + public abstract function evaluateItems(VipsSolution $solution): array; + + /** + * Evaluates a student's solution. + * + * @param VipsSolution $solution The solution object returned by getSolutionFromRequest(). + */ + public function evaluate(VipsSolution $solution): array + { + $results = $this->evaluateItems($solution); + $mc_mode = $solution->assignment->options['evaluation_mode']; + $malus = 0; + $points = 0; + $safe = true; + + foreach ($results as $item) { + if ($item['points'] === 0) { + ++$malus; + } else if ($item['points'] !== null) { + $points += $item['points']; + } + + if ($item['safe'] === null) { + $safe = null; + } else if ($safe !== null) { + // only true if all items are marked as 'safe' + $safe &= $item['safe']; + } + } + + if ($this->isMultipleChoice()) { + if ($mc_mode == 1) { + $points = max($points - $malus, 0); + } else if ($mc_mode == 2 && $malus > 0) { + $points = 0; + } + } + + $percent = $points / max(count($results), 1); + + return ['percent' => $percent, 'safe' => $safe]; + } + + /** + * Return the default response when there is no existing solution. + */ + public function defaultResponse(): array + { + return array_fill(0, $this->itemCount(), ''); + } + + /** + * Return the response of the student from the request POST data. + * + * @param array $request array containing the postdata for the solution. + */ + public function responseFromRequest(array|ArrayAccess $request): array + { + $result = []; + + for ($i = 0; $i < $this->itemCount(); ++$i) { + $result[] = trim($request['answer'][$i] ?? ''); + } + + return $result; + } + + /** + * Export a response for this exercise into an array of strings. + */ + public function exportResponse(array $response): array + { + return array_values($response); + } + + /** + * Export this exercise to Vips XML format. + */ + public function getXMLTemplate(VipsAssignment $assignment): Flexi\Template + { + return $this->getViewTemplate('xml', null, $assignment, null); + } + + /** + * Exercise handler to be called when a solution is corrected. + */ + public function correctSolutionAction(Trails\Controller$controller, VipsSolution $solution): void + { + } + + /** + * Return a URL to a specified route in this exercise class. + * $params can contain optional additional parameters. + */ + public function url_for($path, $params = []): string + { + $params['exercise_id'] = $this->id; + + return URLHelper::getURL('dispatch.php/vips/sheets/relay/' . $path, $params); + } + + /** + * Return an encoded URL to a specified route in this exercise class. + * $params can contain optional additional parameters. + */ + public function link_for($path, $params = []): string + { + return htmlReady($this->url_for($path, $params)); + } + + /** + * Create a template for editing an exercise. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/edit'); + $template->exercise = $this; + + return $template; + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate( + string $view, + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + if ($assignment->isShuffled() && $user_id) { + $this->shuffleAnswers($user_id); + } + + $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/' . $view); + $template->exercise = $this; + $template->solution = $solution; + $template->response = $solution ? $solution->response : null; + $template->evaluation_mode = $assignment->options['evaluation_mode']; + + return $template; + } + + /** + * Return a template for solving an exercise. + */ + public function getSolveTemplate( + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + return $this->getViewTemplate('solve', $solution, $assignment, $user_id); + } + + /** + * Return a template for correcting an exercise. + */ + public function getCorrectionTemplate(VipsSolution $solution): Flexi\Template + { + return $this->getViewTemplate('correct', $solution, $solution->assignment, $solution->user_id); + } + + /** + * Return a template for printing an exercise. + */ + public function getPrintTemplate(VipsSolution $solution, VipsAssignment $assignment, ?string $user_id) + { + return $this->getViewTemplate('print', $solution, $assignment, $user_id); + } + + /** + * Get the name of this exercise type. + */ + public function getTypeName(): string + { + return self::$exercise_types[$this->type]['name']; + } + + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('question-circle', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return ''; + } + + /** + * Get the list of supported exercise types. + */ + public static function getExerciseTypes(): array + { + return self::$exercise_types; + } + + /** + * Register a new exercise type and class. + * + * @param class-string<static> $class + */ + public static function addExerciseType(string $name, string $class, mixed $type = null): void + { + self::$exercise_types[$class] = compact('name', 'type'); + } + + /** + * Return the list of keywords used for legacy text export. The first + * keyword in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return []; + } + + /** + * Import a new exercise from text data array. + */ + public static function importText(string $segment): static + { + $all_keywords = ['Tipp']; + + $types = []; + foreach (self::$exercise_types as $key => $value) { + $keywords = $key::getTextKeywords(); + + if ($keywords) { + $all_keywords = array_merge($all_keywords, $keywords); + $types[$key] = array_shift($keywords); + } + } + + $type = ''; + $pattern = implode('|', array_unique($all_keywords)); + $parts = preg_split("/\n($pattern):/", $segment, -1, PREG_SPLIT_DELIM_CAPTURE); + $title = array_shift($parts); + + $exercise = [['Name' => trim($title)]]; + + if ($parts) { + $type = array_shift($parts); + $text = array_shift($parts); + $text = preg_replace('/\\\\' . $type . '$/', '', trim($text)); + + $exercise[] = ['Type' => trim($type)]; + $exercise[] = ['Text' => trim($text)]; + } + + while ($parts) { + $tag = array_shift($parts); + $val = array_shift($parts); + $val = preg_replace('/\\\\' . $tag . '$/', '', trim($val)); + + $exercise[] = [$tag => trim($val)]; + } + + foreach ($types as $key => $value) { + if (preg_match('/^' . $value . '$/', $type)) { + $exercise_type = $key; + } + } + + if (!isset($exercise_type)) { + throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type); + } + + /** @var class-string<static> $exercise_type */ + $result = new $exercise_type(); + $result->initText($exercise); + return $result; + } + + /** + * Import a new exercise from Vips XML format. + */ + public static function importXML($exercise): static + { + $type = (string) $exercise->items->item[0]['type']; + + foreach (self::$exercise_types as $key => $value) { + if ($type === $value['type'] || is_array($value['type']) && in_array($type, $value['type'])) { + $exercise_type = $key; + } + } + + if (!isset($exercise_type)) { + throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type); + } + + if ( + $exercise_type === MultipleChoiceTask::class + && $exercise->items->item[0]->choices + ) { + $exercise_type = MatrixChoiceTask::class; + } + + /** @var class-string<static> $exercise_type */ + $result = new $exercise_type(); + $result->initXML($exercise); + return $result; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + foreach ($exercise as $tag) { + if (key($tag) === 'Name') { + $this->title = current($tag) ?: _('Aufgabe'); + } + + if (key($tag) === 'Text') { + $this->description = Studip\Markup::purifyHtml(current($tag)); + } + + if (key($tag) === 'Tipp') { + $this->options['hint'] = Studip\Markup::purifyHtml(current($tag)); + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + $this->title = trim($exercise->title); + + if ($this->title === '') { + $this->title = _('Aufgabe'); + } + + if ($exercise->description) { + $this->description = Studip\Markup::purifyHtml(trim($exercise->description)); + } + + if ($exercise->hint) { + $this->options['hint'] = Studip\Markup::purifyHtml(trim($exercise->hint)); + } + + if ($exercise['feedback'] == 'true') { + $this->options['comment'] = 1; + } + + if ($exercise->{'file-refs'}['hidden'] == 'true') { + $this->options['files_hidden'] = 1; + } + + if ($exercise->items->item[0]->feedback) { + $this->options['feedback'] = Studip\Markup::purifyHtml(trim($exercise->items->item[0]->feedback)); + } + } + + /** + * Construct a new solution object from the request post data. + */ + public function getSolutionFromRequest($request, ?array $files = null): VipsSolution + { + $solution = new VipsSolution(); + $solution->exercise = $this; + $solution->user_id = $GLOBALS['user']->id; + $solution->response = $this->responseFromRequest($request); + $solution->student_comment = trim($request['student_comment']); + + return $solution; + } + + /** + * Include files referenced by URL into the exercise attachments and + * rewrite all corresponding URLs in the exercise text. + */ + public function includeFilesForExport(): void + { + if (!$this->folder || count($this->folder->file_refs) === 0) { + $this->options['files_hidden'] = 1; + } + + $this->description = $this->rewriteLinksForExport($this->description); + $this->options['hint'] = $this->rewriteLinksForExport($this->options['hint']); + $this->task = $this->rewriteLinksForExport($this->task); + } + + /** + * Return a normalized version of a string + * + * @param string $string string to be normalized + * @param boolean $lowercase make string lower case + * @return string The normalized string + */ + protected function normalizeText(string $string, bool $lowercase = true): string + { + // remove leading/trailing spaces + $string = trim($string); + + // compress white space + $string = preg_replace('/\s+/u', ' ', $string); + + // delete blanks before and after [](){}:;,.!?"=<>^*/+- + $string = preg_replace('/ *([][(){}:;,.!?"=<>^*\/+-]) */', '$1', $string); + + // convert to lower case if requested + return $lowercase ? mb_strtolower($string) : $string; + } + + /** + * Return a normalized version of a float (and optionally a unit) + * + * @param string $string string to be normalized + * @param string $unit will contain the unit text + * @return float The normalized value + */ + protected function normalizeFloat(string $string, string &$unit): float + { + static $si_scale = [ + 'T' => 12, + 'G' => 9, + 'M' => 6, + 'k' => 3, + 'h' => 2, + 'd' => -1, + 'c' => -2, + 'm' => -3, + 'µ' => -6, + 'μ' => -6, + 'n' => -9, + 'p' => -12 + ]; + + // normalize representation + $string = $this->normalizeText($string, false); + $string = str_replace('*10^', 'e', $string); + $string = preg_replace_callback('/(\d+)\/(\d+)/', function($m) { return $m[1] / $m[2]; }, $string); + $string = strtr($string, ',', '.'); + + // split into value and unit + preg_match('/^([-+0-9.e]*)(.*)/', $string, $matches); + $value = (float) $matches[1]; + $unit = trim($matches[2]); + + if ($unit) { + $prefix = mb_substr($unit, 0, 1); + $letter = mb_substr($unit, 1, 1); + + if (ctype_alpha($letter) && isset($si_scale[$prefix])) { + $value *= pow(10, $si_scale[$prefix]); + $unit = mb_substr($unit, 1); + } + } + + return $value; + } + + /** + * UTF-8 compatible version of standard PHP levenshtein function. + */ + protected function levenshtein(string $string1, string $string2): int + { + $mb_str1 = preg_split('//u', $string1, null, PREG_SPLIT_NO_EMPTY); + $mb_str2 = preg_split('//u', $string2, null, PREG_SPLIT_NO_EMPTY); + + $mb_len1 = count($mb_str1); + $mb_len2 = count($mb_str2); + + $dist = []; + for ($i = 0; $i <= $mb_len1; ++$i) { + $dist[$i][0] = $i; + } + for ($j = 0; $j <= $mb_len2; ++$j) { + $dist[0][$j] = $j; + } + + for ($i = 1; $i <= $mb_len1; $i++) { + for ($j = 1; $j <= $mb_len2; $j++) { + $dist[$i][$j] = min( + $dist[$i-1][$j] + 1, + $dist[$i][$j-1] + 1, + $dist[$i-1][$j-1] + ($mb_str1[$i-1] !== $mb_str2[$j-1] ? 1 : 0) + ); + } + } + + return $dist[$mb_len1][$mb_len2]; + } + + /** + * Scan the given string or array (recursively) for referenced file URLs + * and rewrite those links into URNs suitable for XML export. + */ + protected function rewriteLinksForExport(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->rewriteLinksForExport($value); + } + } else if (is_string($data) && Studip\Markup::isHtml($data)) { + $data = preg_replace_callback('/"\Khttps?:[^"]*/', function($match) { + $url = html_entity_decode($match[0]); + $url = preg_replace( + '%/download/(?:normal|force_download)/\d/(\w+)/.+%', + '/sendfile.php?file_id=$1', + $url + ); + [$url, $query] = explode('?', $url); + + if (is_internal_url($url) && basename($url) === 'sendfile.php') { + parse_str($query, $query_params); + $file_id = $query_params['file_id']; + $file_ref = FileRef::find($file_id); + + if ($file_ref && $this->folder->file_refs->find($file_id)) { + return 'urn:vips:file-ref:file-' . $file_ref->file_id; + } + + if ($file_ref) { + $folder = $file_ref->folder->getTypedFolder(); + + if ($folder->isFileDownloadable($file_ref, $GLOBALS['user']->id)) { + if (!$this->folder->file_refs->find($file_id)) { + $file = $file_ref->file; + // $this->files->append($file); + } + + return 'urn:vips:file-ref:file-' . $file_id->file_id; + } + } + } + + return $match[0]; + }, $data); + } + + return $data; + } + + /** + * Calculate the size parameter for a flexible input element. + * + * @param string $text contents of the input + */ + public function flexibleInputSize(?string $text): string + { + return str_contains($text, "\n") || Studip\Markup::isHtml($text) ? 'large' : 'small'; + } + + /** + * Calculate the optimal textarea height for text exercises. + * + * @param string $text contents of textarea + * @return int height of textarea in lines + */ + public function textareaSize(?string $text): int + { + return max(substr_count($text, "\n") + 3, 5); + } +} diff --git a/lib/models/vips/MatchingTask.php b/lib/models/vips/MatchingTask.php new file mode 100644 index 0000000000000000000000000000000000000000..bb559e224b4946e5ba7dd30139899d60e702c1ff --- /dev/null +++ b/lib/models/vips/MatchingTask.php @@ -0,0 +1,341 @@ +<?php +/* + * MatchingTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class MatchingTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('view-list', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Zuordnung von Elementen zu Kategorien'); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (!isset($id)) { + $this->task['groups'] = []; + } + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + $id = $request['id']; + $_id = $request['_id']; + + $this->task['groups'] = []; + $this->task['select'] = $request['multiple'] ? 'multiple' : 'single'; + + foreach ($request['default'] as $i => $group) { + $group = self::purifyFlexibleInput($group); + $answers = (array) $request['answer'][$i]; + + if (trim($group) != '') { + foreach ($answers as $j => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'id' => (int) $id[$i][$j], + 'text' => trim($answer), + 'group' => count($this->task['groups']) + ]; + } + } + + $this->task['groups'][] = trim($group); + } + } + + // list of answers that must remain unassigned + foreach ($request['_answer'] as $i => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'id' => (int) $_id[$i], + 'text' => trim($answer), + 'group' => -1 + ]; + } + } + + $this->createIds(); + } + + /** + * Genereate new IDs for all answers that do not yet have one. + */ + public function createIds(): void + { + $ids = [0 => true]; + + foreach ($this->task['answers'] as $i => &$answer) { + if (empty($answer['id'])) { + do { + $answer['id'] = rand(); + } while (isset($ids[$answer['id']])); + } + + $ids[$answer['id']] = true; + } + } + + /** + * Check if multiple assignment mode is enabled for this exercise. + */ + public function isMultiSelect(): bool + { + return isset($this->task['select']) && $this->task['select'] === 'multiple'; + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers. + */ + public function itemCount(): int + { + return count($this->task['answers']) - count($this->correctAnswers(-1)); + } + + /** + * Sort the list of answers by their ids. + */ + public function sortAnswersById(): void + { + usort( + $this->task['answers'], + fn($a, $b) => $a['id'] <=> $b['id'] + ); + } + + /** + * Returns all the correct answers for the given group. + */ + public function correctAnswers(string $group): array + { + $answers = []; + + foreach ($this->task['answers'] as $answer) { + if ($answer['group'] == $group) { + $answers[] = $answer['text']; + } + } + + return $answers; + } + + /** + * Check if this answer is a correct assignment to the given group. + */ + public function isCorrectAnswer(array $answer, string $group): bool + { + if ($answer['group'] == $group) { + return true; + } + + foreach ($this->task['answers'] as $_answer) { + if ($_answer['group'] == $group) { + if ($answer['text'] === $_answer['text']) { + return true; + } + } + } + + return false; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $item_count = $this->itemCount(); + + foreach ($this->task['answers'] as $answer) { + $group = $response[$answer['id']] ?? -1; + + if ($group != -1) { + $points = $this->isCorrectAnswer($answer, $group) ? 1 : 0; + $result[] = ['points' => $points, 'safe' => true]; + } + } + + // assign no points for missing answers + while (count($result) < $item_count) { + $result[] = ['points' => 0, 'safe' => true]; + } + + return $result; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['ZU-Frage', 'Vorgabe', 'Antwort', 'Distraktor']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + foreach ($exercise as $tag) { + if (key($tag) === 'Vorgabe') { + $group = count($this->task['groups']); + $this->task['groups'][] = Studip\Markup::purifyHtml(current($tag)); + } + + if (key($tag) === 'Antwort' && isset($group)) { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'group' => $group + ]; + unset($group); + } + + if (key($tag) === 'Distraktor') { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'group' => -1 + ]; + } + } + + $this->createIds(); + } + + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + $this->task['select'] = $exercise->items->item['type'] == 'matching-multiple' ? 'multiple' : 'single'; + + foreach ($exercise->items->item->choices->choice as $choice) { + $this->task['groups'][] = Studip\Markup::purifyHtml(trim($choice)); + } + + foreach ($exercise->items->item->answers->answer as $answer) { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)), + 'group' => (int) $answer['correct'] + ]; + } + + $this->createIds(); + } + + + + /** + * Creates a template for editing a MatchingTask. + */ + public function getEditTemplate(?VipsAssignment $assignment): \Flexi\Template + { + if (!$this->task['answers']) { + foreach (range(0, 4) as $i) { + $this->task['answers'][] = ['id' => '', 'text' => '', 'group' => count($this->task['groups'])]; + $this->task['groups'][] = ''; + } + } + + return parent::getEditTemplate($assignment); + } + + /** + * Return the solution of the student from the request POST data. + * + * @param array $request array containing the postdata for the solution. + * @return array containing the solutions of the student. + */ + public function responseFromRequest(array|ArrayAccess $request): array + { + $result = []; + + foreach ($this->task['answers'] as $answer) { + // get the group the user has added this answer to + $result[$answer['id']] = (int) $request['answer'][$answer['id']]; + } + + return $result; + } + + /** + * Export a response for this exercise into an array of strings. + */ + public function exportResponse(array $response): array + { + $result = []; + + foreach ($this->task['answers'] as $answer) { + if ($answer['group'] != -1) { + if (isset($response[$answer['id']]) && $response[$answer['id']] != -1) { + $result[] = $this->task['groups'][$response[$answer['id']]]; + } else { + $result[] = ''; + } + } + } + + return $result; + } +} diff --git a/lib/models/vips/MatrixChoiceTask.php b/lib/models/vips/MatrixChoiceTask.php new file mode 100644 index 0000000000000000000000000000000000000000..1ba2d900cf168673cc4276182097d3090f26b265 --- /dev/null +++ b/lib/models/vips/MatrixChoiceTask.php @@ -0,0 +1,268 @@ +<?php +/* + * MatrixChoiceTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class MatrixChoiceTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('timetable', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Einfachauswahl pro Zeile in einer Tabelle'); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (!isset($id)) { + $this->task['choices'] = []; + } + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + $this->task['choices'] = []; + $choice_index = []; + + foreach ($request['choice'] as $i => $choice) { + if (trim($choice) != '') { + $this->task['choices'][] = trim($choice); + $choice_index[$i] = count($choice_index); + } + } + + foreach ($request['answer'] as $i => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'text' => trim($answer), + 'choice' => $choice_index[$request['correct'][$i]] + ]; + } + } + + if ($request['optional']) { + $this->options['optional'] = 1; + } + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers. + */ + public function itemCount(): int + { + return count($this->task['answers']); + } + + /** + * Shuffle the answer alternatives. + * + * @param $user_id string used for initialising the randomizer. + */ + public function shuffleAnswers(string $user_id): void + { + srand(crc32($this->id . ':' . $user_id)); + + $random_order = range(0, $this->itemCount() - 1); + shuffle($random_order); + + $answer_temp = []; + foreach ($random_order as $index) { + $answer_temp[$index] = $this->task['answers'][$index]; + } + $this->task['answers'] = $answer_temp; + + srand(); + } + + /** + * Returns true if this exercise type is considered as multiple choice. + * In this case, the evaluation mode set on the assignment is applied. + */ + public function isMultipleChoice(): bool + { + return true; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + + foreach ($this->task['answers'] as $i => $answer) { + if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) { + $points = null; + } else { + $points = $response[$i] == $answer['choice'] ? 1 : 0; + } + + $result[] = ['points' => $points, 'safe' => true]; + } + + return $result; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['MCO-Frage', 'Auswahl', '[+~]?Antwort']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + foreach ($exercise as $tag) { + if (key($tag) === '+Antwort') { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'choice' => 0 + ]; + } else if (key($tag) === 'Antwort') { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'choice' => 1 + ]; + } + } + + foreach ($exercise as $tag) { + if (key($tag) === 'Auswahl') { + [$label_yes, $label_no] = explode('/', current($tag)); + $this->task['choices'] = [trim($label_yes), trim($label_no)]; + } + } + + $this->options['optional'] = 1; + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item->answers->answer as $answer) { + if (isset($answer['correct'])) { + $choice = (int) $answer['correct']; + } else { + $choice = (int) $answer['score'] ? 0 : 1; + } + + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)), + 'choice' => $choice + ]; + } + + foreach ($exercise->items->item->choices->choice as $choice) { + if ($choice['type'] == 'none') { + $this->options['optional'] = 1; + } else { + $this->task['choices'][] = trim($choice); + } + } + } + + /** + * Creates a template for editing an exercise. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + if (!$this->task['choices']) { + $this->task['choices'] = [_('Ja'), _('Nein')]; + } + + if (!$this->task['answers']) { + $this->task['answers'] = array_fill(0, 5, ['text' => '', 'choice' => 0]); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate($view, $solution, $assignment, $user_id): Flexi\Template + { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if (isset($this->options['optional']) && $this->options['optional']) { + $template->optional_choice = [-1 => _('keine Antwort')]; + } else { + $template->optional_choice = []; + } + + return $template; + } + + /** + * Export a response for this exercise into an array of strings. + */ + public function exportResponse(array $response): array + { + return array_map( + fn($a) => $a == -1 ? '' : $a, + $response + ); + } +} diff --git a/lib/models/vips/MultipleChoiceTask.php b/lib/models/vips/MultipleChoiceTask.php new file mode 100644 index 0000000000000000000000000000000000000000..68470ef467c9a5c2daad4870d60802552a49e0f9 --- /dev/null +++ b/lib/models/vips/MultipleChoiceTask.php @@ -0,0 +1,196 @@ +<?php +/* + * MultipleChoiceTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class MultipleChoiceTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('assessment-mc', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Mehrfachauswahl aus einer Liste'); + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + foreach ($request['answer'] as $i => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'text' => trim($answer), + 'score' => (int) $request['correct'][$i] + ]; + } + } + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers. + */ + public function itemCount(): int + { + return count($this->task['answers']); + } + + /** + * Return the default response when there is no existing solution. + */ + public function defaultResponse(): array + { + return []; + } + + /** + * Shuffle the answer alternatives. + * + * @param $user_id string used for initialising the randomizer. + */ + public function shuffleAnswers(string $user_id): void + { + srand(crc32($this->id . ':' . $user_id)); + + $random_order = range(0, $this->itemCount() - 1); + shuffle($random_order); + + $answer_temp = []; + foreach ($random_order as $index) { + $answer_temp[$index] = $this->task['answers'][$index]; + } + $this->task['answers'] = $answer_temp; + + srand(); + } + + /** + * Returns true if this exercise type is considered as multiple choice. + * In this case, the evaluation mode set on the assignment is applied. + */ + public function isMultipleChoice(): bool + { + return true; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + + foreach ($this->task['answers'] as $i => $answer) { + if (!isset($response[$i])) { + $points = null; + } else { + $points = (int) $response[$i] == $answer['score'] ? 1 : 0; + } + + $result[] = ['points' => $points, 'safe' => true]; + } + + return $result; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['MC-Frage', '[+~]?Antwort']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + foreach ($exercise as $tag) { + if (key($tag) === '+Antwort') { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'score' => 1 + ]; + } else if (key($tag) === 'Antwort') { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'score' => 0 + ]; + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item->answers->answer as $answer) { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)), + 'score' => (int) $answer['score'] + ]; + } + } + + /** + * Creates a template for editing a MultipleChoiceTask. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + if (!$this->task['answers']) { + $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]); + } + + return parent::getEditTemplate($assignment); + } +} diff --git a/lib/models/vips/SequenceTask.php b/lib/models/vips/SequenceTask.php new file mode 100644 index 0000000000000000000000000000000000000000..696fe6ac2218de2cf09670a4eb7acb3b905bb4ea --- /dev/null +++ b/lib/models/vips/SequenceTask.php @@ -0,0 +1,255 @@ +<?php +/* + * SequenceTask.php - Vips plugin for Stud.IP + * Copyright (c) 2022 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class SequenceTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('hamburger', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Anordnung von Elementen in einer Reihe'); + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + foreach ($request['answer'] as $i => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'id' => (int) $request['id'][$i], + 'text' => trim($answer) + ]; + } + } + + $this->task['compare'] = $request['compare']; + + $this->createIds(); + } + + /** + * Genereate new IDs for all answers that do not yet have one. + */ + public function createIds(): void + { + $ids = [0 => true]; + + foreach ($this->task['answers'] as $i => &$answer) { + if (empty($answer['id'])) { + do { + $answer['id'] = rand(); + } while (isset($ids[$answer['id']])); + } + + $ids[$answer['id']] = true; + } + } + + /** + * Compute the default maximum points which can be reached in this + * exercise, dependent on the number of answers. + */ + public function itemCount(): int + { + if ($this->task['compare'] === 'sequence') { + return max(count($this->task['answers']) - 1, 0); + } + + return count($this->task['answers']); + } + + /** + * Return the list of answers as ordered by the student (if applicable). + */ + public function orderedAnswers($response) + { + $answers = $this->task['answers']; + $pos = isset($response) ? array_flip($response) : []; + + usort($answers, function($a, $b) use ($pos) { + if (isset($pos[$a['id']]) && isset($pos[$b['id']])) { + return $pos[$a['id']] <=> $pos[$b['id']]; + } else if (isset($pos[$a['id']])) { + return -1; + } else if (isset($pos[$b['id']])) { + return 1; + } else { + return $a['id'] <=> $b['id']; + } + }); + + return $answers; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution object returned by getSolutionFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $item_count = $this->itemCount(); + $answers = $this->task['answers']; + $pos = array_flip($response); + + for ($i = 0; $i < $item_count; ++$i) { + if ($this->task['compare'] === 'sequence') { + if ($pos[$answers[$i]['id']] + 1 == $pos[$answers[$i + 1]['id']]) { + $points = 1; + } else { + $points = 0; + } + } else { + if ($pos[$answers[$i]['id']] == $i) { + $points = 1; + } else { + $points = 0; + } + } + + if (!$this->task['compare'] && count($result)) { + $result[0]['points'] &= $points; + } else { + $result[] = ['points' => $points, 'safe' => true]; + } + } + + return $result; + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item->answers->answer as $answer) { + $this->task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)) + ]; + } + + if ($exercise->items->item->{'evaluation-hints'}) { + switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) { + case 'position': + case 'sequence': + $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type']; + } + } + + $this->createIds(); + } + + + + /** + * Creates a template for editing a SequenceTask. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + if (!$this->task['answers']) { + $this->task['answers'] = array_fill(0, 5, ['id' => '', 'text' => '']); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate( + string $view, + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if ($solution && $solution->id) { + $template->results = $this->evaluateItems($solution); + } + + return $template; + } + + /** + * Return the solution of the student from the request POST data. + * + * @param array $request array containing the postdata for the solution. + * @return array containing the solutions of the student. + */ + public function responseFromRequest(array|ArrayAccess $request): array + { + $result = []; + + foreach ($request['answer'] as $id) { + $result[] = (int) $id; + } + + return $result; + } + + /** + * Export a response for this exercise into an array of strings. + */ + public function exportResponse(array $response): array + { + $result = []; + + foreach ($response as $id) { + foreach ($this->task['answers'] as $answer) { + if ($answer['id'] === $id) { + $result[] = $answer['text']; + } + } + } + + return $result; + } +} diff --git a/lib/models/vips/SingleChoiceTask.php b/lib/models/vips/SingleChoiceTask.php new file mode 100644 index 0000000000000000000000000000000000000000..4029a6502dfa1ba3a6ea2122806dfe3f4243cf06 --- /dev/null +++ b/lib/models/vips/SingleChoiceTask.php @@ -0,0 +1,279 @@ +<?php +/* + * SingleChoiceTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class SingleChoiceTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('assessment', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Einfachauswahl aus einer Liste'); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (!isset($id)) { + $this->task = []; + } + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + $this->task = []; + + foreach ($request['answer'] as $group => $answergroup) { + $task = []; + $description = trim($request['description'][$group]); + $description = Studip\Markup::purifyHtml($description); + + if ($this->task && $description != '') { + $task['description'] = $description; + } + + foreach ($answergroup as $i => $answer) { + $answer = self::purifyFlexibleInput($answer); + + if (trim($answer) != '') { + $task['answers'][] = [ + 'text' => trim($answer), + 'score' => $request['correct'][$group] == $i ? 1 : 0 + ]; + } + } + + if ($task) { + $this->task[] = $task; + } + } + + if ($request['optional']) { + $this->options['optional'] = 1; + } + } + + /** + * Computes the default maximum points which can be reached in this + * exercise, dependent on the number of groups. + * + * @return int maximum points + */ + public function itemCount(): int + { + return count($this->task); + } + + /** + * Shuffle the answer alternatives. + * + * @param $user_id string used for initialising the randomizer. + */ + public function shuffleAnswers(string $user_id): void + { + srand(crc32($this->id . ':' . $user_id)); + + for ($block = 0; $block < count($this->task); $block++) { + $random_order = range(0, count($this->task[$block]['answers']) - 1); + shuffle($random_order); + + $answer_temp = []; + foreach ($random_order as $index) { + $answer_temp[$index] = $this->task[$block]['answers'][$index]; + } + $this->task[$block]['answers'] = $answer_temp; + } + + srand(); + } + + /** + * Returns true if this exercise type is considered as multiple choice. + * In this case, the evaluation mode set on the assignment is applied. + */ + public function isMultipleChoice(): bool + { + return true; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + + foreach ($this->task as $i => $task) { + if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) { + $points = null; + } else { + $points = $task['answers'][$response[$i]]['score']; + } + + $result[] = ['points' => $points, 'safe' => true]; + } + + return $result; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['SCO?-Frage|JN-Frage', '[+~]?Antwort']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + $block = 0; + + foreach ($exercise as $tag) { + if (key($tag) === 'Type' && current($tag) === 'SCO-Frage') { + $this->options['optional'] = 1; + } + + if (key($tag) === '+Antwort' || key($tag) === 'Antwort') { + if (preg_match('/\n--$/', current($tag))) { + $text = trim(substr(current($tag), 0, -3)); + $incr = 1; + } else { + $text = current($tag); + $incr = 0; + } + + $score = key($tag) === '+Antwort' ? 1 : 0; + + $this->task[$block]['answers'][] = [ + 'text' => Studip\Markup::purifyHtml($text), + 'score' => $score + ]; + + $block += $incr; + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item as $item) { + $task = []; + + if ($item->description) { + $task['description'] = Studip\Markup::purifyHtml(trim($item->description->text)); + } + + foreach ($item->answers->answer as $answer) { + if ($answer['default'] == 'true') { + $this->options['optional'] = 1; + } else { + $task['answers'][] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)), + 'score' => (int) $answer['score'] + ]; + } + } + + $this->task[] = $task; + } + } + + /** + * Creates a template for editing a SingleChoiceTask. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + if (!$this->task) { + $this->task[0]['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate( + string $view, + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if (isset($this->options['optional']) && $this->options['optional']) { + $template->optional_answer = [-1 => ['text' => _('keine Antwort'), 'score' => 0]]; + } else { + $template->optional_answer = []; + } + + return $template; + } + + /** + * Export a response for this exercise into an array of strings. + */ + public function exportResponse(array $response): array + { + return array_map(function($a) { return $a == -1 ? '' : $a; }, $response); + } +} diff --git a/lib/models/vips/TextLineTask.php b/lib/models/vips/TextLineTask.php new file mode 100644 index 0000000000000000000000000000000000000000..4a2e7d255d9eea984016686bc546c3eb2b4c751a --- /dev/null +++ b/lib/models/vips/TextLineTask.php @@ -0,0 +1,271 @@ +<?php +/* + * TextLineTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2011 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class TextLineTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('edit-line', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Kurze einzeilige Textantwort'); + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + foreach ($request['answer'] as $i => $answer) { + if (trim($answer) != '') { + $this->task['answers'][] = [ + 'text' => trim($answer), + 'score' => (float) $request['correct'][$i] + ]; + } + } + + $this->task['compare'] = $request['compare']; + + if ($this->task['compare'] === 'numeric') { + $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100; + } + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $studentSolution = $response[0]; + + $similarity = 0; + $safe = false; + $studentSolution = $this->normalizeText($studentSolution, true); + + if ($studentSolution === '') { + $result[] = ['points' => 0, 'safe' => true]; + return $result; + } + + foreach ($this->task['answers'] as $answer) { + $musterLoesung = $this->normalizeText($answer['text'], true); + $similarity_temp = 0; + + if ($musterLoesung === $studentSolution) { + $similarity_temp = 1; + } else if ($this->task['compare'] === 'levenshtein') { // Levenshtein-Distanz + $string1 = mb_substr($studentSolution, 0, 255); + $string2 = mb_substr($musterLoesung, 0, 255); + $divisor = max(mb_strlen($string1), mb_strlen($string2)); + + $levenshtein = $this->levenshtein($string1, $string2) / $divisor; + $similarity_temp = 1 - $levenshtein; + } else if ($this->task['compare'] === 'soundex') { // Soundex-Aussprache + $levenshtein = levenshtein(soundex($musterLoesung), soundex($studentSolution)); + + if ($levenshtein == 0) { + $similarity_temp = 0.8; + } else if ($levenshtein == 1) { + $similarity_temp = 0.6; + } else if ($levenshtein == 2) { + $similarity_temp = 0.4; + } else if ($levenshtein == 3) { + $similarity_temp = 0.2; + } else {// $levenshtein == 4 + $similarity_temp = 0; + } + } else if ($this->task['compare'] === 'numeric') { + $correct = $this->normalizeFloat($answer['text'], $correct_unit); + $student = $this->normalizeFloat($response[0], $student_unit); + + if ($correct_unit === $student_unit) { + if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) { + $similarity_temp = 1; + } else { + $safe = true; + } + } + } + + if ($answer['score'] == 1) { // correct + if ($similarity_temp > $similarity) { + $similarity = $similarity_temp; + $safe = $similarity_temp == 1; + } + } else if ($answer['score'] == 0.5) { // half correct + if ($similarity_temp > $similarity) { + $similarity = $similarity_temp * 0.5; + $safe = $similarity_temp == 1; + } + } else if ($similarity_temp == 1) { // false + $similarity = 0; + $safe = true; + break; + } + } + + $result[] = ['points' => $similarity, 'safe' => $safe]; + + return $result; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['Frage', 'Eingabehilfe', 'Abgleich', '[+~]?Antwort']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + foreach ($exercise as $tag) { + if (key($tag) === 'Abgleich') { + if (current($tag) === 'Levenshtein') { + $this->task['compare'] = 'levenshtein'; + } else if (current($tag) === 'Soundex') { + $this->task['compare'] = 'soundex'; + } + } + + if (key($tag) === '+Antwort') { + $this->task['answers'][] = [ + 'text' => current($tag), + 'score' => 1 + ]; + } else if (key($tag) === '~Antwort') { + $this->task['answers'][] = [ + 'text' => current($tag), + 'score' => 0.5 + ]; + } else if (key($tag) === 'Antwort') { + $this->task['answers'][] = [ + 'text' => current($tag), + 'score' => 0 + ]; + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item->answers->answer as $answer) { + $this->task['answers'][] = [ + 'text' => trim($answer), + 'score' => (float) $answer['score'] + ]; + } + + if ($exercise->items->item->{'evaluation-hints'}) { + switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) { + case 'levenshtein': + case 'soundex': + $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type']; + break; + case 'numeric': + $this->task['compare'] = 'numeric'; + $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'}; + } + } + } + + /** + * Creates a template for editing a TextLineTask. + */ + public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template + { + if (!$this->task['answers']) { + $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate( + string $view, + ?VipsSolution $solution, + VipsAssignment $assignment, + ?string $user_id + ): Flexi\Template { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if ($solution && $solution->id) { + $template->results = $this->evaluateItems($solution); + } + + return $template; + } + + /** + * Returns all the correct answers in an array. + */ + public function correctAnswers(): array + { + $answers = []; + + foreach ($this->task['answers'] as $answer) { + if ($answer['score'] == 1) { + $answers[] = $answer['text']; + } + } + + return $answers; + } +} diff --git a/lib/models/vips/TextTask.php b/lib/models/vips/TextTask.php new file mode 100644 index 0000000000000000000000000000000000000000..5684195f72bd9cdda88415265045a71ed3cbbe92 --- /dev/null +++ b/lib/models/vips/TextTask.php @@ -0,0 +1,279 @@ +<?php +/* + * TextTask.php - Vips plugin for Stud.IP + * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $type database column + * @property string $title database column + * @property string $description database column + * @property string $task database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest + */ +class TextTask extends Exercise +{ + /** + * Get the icon of this exercise type. + */ + public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + return Icon::create('edit', $role); + } + + /** + * Get a description of this exercise type. + */ + public static function getTypeDescription(): string + { + return _('Mehrzeilige Textantwort oder Dateiabgabe'); + } + + /** + * Initialize this instance from the current request environment. + */ + public function initFromRequest($request): void + { + parent::initFromRequest($request); + + $this->task['answers'][0] = [ + 'text' => Studip\Markup::purifyHtml(trim($request['answer_0'])), + 'score' => 1 + ]; + + $this->task['template'] = trim($request['answer_default']); + $this->task['compare'] = $request['compare']; + + if ($request['layout']) { + $this->task['layout'] = $request['layout']; + } + + if ($request['layout'] === 'markup') { + $this->task['template'] = Studip\Markup::purifyHtml($this->task['template']); + } + + if ($request['file_upload'] || $request['layout'] === 'none') { + $this->options['file_upload'] = 1; + } + } + + /** + * Exercise handler to be called when a solution is corrected. + */ + public function correctSolutionAction(Trails\Controller $controller, VipsSolution $solution): void + { + $commented_solution = Request::get('commented_solution'); + + if (isset($commented_solution)) { + $solution->commented_solution = Studip\Markup::purifyHtml(trim($commented_solution)); + } else { + $solution->commented_solution = null; + } + + if (Request::submitted('delete_commented_solution')) { + $solution->commented_solution = null; + $solution->store(); + + PageLayout::postSuccess(_('Die kommentierte Lösung wurde gelöscht.')); + } + } + + /** + * Return the layout of this task (text, markup, code or none). + */ + public function getLayout(): string + { + return $this->task['layout'] ?? 'text'; + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $answerDefault = Studip\Markup::removeHtml($this->task['template']); + $musterLoesung = Studip\Markup::removeHtml($this->task['answers'][0]['text']); + $studentSolution = Studip\Markup::removeHtml($solution->response[0]); + + $answerDefault = $this->normalizeText($answerDefault, true); + $studentSolution = $this->normalizeText($studentSolution, true); + $musterLoesung = $this->normalizeText($musterLoesung, true); + + if ($studentSolution == '' || $studentSolution == $answerDefault) { + $has_files = $solution->folder && count($solution->folder->file_refs); + $result[] = ['points' => 0, 'safe' => !$has_files ? true : null]; + } else if ($musterLoesung == $studentSolution) { + $result[] = ['points' => 1, 'safe' => true]; + } else if ($this->task['compare'] === 'levenshtein') { + $string1 = mb_substr($studentSolution, 0, 500); + $string2 = mb_substr($musterLoesung, 0, 500); + $string3 = mb_substr($answerDefault, 0, 500); + $divisor = $this->levenshtein($string3, $string2) ?: 1; + + $levenshtein = $this->levenshtein($string1, $string2) / $divisor; + $similarity = max(1 - $levenshtein, 0); + $result[] = ['points' => $similarity, 'safe' => false]; + } else { + $result[] = ['points' => 0, 'safe' => null]; + } + + return $result; + } + + /** + * Return the default response when there is no existing solution. + */ + public function defaultResponse(): array + { + return [$this->task['template']]; + } + + /** + * Return the solution of the student from the request POST data. + * + * @param array $request array containing the postdata for the solution. + * @return array containing the solutions of the student. + */ + public function responseFromRequest(array|ArrayAccess $request): array + { + $result = parent::responseFromRequest($request); + + if ($this->getLayout() === 'markup') { + $result = array_map('Studip\Markup::purifyHtml', $result); + } + + return $result; + } + + /** + * Construct a new solution object from the request post data. + */ + public function getSolutionFromRequest($request, ?array $files = null): VipsSolution + { + $solution = parent::getSolutionFromRequest($request, $files); + $upload = $files['upload'] ?: ['name' => []]; + $solution_files = []; + + if ($this->options['file_upload']) { + if ($files['upload']) { + $solution->options['upload'] = $files['upload']; + } + + $solution->store(); + $folder = Folder::findTopFolder($solution->id, 'ResponseFolder', 'response'); + + if (is_array($request['file_ids'])) { + foreach ($request['file_ids'] as $file_id) { + $file_ref = FileRef::find($file_id); + FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent()); + } + } + + FileManager::handleFileUpload($upload, $folder->getTypedFolder()); + } + + return $solution; + } + + /** + * Return the list of keywords used for text export. The first keyword + * in the list must be the keyword for the exercise type. + */ + public static function getTextKeywords(): array + { + return ['Offene Frage', 'Eingabehilfe', 'Abgleich', 'Vorgabe', 'Antwort']; + } + + /** + * Initialize this instance from the given text data array. + */ + public function initText(array $exercise): void + { + parent::initText($exercise); + + foreach ($exercise as $tag) { + if (key($tag) === 'Abgleich') { + if (current($tag) === 'Levenshtein') { + $this->task['compare'] = 'levenshtein'; + } + } + + if (key($tag) === 'Vorgabe') { + $this->task['template'] = Studip\Markup::purifyHtml(current($tag)); + } + + if (key($tag) === 'Antwort') { + $this->task['answers'][0] = [ + 'text' => Studip\Markup::purifyHtml(current($tag)), + 'score' => 1 + ]; + } + } + } + + /** + * Initialize this instance from the given SimpleXMLElement object. + */ + public function initXML($exercise): void + { + parent::initXML($exercise); + + foreach ($exercise->items->item->answers->answer as $answer) { + if ($answer['score'] == '1') { + $this->task['answers'][0] = [ + 'text' => Studip\Markup::purifyHtml(trim($answer)), + 'score' => 1 + ]; + } else if ($answer['default'] == 'true') { + $this->task['template'] = Studip\Markup::purifyHtml(trim($answer)); + } + } + + if ($exercise->items->item->{'evaluation-hints'}) { + switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) { + case 'levenshtein': + $this->task['compare'] = 'levenshtein'; + } + } + + if ($exercise->items->item->{'submission-hints'}->input) { + switch ($exercise->items->item->{'submission-hints'}->input['type']) { + case 'markup': + $this->task['layout'] = 'markup'; + break; + case 'code': + $this->task['layout'] = 'code'; + break; + case 'none': + $this->task['layout'] = 'none'; + } + } + + if ($exercise->items->item->{'submission-hints'}->attachments) { + if ($exercise->items->item->{'submission-hints'}->attachments['upload'] == 'true') { + $this->options['file_upload'] = 1; + } + } + } +} diff --git a/lib/models/vips/VipsAssignment.php b/lib/models/vips/VipsAssignment.php new file mode 100644 index 0000000000000000000000000000000000000000..d73d62aa72f36d3f1e07734f4d49207696e7e67b --- /dev/null +++ b/lib/models/vips/VipsAssignment.php @@ -0,0 +1,1308 @@ +<?php +/* + * VipsAssignment.php - Vips test class for Stud.IP + * Copyright (c) 2014 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property int $test_id database column + * @property string|null $range_type database column + * @property string|null $range_id database column + * @property string $type database column + * @property int|null $start database column + * @property int|null $end database column + * @property int $active database column + * @property float $weight database column + * @property int|null $block_id database column + * @property JSONArrayObject $options database column + * @property int|null $mkdate database column + * @property int|null $chdate database column + * @property SimpleORMapCollection|VipsAssignmentAttempt[] $assignment_attempts has_many VipsAssignmentAttempt + * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution + * @property Course|null $course belongs_to Course + * @property VipsBlock|null $block belongs_to VipsBlock + * @property VipsTest $test belongs_to VipsTest + */ +class VipsAssignment extends SimpleORMap +{ + public const RELEASE_STATUS_NONE = 0; + public const RELEASE_STATUS_POINTS = 1; + public const RELEASE_STATUS_COMMENTS = 2; + public const RELEASE_STATUS_CORRECTIONS = 3; + public const RELEASE_STATUS_SAMPLE_SOLUTIONS = 4; + + public const SCORING_DEFAULT = 0; + public const SCORING_NEGATIVE_POINTS = 1; + public const SCORING_ALL_OR_NOTHING = 2; + + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_assignments'; + + $config['serialized_fields']['options'] = JSONArrayObject::class; + + $config['has_many']['assignment_attempts'] = [ + 'class_name' => VipsAssignmentAttempt::class, + 'assoc_foreign_key' => 'assignment_id' + ]; + $config['has_many']['solutions'] = [ + 'class_name' => VipsSolution::class, + 'assoc_foreign_key' => 'assignment_id' + ]; + + $config['belongs_to']['course'] = [ + 'class_name' => Course::class, + 'foreign_key' => 'range_id' + ]; + $config['belongs_to']['block'] = [ + 'class_name' => VipsBlock::class, + 'foreign_key' => 'block_id' + ]; + $config['belongs_to']['test'] = [ + 'class_name' => VipsTest::class, + 'foreign_key' => 'test_id' + ]; + + parent::configure($config); + } + + /** + * Initialize a new instance of this class. + */ + public function __construct($id = null) + { + parent::__construct($id); + + if (is_null($this->options)) { + $this->options = []; + } + } + + /** + * Delete entry from the database. + */ + public function delete() + { + $gradebook_id = $this->options['gradebook_id']; + + if ($gradebook_id) { + Grading\Definition::deleteBySQL('id = ?', [$gradebook_id]); + } + + VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$this->id]); + + $ref_count = self::countBySql('test_id = ?', [$this->test_id]); + + if ($ref_count === 1) { + $this->test->delete(); + } + + return parent::delete(); + } + + /** + * Find all assignments for a given range_id. + * + * @return VipsAssignment[] + */ + public static function findByRangeId($range_id) + { + return VipsAssignment::findBySQL( + 'range_id = ? AND type IN (?) ORDER BY start', + [$range_id, ['exam', 'practice', 'selftest']] + ); + } + + public static function importText( + string $title, + string $string, + string $user_id, + string $course_id + ): VipsAssignment { + $duration = 7 * 24 * 60 * 60; // one week + + $data_test = [ + 'title' => $title !== '' ? $title : _('Aufgabenblatt'), + 'description' => '', + 'user_id' => $user_id + ]; + $data = [ + 'type' => 'practice', + 'range_id' => $course_id ?: $user_id, + 'range_type' => $course_id ? 'course' : 'user', + 'start' => strtotime(date('Y-m-d H:00:00')), + 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration)) + ]; + + // remove comments + $string = preg_replace('/^#.*/m', '', $string); + + // split into exercises + $segments = preg_split('/^Name:/m', $string); + array_shift($segments); + + $test_obj = VipsTest::create($data_test); + + $result = self::build($data); + $result->test = $test_obj; + $result->store(); + + foreach ($segments as $segment) { + try { + $new_exercise = Exercise::importText($segment); + $new_exercise->user_id = $user_id; + $new_exercise->store(); + $test_obj->addExercise($new_exercise); + } catch (Exception $e) { + $errors[] = $e->getMessage(); + } + } + + if (isset($errors)) { + PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors); + } + + return $result; + } + + public static function importXML( + string $string, + string $user_id, + string $course_id + ): VipsAssignment { + // default options + $options = [ + 'evaluation_mode' => 0, + 'released' => 0 + ]; + + $duration = 7 * 24 * 60 * 60; // one week + + $data_test = [ + 'title' => _('Aufgabenblatt'), + 'description' => '', + 'user_id' => $user_id + ]; + $data = [ + 'type' => 'practice', + 'range_id' => $course_id ?: $user_id, + 'range_type' => $course_id ? 'course' : 'user', + 'start' => strtotime(date('Y-m-d H:00:00')), + 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration)), + 'options' => $options + ]; + + $test = new SimpleXMLElement($string, LIBXML_COMPACT | LIBXML_NOCDATA); + $data['type'] = (string) $test['type']; + + if (trim($test->title) !== '') { + $data_test['title'] = trim($test->title); + } + if ($test->description) { + $data_test['description'] = Studip\Markup::purifyHtml(trim($test->description)); + } + if ($test->notes) { + $data['options']['notes'] = trim($test->notes); + } + + if ($test->limit['access-code']) { + $data['options']['access_code'] = (string) $test->limit['access-code']; + } + if ($test->limit['ip-ranges']) { + $data['options']['ip_range'] = (string) $test->limit['ip-ranges']; + } + if ($test->limit['resets']) { + $data['options']['resets'] = (int) $test->limit['resets']; + } + if ($test->limit['tries']) { + $data['options']['max_tries'] = (int) $test->limit['tries']; + } + + if ($test->option['scoring-mode'] == 'negative_points') { + $data['options']['evaluation_mode'] = self::SCORING_NEGATIVE_POINTS; + } else if ($test->option['scoring-mode'] == 'all_or_nothing') { + $data['options']['evaluation_mode'] = self::SCORING_ALL_OR_NOTHING; + } + if ($test->option['shuffle-answers'] == 'true') { + $data['options']['shuffle_answers'] = 1; + } + if ($test->option['shuffle-exercises'] == 'true') { + $data['options']['shuffle_exercises'] = 1; + } + + if ($test['start']) { + $data['start'] = strtotime($test['start']); + } + if ($test['end']) { + $data['end'] = strtotime($test['end']); + } else if ($data['type'] === 'selftest') { + $data['end'] = null; + } + if ($test['duration']) { + $data['options']['duration'] = (int) $test['duration']; + } + if ($test['block'] && $course_id) { + $block = VipsBlock::findOneBySQL('name = ? AND range_id = ?', [$test['block'], $course_id]); + + if (!$block) { + $block = VipsBlock::create(['name' => $test['block'], 'range_id' => $course_id]); + } + + $data['block_id'] = $block->id; + } + + if ($test->{'feedback-items'}) { + foreach ($test->{'feedback-items'}->feedback as $feedback) { + $threshold = (int) ($feedback['score'] * 100); + $data['options']['feedback'][$threshold] = Studip\Markup::purifyHtml(trim($feedback)); + } + + krsort($data['options']['feedback']); + } + + $test_obj = VipsTest::create($data_test); + + $result = self::build($data); + $result->test = $test_obj; + $result->store(); + + if ($test->files) { + foreach ($test->files->file as $file) { + $file_id = (string) $file['id']; + $content = base64_decode((string) $file); + + $test->registerXPathNamespace('vips', 'urn:vips:test:v1.0'); + $file_refs = $test->xpath('vips:exercises/*/vips:file-refs/*[@ref="' . $file_id . '"]'); + + if ($file_refs && $content !== false) { + if (strlen($file_id) > 5 && str_starts_with($file_id, 'file-')) { + $vips_file = File::find(substr($file_id, 5)); + + // try to avoid reupload of identical files + if ($vips_file && sha1_file($vips_file->getPath()) === sha1($content)) { + $files[$file_id] = $vips_file; + continue; + } + } + + $file = File::create([ + 'user_id' => $user_id, + 'mime_type' => get_mime_type($file['name']), + 'name' => basename($file['name']), + 'size' => strlen($content) + ]); + + file_put_contents($file->getPath(), $content); + } + } + + if (isset($files)) { + $mapped = preg_replace_callback( + '/\burn:vips:file-ref:([A-Za-z_][\w.-]*)/', + function($match) use ($files) { + $file = $files[$match[1]]; + + if ($file) { + return htmlReady($file->getDownloadURL()); + } else { + return $match[0]; + } + }, $string + ); + $test = new SimpleXMLElement($mapped, LIBXML_COMPACT | LIBXML_NOCDATA); + } + } + + foreach ($test->exercises->exercise as $exercise) { + try { + $new_exercise = Exercise::importXML($exercise); + $new_exercise->user_id = $user_id; + $new_exercise->store(); + $exercise_ref = $test_obj->addExercise($new_exercise); + + if ($exercise['points']) { + $exercise_ref->points = (float) $exercise['points']; + $exercise_ref->store(); + } + + if ($exercise->{'file-refs'}) { + $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task'); + + foreach ($exercise->{'file-refs'}->{'file-ref'} as $file_ref) { + $file = $files[(string) $file_ref['ref']]; + + if ($file) { + FileRef::create([ + 'file_id' => $file->id, + 'folder_id' => $folder->id, + 'object_id' => $new_exercise->id, + 'user_id' => $user_id, + 'name' => $file->name + ]); + } + } + } + } catch (Exception $e) { + $errors[] = $e->getMessage(); + } + } + + if (isset($errors)) { + PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors); + } + + return $result; + } + + /** + * Get the name of this assignment type. + */ + public function getTypeName(): string + { + $assignment_types = self::getAssignmentTypes(); + + return $assignment_types[$this->type]['name']; + } + + /** + * Get the icon of this assignment type. + */ + public function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon + { + $assignment_types = self::getAssignmentTypes(); + + return Icon::create( + $assignment_types[$this->type]['icon'], + $role, + ['aria-hidden' => 'true', 'title' => $assignment_types[$this->type]['name']] + ); + } + + /** + * Get the list of supported assignment types. + */ + public static function getAssignmentTypes(): array + { + return [ + 'practice' => ['name' => _('Ãœbung'), 'icon' => 'file'], + 'selftest' => ['name' => _('Selbsttest'), 'icon' => 'check-circle'], + 'exam' => ['name' => _('Klausur'), 'icon' => 'doctoral_cap'] + ]; + } + + /** + * Check if this assignment is locked for editing. + */ + public function isLocked(): bool + { + return $this->type === 'exam' && $this->countAssignmentAttempts() > 0; + } + + /** + * Check if this assignment is visible to this user. + */ + public function isVisible(string $user_id): bool + { + return $this->block_id ? $this->block->isVisible($user_id) : true; + } + + /** + * Check if this assignment has been started. + */ + public function isStarted(): bool + { + $now = time(); + + return $now >= $this->start; + } + + /** + * Check if this assignment is currently running. + * + * @param string|null $user_id check end time for this user id (optional) + */ + public function isRunning(?string $user_id = null): bool + { + $now = time(); + $end = $user_id ? $this->getUserEndTime($user_id) : $this->end; + + return $now >= $this->start && ($end === null || $now <= $end); + } + + /** + * Check if this assignment is already finished. + * + * @param string|null $user_id check end time for this user id (optional) + */ + public function isFinished(?string $user_id = null): bool + { + $now = time(); + $end = $user_id ? $this->getUserEndTime($user_id) : $this->end; + + return $end && $now > $end; + } + + /** + * Check if this assignment has no end date. + */ + public function isUnlimited(): bool + { + return $this->type === 'selftest' && $this->end === null; + } + + /** + * Check if this assignment may use self assessment features. + */ + public function isSelfAssessment(): bool + { + return $this->type === 'selftest' || $this->options['self_assessment']; + } + + /** + * Check if a user may reset and restart this assignment. + */ + public function isResetAllowed(): bool + { + return $this->isSelfAssessment() && $this->options['resets'] !== 0; + } + + /** + * Check if this assignment presents shuffled exercises. + */ + public function isExerciseShuffled(): bool + { + return $this->type === 'exam' && $this->options['shuffle_exercises']; + } + + /** + * Check if this assignment presents shuffled answers. + */ + public function isShuffled(): bool + { + return $this->type === 'exam' && $this->options['shuffle_answers'] !== 0; + } + + /** + * Check if this assignment is using group solutions. + */ + public function hasGroupSolutions(): bool + { + return $this->type === 'practice' && $this->options['use_groups'] !== 0; + } + + /** + * Get the number of tries allowed for exercises on this assignment. + */ + public function getMaxTries(): int + { + if ($this->type === 'selftest') { + return $this->options['max_tries'] ?? 3; + } + + return 0; + } + + /** + * Check whether the given exercise is part of this assignment. + * + * @param int $exercise_id exercise id + */ + public function hasExercise(int $exercise_id): bool + { + return VipsExerciseRef::exists([$this->test_id, $exercise_id]); + } + + /** + * Return array of exercise refs in the test of this assignment. + */ + public function getExerciseRefs(?string $user_id): array + { + $result = $this->test->exercise_refs->getArrayCopy(); + + if ($this->isExerciseShuffled() && $user_id) { + srand(crc32($this->id . ':' . $user_id)); + shuffle($result); + srand(); + } + + return $result; + } + + /** + * Export this assignment to XML format. Returns the XML string. + */ + public function exportXML(): string + { + $files = []; + + foreach ($this->test->exercise_refs as $exercise_ref) { + $exercise = $exercise_ref->exercise; + $exercise->includeFilesForExport(); + + if ($exercise->folder) { + foreach ($exercise->folder->file_refs as $file_ref) { + $files[$file_ref->file_id] = $file_ref->file; + } + } + } + + $template = VipsModule::$template_factory->open('sheets/export_assignment'); + $template->assignment = $this; + $template->files = $files; + + // delete all characters outside the valid character range for XML + // documents (#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]). + return preg_replace("/[^\t\n\r -\xFF]/", '', $template->render()); + } + + /** + * Check whether this assignment is editable by the given user. + * + * @param string|null $user_id user to check (defaults to current user) + */ + public function checkEditPermission(?string $user_id = null): bool + { + if ($this->range_type === 'user') { + return $this->range_id === ($user_id ?: $GLOBALS['user']->id); + } + + return $GLOBALS['perm']->have_studip_perm('tutor', $this->range_id, $user_id); + } + + /** + * Check whether this assignment is viewable by the given user. + * + * @param string|null $user_id user to check (defaults to current user) + */ + public function checkViewPermission(?string $user_id = null): bool + { + if ($this->range_type === 'user') { + return $this->range_id === ($user_id ?: $GLOBALS['user']->id); + } + + return $GLOBALS['perm']->have_studip_perm('autor', $this->range_id, $user_id); + } + + /** + * Check whether this assignment is accessible to a student. This is just + * a shortcut for checking: running, active, ip address and access code. + * + * @param string $user_id check end time for this user id (optional) + */ + public function checkAccess($user_id = null): bool + { + return $this->isRunning($user_id) + && $this->active && $this->checkAccessCode() + && $this->checkIPAccess($_SERVER['REMOTE_ADDR']); + } + + /** + * Check whether the access code provided for this assignment is valid. + * If $access_code is null, the code stored in the user session is used. + * + * @param string|null $access_code access code (optional) + */ + public function checkAccessCode(?string $access_code = null): bool + { + if (isset($access_code)) { + $_SESSION['vips_access_' . $this->id] = $access_code; + } else if (isset($_SESSION['vips_access_' . $this->id])) { + $access_code = $_SESSION['vips_access_' . $this->id]; + } else { + $access_code = null; + } + + return in_array($this->options['access_code'], [null, $access_code], true); + } + + /** + * Check whether the given IP address listed among the IP addresses given + * by the lecturer for this exam (if applicable). + * + * @param string $ip_addr IPv4 or IPv6 address + */ + public function checkIPAccess(string $ip_addr): bool + { + // not an exam: user has access. + if ($this->type !== 'exam') { + return true; + } + + $ip_addr = inet_pton($ip_addr); + $ip_ranges = $this->options['ip_range']; + $exam_rooms = Config::get()->VIPS_EXAM_ROOMS; + + // expand exam room names + if ($exam_rooms) { + $ip_ranges = preg_replace_callback('/#([^ ,]+)/', + function($match) use ($exam_rooms) { + return $exam_rooms[$match[1]]; + }, $ip_ranges); + } + + // Explode space separated list into an array and check the resulting single IPs + $ip_ranges = preg_split('/[ ,]+/', $ip_ranges, -1, PREG_SPLIT_NO_EMPTY); + + // No IP given: user has access. + if (count($ip_ranges) == 0) { + return true; + } + + // One or more IPs are given and user IP matches at least one: user has access. + foreach ($ip_ranges as $ip_range) { + if (str_contains($ip_range, '/')) { + [$ip_range, $bits] = explode('/', $ip_range); + $ip_range = inet_pton($ip_range) ?: ''; + $mask = str_repeat(chr(0), strlen($ip_range)); + + for ($i = 0; $i < strlen($mask); ++$i) { + if ($bits >= 8) { + $bits -= 8; + } else { + $mask[$i] = chr((1 << 8 - $bits) - 1); + $bits = 0; + } + } + + $ip_start = $ip_range & ~$mask; + $ip_end = $ip_range | $mask; + } else { + if (str_contains($ip_range, '-')) { + [$ip_start, $ip_end] = explode('-', $ip_range); + } else { + $ip_start = $ip_end = $ip_range; + } + + if (!str_contains($ip_range, ':')) { + $ip_start = implode('.', array_pad(explode('.', $ip_start), 4, 0)); + $ip_end = implode('.', array_pad(explode('.', $ip_end), 4, 255)); + } + + $ip_start = inet_pton($ip_start); + $ip_end = inet_pton($ip_end); + } + + if (strcmp($ip_start, $ip_addr) <= 0 && strcmp($ip_addr, $ip_end) <= 0) { + return true; + } + } + + return false; + } + + /** + * Get the release status of this assignment for the given user. + * + * Valid values are: + * - 0 = not released + * - 1 = points + * - 2 = comments + * - 3 = corrections + * - 4 = sample solutions + * + * See the according constants of this class. + */ + public function releaseStatus(string $user_id): int + { + if ($this->isFinished() || $this->isSelfAssessment() && $this->isFinished($user_id)) { + if ($this->type === 'exam') { + if ($this->getAssignmentAttempt($user_id)) { + return $this->options['released'] ?? self::RELEASE_STATUS_NONE; + } + } else { + if ($this->options['released'] > 0) { + return $this->options['released']; + } + } + } + + return self::RELEASE_STATUS_NONE; + } + + /** + * Count the number of assignment attempts for this assignment. + */ + public function countAssignmentAttempts(): int + { + return VipsAssignmentAttempt::countBySql('assignment_id = ?', [$this->id]); + } + + /** + * Get the assignment attempt of the given user for this assignment. + * Returns null if there is no assignment attempt for this user. + * + * @param string $user_id user id + */ + public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt + { + return VipsAssignmentAttempt::findOneBySQL('assignment_id = ? AND user_id = ?', [$this->id, $user_id]); + } + + /** + * Record an assignment attempt for the given user for this assignment. + */ + public function recordAssignmentAttempt(string $user_id): void + { + if (!$this->getAssignmentAttempt($user_id)) { + if ($this->type === 'exam') { + $end = time() + $this->options['duration'] * 60; + $ip_address = $_SERVER['REMOTE_ADDR']; + $options = ['session_id' => session_id()]; + } else { + $end = null; + $ip_address = ''; + $options = null; + } + + VipsAssignmentAttempt::create([ + 'assignment_id' => $this->id, + 'user_id' => $user_id, + 'start' => time(), + 'end' => $end, + 'ip_address' => $ip_address, + 'options' => $options + ]); + } + } + + /** + * Finish an assignment attempt for the given user for this assignment. + */ + public function finishAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt + { + $assignment_attempt = $this->getAssignmentAttempt($user_id); + $now = time(); + + if ($assignment_attempt) { + if ($assignment_attempt->end === null || $assignment_attempt->end > $now) { + $assignment_attempt->end = $now; + $assignment_attempt->store(); + } + } + + return $assignment_attempt; + } + + /** + * Get the individual end time of the given user for this assignment. + */ + public function getUserEndTime(string $user_id): ?int + { + if ($this->type === 'practice') { + return $this->end; + } + + $assignment_attempt = $this->getAssignmentAttempt($user_id); + + if ($assignment_attempt) { + $start = $assignment_attempt->start; + } else { + $start = time(); + } + + if ($assignment_attempt && $assignment_attempt->end) { + return min($assignment_attempt->end, $this->end ?: $assignment_attempt->end); + } else if ($this->type === 'exam') { + return min($start + $this->options['duration'] * 60, $this->end); + } else { + return $this->end; + } + } + + /** + * Get all members that were assigned to a particular group for + * this assignment. + * + * @param VipsGroup $group The group object + * @return VipsGroupMember[] + */ + public function getGroupMembers($group): array + { + return VipsGroupMember::findBySQL( + 'group_id = ? AND start < ? AND (end > ? OR end IS NULL)', + [$group->id, $this->end, $this->end] + ); + } + + /** + * Get the group the user was assigned to for this assignment. + * Returns null if there is no group assignment for this user. + */ + public function getUserGroup(string $user_id): ?VipsGroup + { + if (!$this->hasGroupSolutions()) { + return null; + } + + return VipsGroup::findOneBySQL( + 'JOIN etask_group_members ON group_id = statusgruppe_id + WHERE range_id = ? + AND user_id = ? + AND start < ? + AND (end > ? OR end IS NULL)', + [$this->range_id, $user_id, $this->end, $this->end] + ); + } + + /** + * Store a solution related to this assignment into the database. + * + * @param VipsSolution $solution The solution object + */ + public function storeSolution(VipsSolution $solution): bool|int + { + $solution->assignment = $this; + + // store some client info for exams + if ($this->type === 'exam') { + $solution->ip_address = $_SERVER['REMOTE_ADDR']; + $solution->options['session_id'] = session_id(); + } + + // in selftests, autocorrect solution + if ($this->isSelfAssessment()) { + $this->correctSolution($solution); + } + + // insert new solution into etask_responses + return $solution->store(); + } + + /** + * Correct a solution and store the points for the solution in the object. + * + * @param VipsSolution $solution The solution object + * @param bool $corrected mark solution as corrected + */ + public function correctSolution(VipsSolution $solution, bool $corrected = false): void + { + $exercise = $solution->exercise; + $exercise_ref = $this->test->getExerciseRef($exercise->id); + $max_points = (float) $exercise_ref->points; + + // always set corrected to true for selftest exercises + $selftest = $this->type === 'selftest'; + $evaluation = $exercise->evaluate($solution); + $eval_safe = $selftest ? $evaluation['safe'] !== null : $evaluation['safe']; + + $reached_points = round($evaluation['percent'] * $max_points * 2) / 2; + $corrected = (int) ($corrected || $eval_safe); + + // insert solution points + $solution->state = $corrected; + $solution->points = $reached_points; + $solution->chdate = time(); + + if ($selftest && $evaluation['percent'] != 1 && isset($exercise->options['feedback'])) { + $solution->feedback = $exercise->options['feedback']; + } + } + + /** + * Restores an archived solution as the current solution. + * + * @param VipsSolution $solution The solution object + */ + public function restoreSolution(VipsSolution $solution): void + { + if ($solution->isArchived() && $solution->assignment_id == $this->id) { + $new_solution = VipsSolution::build($solution); + $new_solution->id = 0; + + if ($solution->folder) { + $new_solution->store(); + $folder = Folder::findTopFolder($new_solution->id, 'ResponseFolder', 'response'); + + foreach ($solution->folder->file_refs as $file_ref) { + FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), $file_ref->user); + } + } + + $this->storeSolution($new_solution); + } + } + + /** + * Fetch archived solutions related to this assignment from the database. + * Returns empty list if there are no archived solutions for this exercise. + * + * @return VipsSolution[] + */ + public function getArchivedGroupSolutions(string $group_id, int $exercise_id): array + { + return VipsSolution::findBySQL( + 'JOIN etask_group_members USING(user_id) + WHERE task_id = ? + AND assignment_id = ? + AND group_id = ? + AND start < ? + AND (end > ? OR end IS NULL) + ORDER BY mkdate DESC', + [$exercise_id, $this->id, $group_id, $this->end, $this->end] + ); + } + + /** + * Fetch archived solutions related to this assignment from the database. + * NOTE: This method will NOT check the group solutions, if applicable. + * Returns empty list if there are no archived solutions for this exercise. + * + * @return VipsSolution[] + */ + public function getArchivedUserSolutions(string $user_id, int $exercise_id): array + { + return VipsSolution::findBySQL( + 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC', + [$exercise_id, $this->id, $user_id] + ); + } + + /** + * Fetch archived solutions related to this assignment from the database. + * Returns empty list if there are no archived solutions for this exercise. + * + * @return VipsSolution[] + */ + public function getArchivedSolutions(string $user_id, int $exercise_id): array + { + $group = $this->getUserGroup($user_id); + + if ($group) { + return $this->getArchivedGroupSolutions($group->id, $exercise_id); + } + + return $this->getArchivedUserSolutions($user_id, $exercise_id); + } + + /** + * Fetch a solution related to this assignment from the database. + * Returns null if there is no solution for this exercise yet. + */ + public function getGroupSolution(string $group_id, int $exercise_id): ?VipsSolution + { + return VipsSolution::findOneBySQL( + 'JOIN etask_group_members USING(user_id) + WHERE task_id = ? + AND assignment_id = ? + AND group_id = ? + AND start < ? + AND (end > ? OR end IS NULL) + ORDER BY mkdate DESC', + [$exercise_id, $this->id, $group_id, $this->end, $this->end] + ); + } + + /** + * Fetch a solution related to this assignment from the database. + * NOTE: This method will NOT check the group solution, if applicable. + * Returns null if there is no solution for this exercise yet. + */ + public function getUserSolution(string $user_id, int $exercise_id): ?VipsSolution + { + return VipsSolution::findOneBySQL( + 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC', + [$exercise_id, $this->id, $user_id] + ); + } + + /** + * Fetch a solution related to this assignment from the database. + * Returns null if there is no solution for this exercise yet. + */ + public function getSolution(string $user_id, int $exercise_id): ?VipsSolution + { + $group = $this->getUserGroup($user_id); + + if ($group) { + return $this->getGroupSolution($group->id, $exercise_id); + } + + return $this->getUserSolution($user_id, $exercise_id); + } + + /** + * Delete all solutions of the given user for a single exercise of + * this test from the DB. + */ + public function deleteSolution(string $user_id, int $exercise_id): void + { + $sql = 'task_id = ? AND assignment_id = ? AND user_id = ?'; + + if ($this->isSelfAssessment()) { + // delete in etask_responses + VipsSolution::deleteBySQL($sql, [$exercise_id, $this->id, $user_id]); + } + + // update gradebook if necessary + $this->updateGradebookEntries($user_id); + } + + /** + * Delete all solutions of the given user for this test from the DB. + */ + public function deleteSolutions(string $user_id): void + { + $sql = 'assignment_id = ? AND user_id = ?'; + + if ($this->isSelfAssessment()) { + // delete in etask_responses + VipsSolution::deleteBySQL($sql, [$this->id, $user_id]); + } + + // delete start times + VipsAssignmentAttempt::deleteBySQL($sql, [$this->id, $user_id]); + + // update gradebook if necessary + $this->updateGradebookEntries($user_id); + } + + /** + * Delete all solutions of all users for this test from the DB. + */ + public function deleteAllSolutions(): void + { + $sql = 'assignment_id = ?'; + + if ($this->isSelfAssessment()) { + // delete in etask_responses + VipsSolution::deleteBySQL($sql, [$this->id]); + } + + // delete start times + VipsAssignmentAttempt::deleteBySQL($sql, [$this->id]); + + // update gradebook if necessary + $this->updateGradebookEntries(); + } + + /** + * Count the number of solutions of the given user for this test. + */ + public function countSolutions(string $user_id): int + { + $solutions = 0; + + foreach ($this->test->exercise_refs as $exercise_ref) { + if ($this->getSolution($user_id, $exercise_ref->task_id)) { + ++$solutions; + } + } + + return $solutions; + } + + /** + * Return the points a user has reached in all exercises in this assignment. + */ + public function getUserPoints(string $user_id): float|int + { + $group = $this->getUserGroup($user_id); + + if ($group) { + $user_ids = array_column($this->getGroupMembers($group), 'user_id'); + } else { + $user_ids = [$user_id]; + } + + $solutions = $this->solutions->findBy('user_id', $user_ids)->orderBy('mkdate'); + $points = []; + + foreach ($solutions as $solution) { + $points[$solution->task_id] = (float) $solution->points; + } + + return max(array_sum($points), 0); + } + + /** + * Return the progress a user has achieved on this assignment (range 0..1). + */ + public function getUserProgress(string $user_id): float|int + { + $group = $this->getUserGroup($user_id); + $max_points = 0; + $progress = 0; + + foreach ($this->test->exercise_refs as $exercise_ref) { + $max_points += $exercise_ref->points; + + if ($group) { + $solution = $this->getGroupSolution($group->id, $exercise_ref->task_id); + } else { + $solution = $this->getUserSolution($user_id, $exercise_ref->task_id); + } + + if ($solution) { + $progress += $exercise_ref->points; + } + } + + return $max_points ? $progress / $max_points : 0; + } + + /** + * Return the individual feedback text for the given user in this assignment. + */ + public function getUserFeedback(string $user_id): ?string + { + if (isset($this->options['feedback'])) { + $user_points = $this->getUserPoints($user_id); + $max_points = $this->test->getTotalPoints(); + $percent = $user_points / $max_points * 100; + + foreach ($this->options['feedback'] as $threshold => $feedback) { + if ($percent >= $threshold) { + return $feedback; + } + } + } + + return null; + } + + /** + * Copy this assignment into the given course. Returns the new assignment. + */ + public function copyIntoCourse(string $course_id, string $range_type = 'course'): ?VipsAssignment + { + // determine title of new assignment + if ($this->range_id === $course_id) { + $title = sprintf(_('Kopie von %s'), $this->test->title); + } else { + $title = $this->test->title; + } + + // reset released option for new assignment + $options = $this->options; + unset($options['released']); + unset($options['stopdate']); + unset($options['gradebook_id']); + + $new_test = VipsTest::create([ + 'title' => $title, + 'description' => $this->test->description, + 'user_id' => $GLOBALS['user']->id + ]); + + $new_assignment = VipsAssignment::create([ + 'test_id' => $new_test->id, + 'range_id' => $course_id, + 'range_type' => $range_type, + 'type' => $this->type, + 'start' => $this->start, + 'end' => $this->end, + 'options' => $options + ]); + + foreach ($this->test->exercise_refs as $exercise_ref) { + $exercise_ref->copyIntoTest($new_test->id, $exercise_ref->position); + } + + return $new_assignment; + } + + /** + * Move this assignment into the given course. + */ + public function moveIntoCourse(string $course_id, string $range_type = 'course'): void + { + if ($this->range_id !== $course_id) { + $this->range_id = $course_id; + $this->range_type = $range_type; + $this->block_id = null; + $this->removeFromGradebook(); + $this->store(); + } + } + + /** + * Insert this assignment into the gradebook of its course. + * + * @param string $title gradebook title + * @param float $weight gradebook weight + */ + public function insertIntoGradebook(string $title, float $weight = 1): void + { + $gradebook_id = $this->options['gradebook_id']; + + if (!$gradebook_id) { + $definition = Grading\Definition::create([ + 'course_id' => $this->range_id, + 'item' => $this->id, + 'name' => $title, + 'tool' => _('Aufgaben'), + 'category' => $this->getTypeName(), + 'position' => $this->start, + 'weight' => $weight + ]); + + $this->options['gradebook_id'] = $definition->id; + $this->store(); + } + } + + /** + * Remove this assignment from the gradebook of its course. + */ + public function removeFromGradebook(): void + { + $gradebook_id = $this->options['gradebook_id']; + + if ($gradebook_id) { + Grading\Definition::find($gradebook_id)->delete(); + + unset($this->options['gradebook_id']); + $this->store(); + } + } + + /** + * Update some or all gradebook entries of this assignment. If the + * user_id is specified, only update entries related to this user. + * + * @param string|null $user_id user id + */ + public function updateGradebookEntries(?string $user_id = null): void + { + $gradebook_id = $this->options['gradebook_id']; + + if ($gradebook_id) { + $max_points = $this->test->getTotalPoints() ?: 1; + + if ($user_id) { + $group = $this->getUserGroup($user_id); + } + + if ($group) { + $members = $this->getGroupMembers($group); + } else if ($user_id) { + $members = [(object) compact('user_id')]; + } else { + $members = $this->course->members->findBy('status', 'autor'); + } + + foreach ($members as $member) { + $reached_points = $this->getUserPoints($member->user_id); + $entry = new Grading\Instance([$gradebook_id, $member->user_id]); + + if ($reached_points) { + $entry->rawgrade = $reached_points / $max_points; + $entry->store(); + } else { + $entry->delete(); + } + } + } + } +} diff --git a/lib/models/vips/VipsAssignmentAttempt.php b/lib/models/vips/VipsAssignmentAttempt.php new file mode 100644 index 0000000000000000000000000000000000000000..9eba3716cb6f96acc3ed78e51761fa190ca34e08 --- /dev/null +++ b/lib/models/vips/VipsAssignmentAttempt.php @@ -0,0 +1,99 @@ +<?php +/* + * VipsAssignmentAttempt.php - Vips test attempt class for Stud.IP + * Copyright (c) 2016 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property int $assignment_id database column + * @property string $user_id database column + * @property int|null $start database column + * @property int|null $end database column + * @property string $ip_address database column + * @property JSONArrayObject|null $options database column + * @property int|null $mkdate database column + * @property int|null $chdate database column + * @property VipsAssignment $assignment belongs_to VipsAssignment + * @property User $user belongs_to User + */ +class VipsAssignmentAttempt extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_assignment_attempts'; + + $config['serialized_fields']['options'] = JSONArrayObject::class; + + $config['belongs_to']['assignment'] = [ + 'class_name' => VipsAssignment::class, + 'foreign_key' => 'assignment_id' + ]; + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } + + /** + * Return a student's event log for the assignment as a data array. + */ + public function getLogEntries(): array + { + $assignment = $this->assignment; + $user_id = $this->user_id; + $end_time = min($this->end, $assignment->end); + + $solutions = VipsSolution::findBySQL('assignment_id = ? AND user_id = ?', [$assignment->id, $user_id]); + + foreach ($assignment->test->exercise_refs as $exercise_ref) { + $position[$exercise_ref->task_id] = $exercise_ref->position; + } + + $logs[] = [ + 'label' => _('Beginn der Klausur'), + 'time' => $this->start, + 'ip_address' => $this->ip_address, + 'session_id' => $this->options['session_id'], + 'archived' => false + ]; + + foreach ($solutions as $solution) { + if ($solution->isSubmitted()) { + $logs[] = [ + 'label' => sprintf(_('Abgabe Aufgabe %d'), $position[$solution->task_id]), + 'time' => $solution->mkdate, + 'ip_address' => $solution->ip_address, + 'session_id' => $solution->options['session_id'], + 'archived' => $solution->isArchived(), + ]; + } + } + + if ($end_time && $end_time < date('Y-m-d H:i:s')) { + $logs[] = [ + 'label' => _('Ende der Klausur'), + 'time' => $end_time, + 'ip_address' => '', + 'session_id' => '', + 'archived' => false + ]; + } + + usort($logs, fn($a, $b) => $a['time'] <=> $b['time']); + + return $logs; + } +} diff --git a/lib/models/vips/VipsBlock.php b/lib/models/vips/VipsBlock.php new file mode 100644 index 0000000000000000000000000000000000000000..217925481e732893c27815901361e83fc536c726 --- /dev/null +++ b/lib/models/vips/VipsBlock.php @@ -0,0 +1,92 @@ +<?php +/* + * VipsBlock.php - Vips block class for Stud.IP + * Copyright (c) 2016 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $name database column + * @property string $range_id database column + * @property string|null $group_id database column + * @property int $visible database column + * @property float|null $weight database column + * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment + * @property Course $course belongs_to Course + * @property Statusgruppen|null $group belongs_to Statusgruppen + */ +class VipsBlock extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_blocks'; + + $config['has_many']['assignments'] = [ + 'class_name' => VipsAssignment::class, + 'assoc_foreign_key' => 'block_id' + ]; + + $config['belongs_to']['course'] = [ + 'class_name' => Course::class, + 'foreign_key' => 'range_id' + ]; + $config['belongs_to']['group'] = [ + 'class_name' => Statusgruppen::class, + 'foreign_key' => 'group_id' + ]; + + parent::configure($config); + } + + /** + * Delete entry from the database. + */ + public function delete() + { + foreach ($this->assignments as $assignment) { + $assignment->block_id = null; + $assignment->store(); + } + + return parent::delete(); + } + + /** + * Check if this block is visible to this user. + */ + public function isVisible(string $user_id): bool + { + $visible = $this->visible; + + if ($visible && $this->group_id) { + $visible = StatusgruppeUser::exists([$this->group_id, $user_id]); + } + + return $visible; + } + + /** + * Get the first assignment attempt of the given user for this block. + * Returns null if there is no assignment attempt for this user. + * + * @param string $user_id user id + */ + public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt + { + $assignment_ids = $this->assignments->pluck('id'); + + return VipsAssignmentAttempt::findOneBySQL( + 'assignment_id IN (?) AND user_id = ? ORDER BY start', [$assignment_ids, $user_id] + ); + } +} diff --git a/lib/models/vips/VipsExerciseRef.php b/lib/models/vips/VipsExerciseRef.php new file mode 100644 index 0000000000000000000000000000000000000000..3255e278b4ceeac7e97e094541987c5e8bf55bab --- /dev/null +++ b/lib/models/vips/VipsExerciseRef.php @@ -0,0 +1,137 @@ +<?php +/* + * VipsExerciseRef.php - Vips exercise reference class for Stud.IP + * Copyright (c) 2016 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property array $id alias for pk + * @property int $test_id database column + * @property int $task_id database column + * @property int $position database column + * @property int $part database column + * @property float|null $points database column + * @property string $options database column + * @property int|null $mkdate database column + * @property int|null $chdate database column + * @property Exercise $exercise belongs_to Exercise + * @property VipsTest $test belongs_to VipsTest + */ +class VipsExerciseRef extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_test_tasks'; + + $config['belongs_to']['exercise'] = [ + 'class_name' => Exercise::class, + 'foreign_key' => 'task_id' + ]; + $config['belongs_to']['test'] = [ + 'class_name' => VipsTest::class, + 'foreign_key' => 'test_id' + ]; + + parent::configure($config); + } + + /** + * Set value for the "exercise" relation (to avoid SORM errors). + */ + public function setExercise(Exercise $exercise): void + { + $this->task_id = $exercise->id; + $this->relations['exercise'] = $exercise; + } + + /** + * Delete entry from the database. + */ + public function delete() + { + $ref_count = self::countBySql('task_id = ?', [$this->task_id]); + + if ($ref_count == 1) { + $this->exercise->delete(); + } + + return parent::delete(); + } + + /** + * Copy the referenced exercise into the given test at the specified + * position (or at the end). Returns the new exercise reference. + * + * @param string $test_id test id + * @param int $position exercise position (optional) + */ + public function copyIntoTest(string $test_id, ?int $position = null): VipsExerciseRef + { + $db = DBManager::get(); + + if ($position === null) { + $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?'); + $stmt->execute([$test_id]); + $position = $stmt->fetchColumn() + 1; + } + + $new_exercise = Exercise::create([ + 'type' => $this->exercise->type, + 'title' => $this->exercise->title, + 'description' => $this->exercise->description, + 'task' => $this->exercise->task, + 'options' => $this->exercise->options, + 'user_id' => $GLOBALS['user']->id + ]); + + if ($this->exercise->folder) { + $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task'); + + foreach ($this->exercise->folder->file_refs as $file_ref) { + FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent()); + } + } + + return VipsExerciseRef::create([ + 'task_id' => $new_exercise->id, + 'test_id' => $test_id, + 'points' => $this->points, + 'position' => $position + ]); + } + + /** + * Move the referenced exercise into the given test (at the end). + * + * @param string $test_id test id + */ + public function moveIntoTest(string $test_id): void + { + $db = DBManager::get(); + $old_test_id = $this->test_id; + $old_position = $this->position; + + if ($old_test_id != $test_id) { + $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?'); + $stmt->execute([$test_id]); + $this->position = $stmt->fetchColumn() + 1; + $this->test_id = $test_id; + $this->store(); + + // renumber following exercises + $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?'; + $stmt = $db->prepare($sql); + $stmt->execute([$old_test_id, $old_position]); + } + } +} diff --git a/lib/models/vips/VipsGroup.php b/lib/models/vips/VipsGroup.php new file mode 100644 index 0000000000000000000000000000000000000000..8b43c2ed31e17f4b6af1e22d9249aa9ea20a8680 --- /dev/null +++ b/lib/models/vips/VipsGroup.php @@ -0,0 +1,79 @@ +<?php +/* + * VipsGroup.php - Vips group class for Stud.IP + * Copyright (c) 2016 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property string $id alias column for statusgruppe_id + * @property string $statusgruppe_id database column + * @property string $name database column + * @property string|null $description database column + * @property string $range_id database column + * @property int $position database column + * @property int $size database column + * @property int $selfassign database column + * @property int $selfassign_start database column + * @property int $selfassign_end database column + * @property int $mkdate database column + * @property int $chdate database column + * @property int $calendar_group database column + * @property string|null $name_w database column + * @property string|null $name_m database column + * @property SimpleORMapCollection|VipsGroupMember[] $members has_many VipsGroupMember + * @property SimpleORMapCollection|VipsGroupMember[] $current_members has_many VipsGroupMember + * @property Course $course belongs_to Course + */ +class VipsGroup extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'statusgruppen'; + + $config['has_many']['members'] = [ + 'class_name' => VipsGroupMember::class, + 'assoc_foreign_key' => 'group_id', + 'on_delete' => 'delete' + ]; + $config['has_many']['current_members'] = [ + 'class_name' => VipsGroupMember::class, + 'assoc_foreign_key' => 'group_id', + 'order_by' => 'AND end IS NULL' + ]; + + $config['belongs_to']['course'] = [ + 'class_name' => Course::class, + 'foreign_key' => 'range_id' + ]; + + parent::configure($config); + } + + /** + * Get the group the user is currently assigned to in a course. + * Returns null if there is no group assignment for this user. + * + * @param string $user_id user id + * @param string $course_id course id + */ + public static function getUserGroup(string $user_id, string $course_id): ?VipsGroup + { + return self::findOneBySQL( + 'JOIN etask_group_members ON group_id = statusgruppe_id + WHERE range_id = ? + AND user_id = ? + AND end IS NULL', + [$course_id, $user_id] + ); + } +} diff --git a/lib/models/vips/VipsGroupMember.php b/lib/models/vips/VipsGroupMember.php new file mode 100644 index 0000000000000000000000000000000000000000..c6be629f0a5a2fcc97ca6aa95472a734304d5839 --- /dev/null +++ b/lib/models/vips/VipsGroupMember.php @@ -0,0 +1,50 @@ +<?php +/* + * VipsGroupMember.php - Vips group member class for Stud.IP + * Copyright (c) 2016 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property array $id alias for pk + * @property string $group_id database column + * @property string $user_id database column + * @property int $start database column + * @property int|null $end database column + * @property VipsGroup $group belongs_to VipsGroup + * @property User $user belongs_to User + * @property mixed $vorname additional field + * @property mixed $nachname additional field + * @property mixed $username additional field + */ +class VipsGroupMember extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_group_members'; + + $config['additional_fields']['vorname'] = ['user', 'vorname']; + $config['additional_fields']['nachname'] = ['user', 'nachname']; + $config['additional_fields']['username'] = ['user', 'username']; + + $config['belongs_to']['group'] = [ + 'class_name' => VipsGroup::class, + 'foreign_key' => 'group_id' + ]; + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } +} diff --git a/lib/models/vips/VipsSolution.php b/lib/models/vips/VipsSolution.php new file mode 100644 index 0000000000000000000000000000000000000000..14b98264279e13c0cb8949ae48616977ab49e4a0 --- /dev/null +++ b/lib/models/vips/VipsSolution.php @@ -0,0 +1,160 @@ +<?php +/* + * VipsSolution.php - Vips solution class for Stud.IP + * Copyright (c) 2014 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * + * @property int $id database column + * @property int $assignment_id database column + * @property int $task_id database column + * @property string $user_id database column + * @property JSONArrayObject $response database column + * @property string|null $student_comment database column + * @property string $ip_address database column + * @property int|null $state database column + * @property float|null $points database column + * @property string|null $feedback database column + * @property string|null $commented_solution database column + * @property string|null $grader_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property JSONArrayObject $options database column + * @property Exercise $exercise belongs_to Exercise + * @property VipsAssignment $assignment belongs_to VipsAssignment + * @property User $user belongs_to User + * @property Folder $folder has_one Folder + * @property Folder $feedback_folder has_one Folder + */ +class VipsSolution extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_responses'; + + $config['serialized_fields']['response'] = JSONArrayObject::class; + $config['serialized_fields']['options'] = JSONArrayObject::class; + + $config['registered_callbacks']['after_store'][] = 'after_store'; + + $config['has_one']['folder'] = [ + 'class_name' => Folder::class, + 'assoc_foreign_key' => 'range_id', + 'assoc_func' => 'findByRangeIdAndFolderType', + 'foreign_key' => fn($record) => [$record->getId(), 'ResponseFolder'], + 'on_delete' => 'delete' + ]; + $config['has_one']['feedback_folder'] = [ + 'class_name' => Folder::class, + 'assoc_foreign_key' => 'range_id', + 'assoc_func' => 'findByRangeIdAndFolderType', + 'foreign_key' => fn($record) => [$record->getId(), 'FeedbackFolder'], + 'on_delete' => 'delete' + ]; + + $config['belongs_to']['exercise'] = [ + 'class_name' => Exercise::class, + 'foreign_key' => 'task_id' + ]; + $config['belongs_to']['assignment'] = [ + 'class_name' => VipsAssignment::class, + 'foreign_key' => 'assignment_id' + ]; + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } + + /** + * Update the gradebook entry. + */ + public function after_store(): void + { + $this->assignment->updateGradebookEntries($this->user_id); + } + + /** + * Set value for the "exercise" relation (to avoid SORM errors). + */ + public function setExercise(Exercise $exercise): void + { + $this->task_id = $exercise->id; + $this->relations['exercise'] = $exercise; + } + + /** + * Get array of submitted answers for this solution (PHP array). + */ + public function getResponse(): array + { + return $this->content['response']->getArrayCopy(); + } + + /** + * Check if this solution is archived. + */ + public function isArchived(): bool + { + $solution = VipsSolution::findOneBySql( + 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY id DESC', + [$this->task_id, $this->assignment_id, $this->user_id] + ); + + return $solution && $this->id != $solution->id; + } + + /** + * Check if this solution is empty (default response and no files). + */ + public function isEmpty(): bool + { + return $this->response == $this->exercise->defaultResponse() + && $this->student_comment == '' + && (!$this->folder || count($this->folder->file_refs) === 0); + } + + /** + * Check if this solution has been submitted (is not a dummy solution). + */ + public function isSubmitted(): bool + { + return $this->id && !$this->mkdate; + } + + /** + * Check if this solution has any corrector feedback (text or files). + */ + public function hasFeedback() + { + return $this->feedback + || ($this->feedback_folder && count($this->feedback_folder->file_refs) > 0); + } + + /** + * Return the total number of solutions (including archived ones) + * submitted by the same user for this exercise. + */ + public function countTries(): int + { + if ($this->isNew()) { + return 0; + } + + return VipsSolution::countBySql( + 'task_id = ? AND assignment_id = ? AND user_id = ?', + [$this->task_id, $this->assignment_id, $this->user_id] + ); + } +} diff --git a/lib/models/vips/VipsTest.php b/lib/models/vips/VipsTest.php new file mode 100644 index 0000000000000000000000000000000000000000..178b352d5e3267cc42a23c3a82e3f91db2538396 --- /dev/null +++ b/lib/models/vips/VipsTest.php @@ -0,0 +1,121 @@ +<?php +/* + * VipsTest.php - Vips test class for Stud.IP + * Copyright (c) 2014 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +/** + * @license GPL2 or any later version + * + * @property int $id database column + * @property string $title database column + * @property string $description database column + * @property string $user_id database column + * @property int $mkdate database column + * @property int $chdate database column + * @property string|null $options database column + * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment + * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef + * @property User $user belongs_to User + * @property SimpleORMapCollection|Exercise[] $exercises has_and_belongs_to_many Exercise + */ +class VipsTest extends SimpleORMap +{ + /** + * Configure the database mapping. + */ + protected static function configure($config = []) + { + $config['db_table'] = 'etask_tests'; + + // $config['serialized_fields']['options'] = 'JSONArrayObject'; + + $config['has_and_belongs_to_many']['exercises'] = [ + 'class_name' => Exercise::class, + 'assoc_foreign_key' => 'id', + 'thru_table' => 'etask_test_tasks', + 'thru_key' => 'test_id', + 'thru_assoc_key' => 'task_id', + 'order_by' => 'ORDER BY position' + ]; + + $config['has_many']['assignments'] = [ + 'class_name' => VipsAssignment::class, + 'assoc_foreign_key' => 'test_id' + ]; + $config['has_many']['exercise_refs'] = [ + 'class_name' => VipsExerciseRef::class, + 'assoc_foreign_key' => 'test_id', + 'on_delete' => 'delete', + 'order_by' => 'ORDER BY position' + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } + + public function addExercise(Exercise $exercise): VipsExerciseRef + { + $attributes = [ + 'task_id' => $exercise->id, + 'test_id' => $this->id, + 'position' => count($this->exercise_refs) + 1, + 'points' => $exercise->itemCount() + ]; + + $exercise_ref = VipsExerciseRef::create($attributes); + + $this->resetRelation('exercises'); + $this->resetRelation('exercise_refs'); + + return $exercise_ref; + } + + public function removeExercise(int $exercise_id): void + { + $db = DBManager::get(); + + $exercise_ref = VipsExerciseRef::find([$this->id, $exercise_id]); + $position = $exercise_ref->position; + + if ($exercise_ref->delete()) { + // renumber following exercises + $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?'; + $stmt = $db->prepare($sql); + $stmt->execute([$this->id, $position]); + } + + $this->resetRelation('exercises'); + $this->resetRelation('exercise_refs'); + } + + public function getExerciseRef(int $exercise_id): ?VipsExerciseRef + { + return $this->exercise_refs->findOneBy('task_id', $exercise_id); + } + + /** + * Return the maximum number of points a person can get on this test. + * + * @return integer number of maximum points + */ + public function getTotalPoints(): int + { + $points = 0; + + foreach ($this->exercise_refs as $exercise_ref) { + $points += $exercise_ref->points; + } + + return $points; + } +} diff --git a/lib/modules/VipsModule.php b/lib/modules/VipsModule.php new file mode 100644 index 0000000000000000000000000000000000000000..9c37c3a790959becff0014ef207194c3abcbef67 --- /dev/null +++ b/lib/modules/VipsModule.php @@ -0,0 +1,471 @@ +<?php +/* + * VipsModule.php - Vips plugin class for Stud.IP + * Copyright (c) 2007-2021 Elmar Ludwig + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + */ + +use Courseware\CoursewarePlugin; + +/** + * Vips plugin class for Stud.IP + */ +class VipsModule extends CorePlugin implements StudipModule, SystemPlugin, PrivacyPlugin, CoursewarePlugin +{ + public static ?bool $exam_mode = null; + public static ?VipsModule $instance = null; + public static ?Flexi\Factory $template_factory = null; + + public function __construct() + { + global $perm, $user; + + parent::__construct(); + + self::$instance = $this; + self::$template_factory = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/app/views/vips'); + + NotificationCenter::addObserver($this, 'userDidDelete', 'UserDidDelete'); + NotificationCenter::addObserver($this, 'courseDidDelete', 'CourseDidDelete'); + NotificationCenter::addObserver($this, 'userDidLeaveCourse', 'UserDidLeaveCourse'); + NotificationCenter::addObserver($this, 'userDidMigrate', 'UserDidMigrate'); + NotificationCenter::addObserver($this, 'statusgruppeUserDidCreate', 'StatusgruppeUserDidCreate'); + NotificationCenter::addObserver($this, 'statusgruppeUserDidDelete', 'StatusgruppeUserDidDelete'); + + Exercise::addExerciseType(_('Single Choice'), SingleChoiceTask::class, ['choice-single', '']); + Exercise::addExerciseType(_('Multiple Choice'), MultipleChoiceTask::class, 'choice-multiple'); + Exercise::addExerciseType(_('Multiple Choice Matrix'), MatrixChoiceTask::class, 'choice-matrix'); + Exercise::addExerciseType(_('Freie Antwort'), TextLineTask::class, 'text-line'); + Exercise::addExerciseType(_('Textaufgabe'), TextTask::class, 'text-area'); + Exercise::addExerciseType(_('Lückentext'), ClozeTask::class, ['cloze-input', 'cloze-select', 'cloze-drag']); + Exercise::addExerciseType(_('Zuordnung'), MatchingTask::class, ['matching', 'matching-multiple']); + Exercise::addExerciseType(_('Reihenfolge'), SequenceTask::class, 'sequence'); + + if ($perm->have_perm('root')) { + $nav_item = new Navigation(_('Klausuren'), 'dispatch.php/vips/config'); + Navigation::addItem('/admin/config/vips', $nav_item); + } + + if (Navigation::hasItem('/contents')) { + $nav_item = new Navigation(_('Aufgaben')); + $nav_item->setImage(Icon::create('vips')); + $nav_item->setDescription(_('Erstellen und Verwalten von Aufgabenblättern')); + Navigation::addItem('/contents/vips', $nav_item); + + $sub_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/pool/assignments'); + $nav_item->addSubNavigation('assignments', $sub_item); + + $sub_item = new Navigation(_('Aufgaben'), 'dispatch.php/vips/pool/exercises'); + $nav_item->addSubNavigation('exercises', $sub_item); + } + + // check for running exams + if (Config::get()->VIPS_EXAM_RESTRICTIONS && !isset(self::$exam_mode)) { + $courses = self::getCoursesWithRunningExams($user->id); + self::$exam_mode = count($courses) > 0; + + if (self::$exam_mode) { + $page = basename($_SERVER['PHP_SELF']); + $path_info = Request::pathInfo(); + $course_id = Context::getId(); + + // redirect page calls if necessary + if (match_route('dispatch.php/jsupdater/get')) { + // always allow jsupdater calls + UpdateInformation::setInformation('vips', ['exam_mode' => true]); + } else if (isset($course_id, $courses[$course_id])) { + // course with running exam is selected, allow all exam actions + if (!match_route('dispatch.php/vips/sheets')) { + header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets')); + sess()->save(); + die(); + } + } else if (count($courses) === 1) { + // only one course with running exam, redirect there + header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets', ['cid' => key($courses)])); + sess()->save(); + + die(); + } else if (!match_route('dispatch.php/vips/exam_mode')) { + // forward to overview of all running courses with exams + header('Location: ' . URLHelper::getURL('dispatch.php/vips/exam_mode')); + sess()->save(); + die(); + } + } else { + PageLayout::addHeadElement( + 'script', + [], + 'STUDIP.JSUpdater.register("vips", () => location.reload());' + ); + } + } + } + + /** + * Return whether or not the current user has the given status in a course. + * + * @param string $status status name: 'autor', 'tutor' or 'dozent' + * @param string $course_id course to check + */ + public static function hasStatus(string $status, string $course_id): bool + { + return $course_id && $GLOBALS['perm']->have_studip_perm($status, $course_id); + } + + /** + * Check whether or not the current user has the required status in a course. + * + * @param string $status required status: 'autor', 'tutor' or 'dozent' + * @param string $course_id course to check + * @throws AccessDeniedException if the requirement is not met, an exception is thrown + */ + public static function requireStatus(string $status, string $course_id): void + { + if (!VipsModule::hasStatus($status, $course_id)) { + throw new AccessDeniedException(_('Sie verfügen nicht über die notwendigen Rechte für diese Aktion.')); + } + } + + /** + * Checks whether or not the current user may view an assignment. + * + * @param VipsAssignment|null $assignment assignment to check + * @param int|null $exercise_id check that this exercise is on the assignment (optional) + * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown + */ + public static function requireViewPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void + { + if (!$assignment || !$assignment->checkViewPermission()) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ($exercise_id && !$assignment->hasExercise($exercise_id)) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!')); + } + } + + /** + * Checks whether or not the current user may edit an assignment. + * + * @param VipsAssignment|null $assignment assignment to check + * @param int|null $exercise_id check that this exercise is on the assignment (optional) + * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown + */ + public static function requireEditPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void + { + if (!$assignment || !$assignment->checkEditPermission()) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ($exercise_id && !$assignment->hasExercise($exercise_id)) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!')); + } + } + + /** + * Get all courses where the user is at least tutor and Vips is activated. + * + * @return array with all course ids, null if no courses + */ + public static function getActiveCourses(string $user_id): array + { + $plugin_manager = PluginManager::getInstance(); + $vips_plugin_id = VipsModule::$instance->getPluginId(); + + $sql = "JOIN seminar_user USING(Seminar_id) + WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor') + ORDER BY (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) DESC, Name"; + $courses = Course::findBySQL($sql, [$user_id]); + + // remove courses where Vips is not active + foreach ($courses as $key => $course) { + if (!$plugin_manager->isPluginActivated($vips_plugin_id, $course->id)) { + unset($courses[$key]); + } + } + + return $courses; + } + + /** + * Get all courses with currently running exams for the given user. + * + * @param string $user_id The user id + * + * @return array associative array of course ids and course names + */ + public static function getCoursesWithRunningExams(string $user_id): array + { + $db = DBManager::get(); + + $courses = []; + + $sql = "SELECT DISTINCT seminare.Seminar_id, seminare.Name, etask_assignments.id + FROM etask_assignments + JOIN seminar_user ON seminar_user.Seminar_id = etask_assignments.range_id + JOIN seminare USING(Seminar_id) + WHERE etask_assignments.type = 'exam' + AND etask_assignments.start <= UNIX_TIMESTAMP() + AND etask_assignments.end > UNIX_TIMESTAMP() + AND seminar_user.user_id = ? + AND seminar_user.status = 'autor' + ORDER BY seminare.Name"; + $stmt = $db->prepare($sql); + $stmt->execute([$user_id]); + + foreach ($stmt as $row) { + $assignment = VipsAssignment::find($row['id']); + $ip_range = $assignment->options['ip_range']; + + if ($assignment->isVisible($user_id)) { + if (strlen($ip_range) > 0 && $assignment->checkIPAccess($_SERVER['REMOTE_ADDR'])) { + $courses[$row['Seminar_id']] = $row['Name']; + } + } + } + + return $courses; + } + + public function setupExamNavigation() + { + $navigation = new Navigation(''); + + $start = Navigation::getItem('/start'); + $start->setURL('dispatch.php/vips/exam_mode'); + $navigation->addSubNavigation('start', $start); + + $course = new Navigation(_('Veranstaltung')); + $navigation->addSubNavigation('course', $course); + + $vips = new Navigation($this->getPluginName()); + $vips->setImage(Icon::create('vips')); + $course->addSubNavigation('vips', $vips); + + $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets'); + $vips->addSubNavigation('sheets', $nav_item); + + $links = new Navigation('Links'); + $links->addSubNavigation('logout', new Navigation(_('Logout'), 'logout.php')); + $navigation->addSubNavigation('links', $links); + + Config::get()->PERSONAL_NOTIFICATIONS_ACTIVATED = 0; + PageLayout::addStyle('#navigation-level-1, #navigation-level-2, #context-title { display: none; }'); + PageLayout::addCustomQuicksearch('<div style="width: 64px;"></div>'); + Navigation::setRootNavigation($navigation); + } + + public function getIconNavigation($course_id, $last_visit, $user_id) + { + if (VipsModule::hasStatus('tutor', $course_id)) { + // find all uncorrected exercises in finished assignments in this course + // Added JOIN with seminar_user to filter out lecturer/tutor solutions. + $new_items = VipsSolution::countBySql( + "JOIN etask_assignments ON etask_responses.assignment_id = etask_assignments.id + LEFT JOIN seminar_user + ON seminar_user.Seminar_id = etask_assignments.range_id + AND seminar_user.user_id = etask_responses.user_id + WHERE etask_assignments.range_id = ? + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND etask_assignments.end <= UNIX_TIMESTAMP() + AND etask_responses.state = 0 + AND IFNULL(seminar_user.status, 'autor') = 'autor'", + [$course_id] + ); + + $message = ngettext('%d unkorrigierte Lösung', '%d unkorrigierte Lösungen', $new_items); + } else { + // find all active assignments not yet seen by the student + $assignments = VipsAssignment::findBySQL( + "LEFT JOIN etask_assignment_attempts + ON etask_assignment_attempts.assignment_id = etask_assignments.id + AND etask_assignment_attempts.user_id = ? + WHERE etask_assignments.range_id = ? + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND etask_assignments.start <= UNIX_TIMESTAMP() + AND (etask_assignments.end IS NULL OR etask_assignments.end > UNIX_TIMESTAMP()) + AND etask_assignment_attempts.user_id IS NULL", + [$user_id, $course_id] + ); + + $new_items = 0; + + foreach ($assignments as $assignment) { + if ($assignment->isVisible($user_id)) { + ++$new_items; + } + } + + $message = ngettext('%d neues Aufgabenblatt', '%d neue Aufgabenblätter', $new_items); + } + + $overview_message = $this->getPluginName(); + $icon = Icon::create('vips'); + + if ($new_items > 0) { + $overview_message = sprintf($message, $new_items); + $icon = Icon::create('vips', Icon::ROLE_NEW); + } + + $icon_navigation = new Navigation($this->getPluginName(), 'dispatch.php/vips/sheets'); + $icon_navigation->setImage($icon->copyWithAttributes(['title' => $overview_message])); + + return $icon_navigation; + } + + public function getInfoTemplate($course_id) + { + return null; + } + + public function getTabNavigation($course_id) + { + $navigation = new Navigation($this->getPluginName()); + $navigation->setImage(Icon::create('vips')); + + $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets'); + $navigation->addSubNavigation('sheets', $nav_item); + + $nav_item = new Navigation(_('Ergebnisse'), 'dispatch.php/vips/solutions'); + $navigation->addSubNavigation('solutions', $nav_item); + + return ['vips' => $navigation]; + } + + public function getMetadata() + { + $metadata['category'] = _('Inhalte und Aufgabenstellungen'); + $metadata['displayname'] = _('Aufgaben und Prüfungen'); + $metadata['summary'] = + _('Erstellung und Durchführung von Ãœbungen, Tests und Klausuren'); + $metadata['description'] = + _('Mit diesem Werkzeug können Ãœbungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' . + 'Die Lehrenden erhalten eine Ãœbersicht darüber, welche Teilnehmenden eine Ãœbung oder einen ' . + 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Ãœbungszetteln ' . + 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' . + 'erhalten sofort einen Ãœberblick darüber, was noch zu korrigieren ist. Neben allgemein ' . + 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' . + 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.'); + $metadata['keywords'] = + _('Einsatz bei Hausaufgaben und Präsenzprüfungen; Reduzierter Arbeitsaufwand bei der Auswertung; ' . + 'Sortierte Ãœbersicht der eingereichten Ergebnisse; Single-, Multiple-Choice- und Textaufgaben, ' . + 'Lückentexte und Zuordnungen; Notwendige Korrekturen und erzielte Punktzahlen auf einen Blick'); + $metadata['icon'] = Icon::create('vips'); + + return $metadata; + } + + public function userDidDelete($event, $user) + { + // delete all personal assignments + VipsAssignment::deleteBySQL('range_id = ?', [$user->id]); + + // delete in etask_responses + VipsSolution::deleteBySQL('user_id = ?', [$user->id]); + + // delete start times and group memberships + VipsAssignmentAttempt::deleteBySQL('user_id = ?', [$user->id]); + VipsGroupMember::deleteBySQL('user_id = ?', [$user->id]); + } + + public function courseDidDelete($event, $course) + { + // delete all assignments in course + VipsAssignment::deleteBySQL('range_id = ?', [$course->id]); + + // delete other course related info + VipsBlock::deleteBySQL('range_id = ?', [$course->id]); + } + + public function userDidLeaveCourse($event, $course_id, $user_id) + { + // terminate group membership when leaving a course + $group_member = VipsGroupMember::findOneBySQL( + 'JOIN statusgruppen ON statusgruppe_id = group_id WHERE range_id = ? AND user_id = ? AND end IS NULL', + [$course_id, $user_id] + ); + + if ($group_member) { + $group_member->end = time(); + $group_member->store(); + } + } + + public function userDidMigrate($event, $user_id, $new_id) + { + $db = DBManager::get(); + + $db->execute('UPDATE IGNORE etask_assignment_attempts SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]); + $db->execute('UPDATE etask_tasks SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]); + + $db->execute('UPDATE IGNORE etask_responses SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]); + $db->execute('UPDATE etask_tests SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]); + } + + public function statusgruppeUserDidCreate($event, $statusgruppe_user) + { + VipsGroupMember::create([ + 'group_id' => $statusgruppe_user->statusgruppe_id, + 'user_id' => $statusgruppe_user->user_id, + 'start' => time() + ]); + } + + public function statusgruppeUserDidDelete($event, $statusgruppe_user) + { + $member = VipsGroupMember::findOneBySQL( + 'group_id = ? AND user_id = ? AND end IS NULL', + [$statusgruppe_user->statusgruppe_id, $statusgruppe_user->user_id] + ); + + if ($member) { + $member->end = time(); + $member->store(); + } + } + + /** + * Export available data of a given user into a storage object + * (an instance of the StoredUserData class) for that user. + * + * @param StoredUserData $store object to store data into + */ + public function exportUserData(StoredUserData $store) + { + $db = DBManager::get(); + + $data = $db->fetchAll('SELECT * FROM etask_group_members WHERE user_id = ?', [$store->user_id]); + $store->addTabularData(_('Aufgaben-Gruppenzuordnung'), 'etask_group_members', $data); + } + + /** + * Implement this method to register more block types. + * + * You get the current list of block types and return an updated list + * containing your own block types. + */ + public function registerBlockTypes(array $otherBlockTypes): array + { + $otherBlockTypes[] = Courseware\BlockTypes\TestBlock::class; + + return $otherBlockTypes; + } + + /** + * Implement this method to register more container types. + * + * You get the current list of container types and return an updated list + * containing your own container types. + */ + public function registerContainerTypes(array $otherContainerTypes): array + { + return $otherContainerTypes; + } +} diff --git a/public/assets/images/choice_checked.svg b/public/assets/images/choice_checked.svg new file mode 100644 index 0000000000000000000000000000000000000000..ba483b556e4e417c0e2329b8eeb1f9ca5c32e998 --- /dev/null +++ b/public/assets/images/choice_checked.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.986 1.337a6.676 6.676 0 1 0 0 13.352 6.676 6.676 0 1 0 0-13.352m0 11.894a5.219 5.219 0 1 1 0-10.437 5.219 5.219 0 0 1 0 10.437"/><path fill="#28497C" d="m15.985 13.943-5.93-5.93 5.93-5.93L13.917.014l-5.931 5.93L2.054.013l-2.069 2.07 5.932 5.93-5.932 5.931 2.069 2.07 5.932-5.932 5.932 5.93z"/></svg> \ No newline at end of file diff --git a/public/assets/images/choice_unchecked.svg b/public/assets/images/choice_unchecked.svg new file mode 100644 index 0000000000000000000000000000000000000000..4fa3b2a13fcb020a46ec836e68a54d8306ac8d87 --- /dev/null +++ b/public/assets/images/choice_unchecked.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.999 1.337a6.676 6.676 0 0 0 0 13.352 6.676 6.676 0 1 0 0-13.352m0 11.894a5.218 5.218 0 1 1 .002-10.436A5.218 5.218 0 0 1 8 13.23"/></svg> \ No newline at end of file diff --git a/public/assets/images/collapse.svg b/public/assets/images/collapse.svg new file mode 100644 index 0000000000000000000000000000000000000000..9aba4d51c09b07b2e2a982673c11d90103eb4cf5 --- /dev/null +++ b/public/assets/images/collapse.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16"><path fill="#6C737B" d="m4.884 10.877 3.115-3.116 3.119 3.116 1.32-1.318L8.001 5.12h-.002L3.563 9.559z"/><path fill="#6C737B" d="M0-.001V16h16V-.001zm14.434 14.435H1.567V1.565h12.866z"/></svg> \ No newline at end of file diff --git a/public/assets/images/expand.svg b/public/assets/images/expand.svg new file mode 100644 index 0000000000000000000000000000000000000000..12c5fa1e73f4675045a8b8daf6c3c17b36a6ba45 --- /dev/null +++ b/public/assets/images/expand.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16"><path fill="#6C737B" d="M11.118 5.571 8 8.687 4.882 5.571 3.563 6.892l4.436 4.438h.003l4.436-4.438z"/><path fill="#6C737B" d="M0-.001V16h15.999V-.001zm14.435 14.435H1.566V1.565h12.868z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/black/vips.svg b/public/assets/images/icons/black/vips.svg new file mode 100644 index 0000000000000000000000000000000000000000..fdd66a711365c7511c1c6206a422d5d2c5c2375d --- /dev/null +++ b/public/assets/images/icons/black/vips.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16h-9.83a1.917 1.917 0 0 1-1.923-1.907V4.329c0-1.053.864-1.908 1.923-1.908h9.83c.476 0 .911.171 1.245.458l-.982.983a.54.54 0 0 0-.263-.067h-9.83a.536.536 0 0 0-.539.535v9.764c0 .296.24.534.539.534h9.83c.297 0 .54-.238.54-.534V8.69z"/><path d="m8.342 11.406 7.663-7.664-1.32-1.32-7.665 7.665L3.902 6.97l-1.32 1.32 4.437 4.438h.001l1.066-1.066z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/assessment-mc.svg b/public/assets/images/icons/blue/assessment-mc.svg new file mode 100644 index 0000000000000000000000000000000000000000..cb919d8f68ba6f735148efefc600d835336f1a83 --- /dev/null +++ b/public/assets/images/icons/blue/assessment-mc.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="#28497c"><path d="M4.622 5.865H1.017V2.262h3.604zM1.55 5.331h2.536V2.795H1.55zm3.072 4.472H1.017V6.199h3.604zM1.55 9.268h2.536V6.73H1.55zm3.072 4.47H1.017v-3.604h3.604zm-3.072-.534h2.536V10.67H1.55zM6.041 3.051h8.941v2.137H6.041zm0 3.892h8.941v2.135H6.041zm0 3.912h8.941v2.136H6.041z"/><path d="m5.248 6.887-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425z"/><path d="m5.248 6.887-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425zm0 3.876-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425Z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/edit-line.svg b/public/assets/images/icons/blue/edit-line.svg new file mode 100644 index 0000000000000000000000000000000000000000..b8e2f4acd22f2a40dc081ea47332e7345b8e1f53 --- /dev/null +++ b/public/assets/images/icons/blue/edit-line.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 54 54"><g fill="#28497c"><path d="M47.91 4.8A6.3 6.3 0 0 0 39.47 7l-1 1.72-1.53 2.55 10.7 5.95 1.53-2.55 1-1.72a5.83 5.83 0 0 0-2.26-8.15m-1.44 8.36-5.35-3 1-1.71a3.15 3.15 0 0 1 4.22-1.09 2.92 2.92 0 0 1 1.13 4.06Zm-3.08 5.13-5.34-2.98-.9-.49-1.78-1L23 34.47 23.13 46l10.57-5.58 12.37-20.64-1.79-.99z"/><path d="M32 9v38H6V9zm3-3H3v44h32z"/><path d="M9 11.97h20v4H9z"/></g></svg> \ No newline at end of file diff --git a/public/assets/images/icons/blue/vips.svg b/public/assets/images/icons/blue/vips.svg new file mode 100644 index 0000000000000000000000000000000000000000..35dae000eab35460d9243a6bbf9f75c79696f6f3 --- /dev/null +++ b/public/assets/images/icons/blue/vips.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#24437C" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#24437C" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/red/vips.svg b/public/assets/images/icons/red/vips.svg new file mode 100644 index 0000000000000000000000000000000000000000..49c4caea81f7236df7092a3c7e1eb9b7b32e2868 --- /dev/null +++ b/public/assets/images/icons/red/vips.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#D60000" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#D60000" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file diff --git a/public/assets/images/icons/white/vips.svg b/public/assets/images/icons/white/vips.svg new file mode 100644 index 0000000000000000000000000000000000000000..516901d8f1e12c0a7c6be5efac465a79be26e273 --- /dev/null +++ b/public/assets/images/icons/white/vips.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#FFF" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#FFF" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png new file mode 100644 index 0000000000000000000000000000000000000000..067324574fdcf67d5d1d1b3784d4dd63d0697267 Binary files /dev/null and b/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png differ diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png new file mode 100644 index 0000000000000000000000000000000000000000..54322a1a5a7fcbb8e4307d9c72f39d55695202ac Binary files /dev/null and b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png differ diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png new file mode 100644 index 0000000000000000000000000000000000000000..7b1c2b2b84c4f2680c923ea8d828ee4a82f77dd0 Binary files /dev/null and b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png differ diff --git a/resources/assets/javascripts/bootstrap/vips.js b/resources/assets/javascripts/bootstrap/vips.js new file mode 100644 index 0000000000000000000000000000000000000000..69eb34c5dd8ca640001529c2b62cd99efaa3591c --- /dev/null +++ b/resources/assets/javascripts/bootstrap/vips.js @@ -0,0 +1,336 @@ +import { $gettext } from "../lib/gettext"; + +$(function() { + if ($('#exam_timer').length > 0) { + const exam_timer = $('#exam_timer'); + const user_end_time = exam_timer.data('time') + Math.floor(Date.now() / 1000); + const timer_id = setInterval(() => { + const remaining_time = user_end_time - Math.floor(Date.now() / 1000); + + // update timer + exam_timer.children('.time').text(Math.round(remaining_time / 60)); + + if (remaining_time < 180 && !exam_timer.hasClass('alert')) { + exam_timer.addClass('alert'); + } + + if (remaining_time < 0) { + if (document.jsfrm) { + clearInterval(timer_id); + document.jsfrm.removeAttribute('data-secure'); + document.jsfrm.forced.value = 1; + document.jsfrm.submit(); + } else { + location.reload(); + } + } + }, 1000); + + exam_timer.draggable(); + } + + if ($('#list').length > 0) { + const assignment = $('#list').data('assignment'); + + $('#list').sortable({ + axis: 'y', + containment: 'parent', + handle: '.drag-handle', + helper(event, element) { + element.children().width((index, width) => width); + + return element; + }, + tolerance: 'pointer', + update() { + $.post( + STUDIP.URLHelper.getURL('dispatch.php/vips/sheets/move_exercise', { assignment_id: assignment }), + $('#list').sortable('serialize') + ); + } + }); + + $('#list > tr').on('keydown', function (event) { + if (event.key === 'ArrowUp' && event.target === this) { + $(this).prev().before(this); + } else if (event.key === 'ArrowDown' && event.target === this) { + $(this).next().after(this); + } else { + return; + } + + $(this).focus(); + $('#list').sortable('option').update(); + event.preventDefault(); + }); + } + + $(document).on('click', '.add_ip_range', function (event) { + const input = $(this).closest('fieldset').find('input[name=ip_range]'); + + input.val(input.val() + ' ' + $(this).attr('data-value')); + event.preventDefault(); + }); + + $(document).on('input', '.validate_ip_range', function () { + const ip_ranges = $(this).val().split(/[ ,]+/); + let message = ''; + + for (const ip_range of ip_ranges) { + if ( + ip_range.length > 0 + && ip_range.charAt(0) !== '#' + && !ip_range.match(/^[\d.]+(\/\d+|-[\d.]+)?$/) + && !ip_range.match(/^[\da-fA-F:]+(\/\d+|-[\da-fA-F:]+)?$/) + ) { + message = $gettext('Der IP-Zugriffsbereich ist ungültig.'); + } + } + + this.setCustomValidity(message); + }); + + $(document).on('click', '.vips_file_upload', function (event) { + $(this).closest('form').find('.file_upload').click(); + event.preventDefault(); + }); + + $(document).on('change', '.file_upload.attach', function () { + const button = $(this).closest('form').find('.vips_file_upload'); + + if (this.files && this.files.length > 1) { + button.text(button.data('label').replace('%d', this.files.length)); + button.next('.file_upload_hint').show(); + } else if (this.files) { + button.text(this.files[0].name); + button.next('.file_upload_hint').show(); + } + }); + + $(document).on('change', '.file_upload.inline', function (event) { + const textarea = $(this).closest('form').find('.download'); + const reader = new FileReader(); + + if (this.files && this.files.length > 0) { + reader.onload = function () { + textarea.val(reader.result); + }; + reader.onerror = function () { + STUDIP.Dialog.show(reader.error.message, { + title: $gettext('Fehler beim Hochladen'), + size: 'fit', + wikilink: false, + dialogClass: 'studip-confirmation' + }); + } + reader.readAsText(this.files[0]); + } + event.preventDefault(); + }); + + $(document).on('click', '.vips_file_download', function (event) { + const text = $(this).closest('form').find('.download').val(); + const link = $(this).closest('form').find('a[download]'); + const blob = new Blob([text], {type: 'text/plain; charset=UTF-8'}); + + link.attr('href', URL.createObjectURL(blob)); + link[0].click(); + event.preventDefault(); + }); + + $('.sortable_list').sortable({ + axis: 'y', + containment: 'parent', + items: '> .sortable_item', + tolerance: 'pointer' + }); + + $(document).on('keydown', '.sortable_item', function (event) { + if (event.key === 'ArrowUp' && event.target === this) { + $(this).prev('.sortable_item:visible').before(this); + } else if (event.key === 'ArrowDown' && event.target === this) { + $(this).next('.sortable_item:visible').after(this); + } else { + return; + } + + $(this).focus(); + event.preventDefault(); + }); + + $(document).on('click', '.textarea_toggle', function (event) { + const toggle = $(this).closest('.size_toggle'); + const items = toggle.find('.character_input'); + + const name = items[0].name; + items[0].name = items[1].name; + items[1].name = name; + + const value = items[0].value; + items[0].value = items[1].value; + items[1].value = value; + + if (STUDIP.wysiwyg.getEditor && STUDIP.wysiwyg.getEditor(items[1])) { + STUDIP.wysiwyg.getEditor(items[1]).setData(value); + } + + toggle.toggleClass('size_large').toggleClass('size_small'); + event.preventDefault(); + }); + + $(document).on('change', '.tb_layout', function () { + const toggle = $(this).closest('fieldset').find('.size_toggle'); + + toggle.find('.small_input').toggleClass('monospace', $(this).val() === 'code'); + + if ( + $(this).val() === '' && toggle.hasClass('size_large') + || $(this).val() === 'code' && toggle.hasClass('size_large') + || $(this).val() === 'markup' && toggle.hasClass('size_small') + ) { + toggle.find('.textarea_toggle').click(); + } + }); + + $(document).on('click', '.choice_list .add_dynamic_row', function () { + $(this).closest('fieldset').find('.choice_select').each(function () { + const template = $(this).children('.template').last(); + const clone = template.clone(true).removeClass('template'); + const index = template.data('index'); + + template.data('index', index + 1); + clone.insertBefore(template); + clone.find('input[data-value]').each(function () { + $(this).attr('value', index); + $(this).removeAttr('data-value'); + }); + }); + }); + + $(document).on('change', '.choice_list input', function () { + const index = $(this).closest('.dynamic_row').data('index'); + const items = $(this).closest('fieldset').find('.choice_select'); + + items.children().filter(function () { + return $(this).data('index') === index; + }).children('span').text($(this).val()); + }); + + $(document).on('click', '.choice_list .delete_dynamic_row', function () { + const index = $(this).closest('.dynamic_row').data('index'); + const items = $(this).closest('fieldset').find('.choice_select'); + + items.children().filter(function () { + return $(this).data('index') === index; + }).remove(); + }); + + $('.dynamic_list').each(function () { + $(this).children('.dynamic_row').each(function (i) { + $(this).data('index', i); + }); + }); + + $(document).on('click', '.add_dynamic_row', function (event) { + const container = $(this).closest('.dynamic_list'); + const template = container.children('.template').last(); + const clone = template.clone(true).removeClass('template'); + const index = template.data('index'); + + template.data('index', index + 1); + clone.insertBefore(template); + clone.find('input[data-name], select[data-name], textarea[data-name]').each(function () { + if ($(this).data('name').indexOf(':') === 0) { + $(this).data('name', $(this).data('name').substr(1) + '[' + index + ']'); + } else { + $(this).attr('name', $(this).data('name') + '[' + index + ']'); + $(this).removeAttr('data-name'); + } + }); + clone.find('input[data-value], select[data-value], textarea[data-value]').each(function () { + if ($(this).data('value').indexOf(':') === 0) { + $(this).data('value', $(this).data('value').substr(1)); + } else { + $(this).attr('value', index); + $(this).removeAttr('data-value'); + } + }); + clone.find('.wysiwyg-hidden:not(.template *)').toggleClass('wysiwyg wysiwyg-hidden'); + clone.find('.add_dynamic_row:visible').click(); + event.preventDefault(); + }); + + $(document).on('click', '.delete_dynamic_row', function (event) { + $(this).closest('.dynamic_row').remove(); + event.preventDefault(); + }); + + $(document).on('click', '.solution-toggle', function (event) { + if ($(this).closest('.solution').length > 0) { + $(this).closest('.solution').toggleClass('solution-closed'); + } else if ($('.arrow_all').first().css('display') !== 'none') { + $('.arrow_all').toggle(); + $('.solution').removeClass('solution-closed'); + } else { + $('.arrow_all').toggle(); + $('.solution').addClass('solution-closed'); + } + + $(document.body).trigger('sticky_kit:recalc'); + event.preventDefault(); + }); + + $(document).on('click', '.edit_solution', function (event) { + const tabs = $(this).closest('.vips_tabs'); + + tabs.removeClass('edit-hidden'); + tabs.find('.wysiwyg').attr('name', 'commented_solution'); + tabs.tabs('option', 'active', 0); + event.preventDefault(); + }); + + // add select2 to modal dialog including selects with optgroups + $(document).on('dialog-open', function (event, parameters) { + $('.vips_nested_select').select2({ + minimumResultsForSearch: 12, + dropdownParent: $(parameters.dialog).closest('.ui-dialog, body'), + matcher(params, data) { + const originalMatcher = $.fn.select2.defaults.defaults.matcher; + const result = originalMatcher(params, data); + + if (result && result.children && data.children && data.children.length) { + if (data.children.length !== result.children.length && + data.text.toLowerCase().includes(params.term.toLowerCase())) { + result.children = data.children; + } + } + + return result; + } + }); + }); + + $('.assignment_type').change(function () { + $('#assignment').attr('class', $(this).val()); + + if ($(this).val() === 'exam') { + $('#exam_length input').attr('disabled', null); + } else { + $('#exam_length input').attr('disabled', 'disabled'); + } + + if ($(this).val() === 'selftest') { + $('#end_date input').attr('required', null); + $('#end_date span').removeClass('required'); + } else { + $('#end_date input').attr('required', 'required'); + $('#end_date span').addClass('required'); + } + }); + + $('.rh_select_type').change(function () { + $(this).parent().next('table').toggleClass('rh_single'); + }); + + STUDIP.Vips.vips_post_render(document); +}); diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index 102a558fde117174efd202ca3f9ac771081c792d..2210471267641f82a72d8eea3d4e75189873cdb2 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -77,6 +77,7 @@ import "./bootstrap/admin-courses.js" import "./bootstrap/oer.js" import "./bootstrap/courseware.js" import "./bootstrap/external_pages.js" +import "./bootstrap/vips.js" import "./mvv_course_wizard.js" import "./mvv.js" diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index 2103ca2e5d8642a65b10bc1bd55f42a9a1e6ac92..4af6ed9659ddb8be41fdff57b11a66df7b8aa764 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -78,6 +78,7 @@ 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'; +import * as Vips from './lib/vips.js'; const configURLHelper = _.get(window, 'STUDIP.URLHelper', {}); const URLHelper = createURLHelper(configURLHelper); @@ -165,5 +166,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { domReady, dialogReady, ScrollToTop, + Vips, Vue, }); diff --git a/resources/assets/javascripts/lib/vips.js b/resources/assets/javascripts/lib/vips.js new file mode 100644 index 0000000000000000000000000000000000000000..aaff2599037297c10e73b77ba83dc3ca7d8ea83c --- /dev/null +++ b/resources/assets/javascripts/lib/vips.js @@ -0,0 +1,122 @@ +function vips_post_render(element) { + $(element).find('.rh_list').sortable({ + tolerance: 'pointer', + connectWith: '.rh_list', + update(event, ui) { + if (ui.sender) { + ui.item.find('input').val($(this).data('group')); + } + }, + over() { + $(this).addClass('hover'); + }, + out() { + $(this).removeClass('hover'); + }, + receive(event, ui) { + const sortable = $(this).not('.multiple'); + const container = sortable.closest('.rh_table').find('.answer_container'); + + // default answer container can have more items + if (sortable.children().length > 1 && !sortable.is(container)) { + sortable.find('.rh_item').each(function () { + if (!ui.item.is(this)) { + $(this).find('input').val(-1); + $(this).detach().appendTo(container) + .css('opacity', 0).animate({opacity: 1}); + } + }); + } + }, + }); + + $(element).find('.rh_item').on('keydown', function (event) { + const sortable = $(this).parent(); + const container = sortable.closest('.rh_table').find('.answer_container'); + let target = $(); + + if (sortable.is('.mc_list')) { + if (event.key === 'ArrowUp') { + $(this).prev().before(this); + $(this).focus(); + event.preventDefault(); + } else if (event.key === 'ArrowDown') { + $(this).next().after(this); + $(this).focus(); + event.preventDefault(); + } + } else if (sortable.is(container)) { + if (event.key === 'ArrowLeft') { + target = sortable.parent().find('.rh_list').first(); + } + } else { + if (event.key === 'ArrowRight') { + target = container; + } else if (event.key === 'ArrowUp') { + target = sortable.parent().prev().find('.rh_list').first(); + } else if (event.key === 'ArrowDown') { + target = sortable.parent().next().find('.rh_list').first(); + } + } + + if (target.length) { + $(this).find('input').val(target.data('group')); + $(this).appendTo(target).focus(); + event.preventDefault(); + } + }); + + $(element).find('.cloze_select').filter(':contains("\\\\(")').each(function () { + STUDIP.loadChunk('mathjax').then(({ Hub }) => { + Hub.Queue(['Typeset', Hub, this]); + }); + }).select2({ + minimumResultsForSearch: -1, + templateResult(data) { + if ($(data.element).children('.MathJax').length) { + return $(data.element).children('.MathJax').clone(); + } else { + return data.text; + } + }, + templateSelection(data) { + if ($(data.element).children('.MathJax').length) { + return $(data.element).children('.MathJax').clone(); + } else { + return data.text; + } + } + }); + + $(element).find('.cloze_item').draggable({ + revert: 'invalid' + }); + + $(element).find('.cloze_drop').droppable({ + accept: '.cloze_item', + tolerance: 'pointer', + classes: { + 'ui-droppable-hover': 'hover' + }, + drop(event, ui) { + const container = $(this).closest('fieldset').find('.cloze_items'); + + if (!$(this).is(container)) { + $(this).find('.cloze_item').detach().appendTo(container) + .css('opacity', 0).animate({opacity: 1}) + } + + ui.draggable.closest('.cloze_drop').find('input').val(''); + ui.draggable.detach().css({top: 0, left: 0}).appendTo(this); + $(this).find('input').val(ui.draggable.attr('data-value')); + } + }); + + $(element).find('.vips_tabs').each(function () { + $(this).tabs({ + active: $(this).hasClass('edit-hidden') ? 1 : 0 + }); + }) +} + +export { vips_post_render }; diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index db400c2bc548eb3a5492066953c9e72de5afc71f..55171f5de543ba4c78e6aa4d9897a26a88df4f9f 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -25,7 +25,7 @@ &:hover, &:active, &.active { - background: var(--color--button-focus); + background-color: var(--color--button-focus); color: var(--color--font-inverted); } diff --git a/resources/assets/stylesheets/scss/courseware/variables.scss b/resources/assets/stylesheets/scss/courseware/variables.scss index 033c99fa0059cb956efb098ed74dfe5c0e9be767..b3da454fb18956053c25af848ca24f2d8540c1db 100644 --- a/resources/assets/stylesheets/scss/courseware/variables.scss +++ b/resources/assets/stylesheets/scss/courseware/variables.scss @@ -74,6 +74,7 @@ $blockadder-items: ( key-point: exclaim-circle, link: link-extern, table-of-contents: table-of-contents, + test: check-circle, text: edit, timeline: date-cycle, typewriter: block-typewriter, diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss index 5c8f3a4d68c330ca71bc9635ee84f9222dd92273..1cb037fb1c78185158dd3c1a5793c1014cb4cc44 100644 --- a/resources/assets/stylesheets/scss/forms.scss +++ b/resources/assets/stylesheets/scss/forms.scss @@ -136,6 +136,7 @@ form.default { } .formpart { + display: block; margin-bottom: $gap; output.calculator_result { @@ -198,7 +199,7 @@ form.default { margin-top: 2ex; } - fieldset { + fieldset:not(.undecorated) { box-sizing: border-box; border: solid 1px var(--color--fieldset-border); margin: 0 0 15px; @@ -229,6 +230,16 @@ form.default { } } + fieldset.undecorated { + border: none; + margin: 0; + padding: 0; + + > legend { + margin-bottom: 0.5ex; + } + } + .selectbox { padding: 5px; max-height: 200px; diff --git a/resources/assets/stylesheets/scss/jquery-ui/studip.scss b/resources/assets/stylesheets/scss/jquery-ui/studip.scss index db487f0d4c3d8280445ff573d5e6af8b775e72fa..002eb1716cfc137152ba1e0ca725232581e68898 100644 --- a/resources/assets/stylesheets/scss/jquery-ui/studip.scss +++ b/resources/assets/stylesheets/scss/jquery-ui/studip.scss @@ -209,3 +209,45 @@ textarea.ui-resizable-handle.ui-resizable-s { background-color: var(--base-color); } } + +.ui-tabs { + &.ui-widget-content { + border: 1px solid var(--light-gray-color-40); + margin-top: 1.5ex; + padding: 0; + } + + .ui-tabs-nav { + background: none; + border: none; + border-bottom: 1px solid var(--light-gray-color-40); + } + + .ui-tabs-tab { + background: none; + border: none; + } + + .ui-tabs-tab:hover { + border-bottom: 3px solid var(--dark-gray-color-40); + } + + .ui-tabs-nav li.ui-tabs-active { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 3px solid var(--light-gray-color-80); + } + + .ui-tabs-tab .ui-tabs-anchor { + color: var(--base-color); + padding: 5px 15px; + } + + .ui-tabs-active .ui-tabs-anchor { + color: black; + } + + .ui-tabs-panel { + padding: 5px; + } +} diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss index 1fcd1ce8392392b980b4a15df68146ad0b7827ce..0fcc32a74ab276358ea3cbf51b1ca966cf2e483d 100644 --- a/resources/assets/stylesheets/scss/sidebar.scss +++ b/resources/assets/stylesheets/scss/sidebar.scss @@ -179,7 +179,7 @@ div#sidebar-navigation { .widget-links { margin: 5px; > li img { - vertical-align: text-top; + vertical-align: top; } a { display: block; diff --git a/resources/assets/stylesheets/scss/tables.scss b/resources/assets/stylesheets/scss/tables.scss index 32a26c290380767b172c7d7b1205fa35189dbd6f..788392df104dfb09aa15d356f34069a11332722d 100644 --- a/resources/assets/stylesheets/scss/tables.scss +++ b/resources/assets/stylesheets/scss/tables.scss @@ -85,6 +85,10 @@ td.blanksmall { background-color: var(--color--global-background); } +table.fixed { + table-layout: fixed; +} + td.tree-indent { img, svg { vertical-align: bottom; @@ -476,6 +480,15 @@ table.default { white-space: nowrap; } + img { + vertical-align: text-bottom; + } + + input[type="text"], textarea { + padding-bottom: 2px; + padding-top: 2px; + } + padding: 10px 5px; text-align: left; @@ -715,7 +728,7 @@ table.default { } } - tfoot { + tfoot, th { // Fix button and select alignment select { vertical-align: middle; diff --git a/resources/assets/stylesheets/scss/vips.scss b/resources/assets/stylesheets/scss/vips.scss new file mode 100644 index 0000000000000000000000000000000000000000..f11afda08e495d5fab24923e1e44d1fe1eed95ef --- /dev/null +++ b/resources/assets/stylesheets/scss/vips.scss @@ -0,0 +1,592 @@ +form.default { + .inline_select { + height: 32px; + width: auto; + } + + .label-text { + display: block; + margin: 1.5ex 0 0.5ex 0; + text-indent: 0.25ex; + } + + .vips_nested_select { + transition: inherit; + } + + input.cloze_input { + margin: 2px; + padding-bottom: 2px; + padding-top: 2px; + } + + select.cloze_select { + height: auto; + margin: 2px; + width: auto; + } + + input.percent_input { + text-align: right; + width: 4em; + } + + label:not(.undecorated) .select2-container { + display: block; + } +} + +button.vips_file_upload { + @include background-icon(upload); + background-position: 0.5em center; + background-repeat: no-repeat; + background-size: var(--icon-size-inline); + padding-left: 30px; + + &:hover { + @include background-icon(upload, info_alt); + } +} + +progress.assignment { + appearance: none; + background: var(--light-gray-color-20); + border: none; + height: 8px; + width: 120px; + + &::-moz-progress-bar { + background: var(--base-color); + } + &::-webkit-progress-bar { + background: var(--light-gray-color-20); + } + &::-webkit-progress-value { + background: var(--base-color); + } +} + +.vips-teaser { + background-color: var(--content-color-20); + background-image: url(../images/icons/blue/vips.svg); + background-position: 64px 50%; + background-repeat: no-repeat; + background-size: 120px; + max-width: 562px; + padding: 24px 24px 24px 244px; + + header { + font-size: 1.5em; + margin-bottom: 0.5em; + } +} + +.width-1200 { + max-width: 1200px; +} + +.breadcrumb { + margin-bottom: 1ex; + + img { + vertical-align: text-bottom; + } +} + +.smaller { + font-size: smaller; +} + +.monospace, +.vips_tabs .monospace { + font-family: monospace; +} + +.vips_tabs .vips_output { + background: none; +} + +.vips_output { + background-color: var(--dark-gray-color-5); + max-height: 30em; + min-height: 1em; + overflow-y: auto; + padding: 3px; + + pre { + margin: 2px; + white-space: pre-wrap; + } +} + +.sidebar_exercise_label { + display: inline-block; + width: 120px; +} + +.sidebar_exercise_points { + display: inline-block; + text-align: right; + width: 80px; +} + +.sidebar_exercise_state { + display: inline-block; + text-align: right; + width: 32px; +} + +.sortable .gradebook_header { + font-size: smaller; + max-width: 8em; + overflow-x: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sortable_item { + padding-left: 2ex; +} + +.exercise_types { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + max-width: 50em; +} + +.exercise_type { + background-color: transparent; + background-position: 0.5em center; + background-repeat: no-repeat; + border: 1px solid var(--color--fieldset-border); + color: var(--base-color); + cursor: pointer; + margin-top: 1.5ex; + min-height: 50px; + padding: 4px 4px 4px 56px; + text-align: left; + width: 342px; + + &:hover { + border: 1px solid var(--brand-color-dark); + color: var(--active-color); + } +} + +.exercise .points { + float: right; + font-size: 14px; + font-weight: normal; +} + +#exercises .points { + text-align: right; + width: 4em; +} + +.exercise_hint { + background: var(--activity-color-20); + border: 1px dotted var(--dark-gray-color-75); + display: inline-block; + margin-top: 1.5ex; + padding: 1ex; + + > h4 { + margin-top: 0px; + } +} + +#exam_timer { + background-color: var(--white); + border: 3px solid var(--red); + cursor: move; + font-size: 1.1em; + left: calc(100% - 134px); + margin: 3px; + padding: 3px; + position: fixed; + top: 0px; + white-space: nowrap; + z-index: 10001; + + &.alert { + color: var(--red); + } +} + +.inline-block { + display: inline-block; +} + +.inline-content .formatted-content { + display: inline-block; + vertical-align: top; + + > p:last-child { + margin-bottom: 0; + } +} + +.print_settings { + background-color: var(--activity-color-20); + border-bottom: 1px dotted var(--dark-gray-color-75); + display: none; + margin: -2em -2em 1em -2em; + padding: 1ex 2em; + + label { + margin-left: 1em; + } +} + +.choice_select { + display: inline-block; + padding: 0 1ex; +} + +.rh_single .rh_add_answer:not(:nth-child(2)) { + display: none; +} + +.rh_table { + border-spacing: 3em 1em; + margin-left: -2em; +} + +.rh_list { + background-color: var(--dark-gray-color-5); + border: 1px dashed var(--dark-gray-color-45); + min-width: 160px; + + &.hover { + border-color: var(--black); + } +} + +.rh_label { + padding-bottom: 2ex; + padding-top: 2ex; +} + +.rh_item { + background-color: var(--white); + border: 1px solid var(--base-color-20); + margin: 4px; + padding: 1ex 1ex 1ex 2ex; + + &:hover { + border-color: var(--dark-gray-color-45); + } +} + +.cloze_drop { + background-color: var(--dark-gray-color-5); + border: 1px dashed var(--dark-gray-color-45); + display: inline-block; + min-height: 32px; + min-width: 80px; + vertical-align: middle; + white-space: normal; + + &.hover { + border-color: var(--black); + } + + &.cloze_items { + display: block; + margin-top: 1em; + padding: 2px; + } +} + +.cloze_item { + background-color: var(--white); + background-size: auto 22px; + border: 1px solid var(--base-color-20); + display: inline-block; + margin: 2px; + min-width: 48px; + padding: 3px 1ex 3px 2ex; + + &:hover { + border-color: var(--dark-gray-color-45); + } +} + +.mc_row { + margin: 1ex 0; + + img, + input[type="image"] { + vertical-align: text-bottom; + } +} + +.mc_list { + display: inline-block; + margin-top: 1.5ex; + min-width: 32em; +} + +.mc_item { + padding: 4px; +} + +.mc_flex, +form.default label.mc_flex { + align-items: start; + column-gap: 6px; + display: flex; +} + +.mc_flex > img:first-child { + padding: 2px; +} + +.mc_flex > .formatted-content { + flex-grow: 1; +} + +.correct_item { + background: var(--green-20); + border: 1px solid var(--green-40); + padding: 3px; +} + +.fuzzy_item { + background: var(--yellow-20); + border: 1px solid var(--yellow-40); + padding: 3px; +} + +.wrong_item { + background: var(--red-20); + border: 1px solid var(--red-40); + padding: 3px; +} + +.neutral_item { + border: 1px dotted var(--dark-gray-color-30); + margin-right: 2.5em; + padding: 3px; +} + +.correction_marker { + float: right; + margin-left: 1em; + + &.sequence { + font-size: 20px; + margin-top: -15px; + } +} + +.correction_inline { + vertical-align: text-bottom; +} + +.group_separator { + border-top: 1px dotted var(--dark-gray-color-75); + margin-top: 1.5ex; + padding-top: 1.5ex; +} + +.dynamic_list { + counter-reset: vips_item; +} + +.dynamic_counter::before { + counter-increment: vips_item; + content: counter(vips_item) "."; +} + +.dynamic_row:first-child .hide_first { + display: none; +} + +.template { + display: none; +} + +.solution-close { + display: inline; +} + +.solution-open { + display: none; +} + +.solution-closed { + + tr { + display: none; + } + + .solution-close { + display: none; + } + + .solution-open { + display: inline; + } +} + +.solution { + vertical-align: top; +} + +.solution-col-5 { + vertical-align: top; + width: 20%; +} + +.solution-none { + color: var(--light-gray-color); +} + +.solution-uncorrected { + color: var(--red); +} + +.solution-autocorrected { + color: var(--petrol); +} + +.solution-corrected { + color: var(--green); + font-weight: bold; +} + +.vips_tabs.edit-hidden .edit-tab { + display: none; +} + +#assignment.exam .exam-hidden, +#assignment.practice .practice-hidden, +#assignment.selftest .selftest-hidden { + display: none; +} + +#list > tr:first-child .icon-shape-arr_2up, +#list > tr:last-child .icon-shape-arr_2down { + visibility: hidden; +} + +.options-toggle { + display: none; +} + +.options-toggle + .caption + .toggle-box, +.options-toggle + .caption > .toggle-open, +.options-toggle:checked + .caption > .toggle-closed { + display: none; +} + +.options-toggle:checked + .caption + .toggle-box, +.options-toggle:checked + .caption > .toggle-open { + display: initial; +} + +.options-toggle + .caption { + background-color: var(--color--fieldset-header); + color: var(--brand-color-dark); + display: block; + font-weight: bold; + margin-bottom: 1.5ex; + padding: 4px; + + > img { + vertical-align: text-bottom; + } +} + +table.default input.small_input { + margin-bottom: 2px; +} + +.size_toggle { + &.size_small .large_input, + &.size_large .small_input { + display: none; + } + + .flexible_input { + display: inline-block; + max-width: 48em; + vertical-align: top; + width: 91%; + } +} + +.vs__selected img, +.vs__dropdown-option img { + vertical-align: text-bottom; +} + +.vs__dropdown-option small { + margin-left: 20px; +} + +.cw-exercise-header { + display: flex; + height: 20px; + + span { + flex-grow: 1; + } +} + +.cw-exercise-fieldset header { + background-color: var(--color--fieldset-header); + color: var(--brand-color-dark); + font-weight: 600; + margin: 14px 0 8px -10px; + padding: 6px 10px; +} + +#vips-sheets-print_assignments { + display: block; + min-height: auto; + width: auto; + + @media screen { + margin: 2em; + + .print_settings { + display: block; + } + } + + footer { + display: none; + } + + table.content th { + padding: 3px; + text-align: left; + } + + ol, ul { + padding-left: 30px; + } + + .assignment { + page-break-after: always; + } + + .exercise { + margin-bottom: 1em; + page-break-inside: avoid; + } + + .label-text { + font-weight: bold; + margin-top: 1.5ex; + } + + .vips_output { + background: none; + max-height: none; + } +} diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index 624e99a43265362a9321f481bdaea9ae2d701ae6..9183081efe8cc77ac395f9e38510465c37111756 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -105,6 +105,7 @@ @import "scss/tree"; @import "scss/typography"; @import "scss/user-administration"; +@import "scss/vips"; @import "scss/wiki"; @import "scss/multi_person_search"; @import "scss/admission"; diff --git a/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue new file mode 100644 index 0000000000000000000000000000000000000000..9c90291278ea583f9c6abe57a1e75d33d1a76527 --- /dev/null +++ b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue @@ -0,0 +1,266 @@ +<template> + <div class="cw-block cw-block-test"> + <courseware-default-block + :block="block" + :canEdit="canEdit" + :isTeacher="isTeacher" + :defaultGrade="false" + @storeEdit="storeBlock" + @closeEdit="initCurrentData" + > + <template #content> + <div class="cw-block-title cw-exercise-header" v-if="assignment"> + <template v-if="exercises.length > 1"> + <button class="as-link" @click="prevExercise" :title="$gettext('Zurück')"> + <studip-icon shape="arr_1left" size="20"/> + </button> + <span> + {{ $gettextInterpolate( + $gettext('%{title}, Aufgabe %{num} von %{length}'), + { title: assignment.title, num: exercise_pos + 1, length: exercises.length } + ) }} + </span> + <button class="as-link" @click="nextExercise" :title="$gettext('Weiter')"> + <studip-icon shape="arr_1right" size="20"/> + </button> + </template> + <span v-else> + {{assignment.title}} + </span> + </div> + <template v-for="(exercise, index) in exercises" :key="exercise.id"> + <div v-show="index === exercise_pos"> + <form class="default" autocomplete="off" :exercise="exercise.id"> + <fieldset class="cw-exercise-fieldset" v-html="exercise.template" ref="content"> + </fieldset> + <footer v-show="exercise.item_count && (assignment.reset_allowed || !exercise.show_solution)"> + <button + v-show="!exercise.show_solution" + class="button accept" + @click.prevent="submitSolution" + > + {{ $gettext('Speichern') }} + </button> + <button + v-show="exercise.show_solution && assignment.reset_allowed" + class="button reset" + @click.prevent="resetDialogHandler" + > + {{ $gettext('Lösung dieser Aufgabe löschen') }} + </button> + <a + v-if="canEdit && $store.getters.viewMode === 'edit'" + class="button" + :href="vips_url('sheets/edit_assignment', { assignment_id: assignment.id })" + > + {{ $gettext('Aufgabenblatt bearbeiten') }} + </a> + </footer> + </form> + </div> + </template> + <courseware-companion-box + :msgCompanion="errorMessage" mood="sad" + v-if="errorMessage !== null" + /> + </template> + <template v-if="canEdit" #edit> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Aufgabenblatt') }} + <studip-select + :options="assignments" + label="title" + :reduce="assignment => assignment.id" + :clearable="false" + v-model="assignment_id" + class="cw-vs-select" + > + <template #open-indicator="{ attributes }"> + <span v-bind="attributes"><studip-icon shape="arr_1down" :size="10"/></span> + </template> + <template #no-options="{}"> + {{ $gettext('Es steht keine Auswahl zur Verfügung') }} + </template> + <template #selected-option="{title, icon, start, end}"> + <studip-icon :shape="icon" role="info"/> + {{title}} ({{start}} - {{end}}) + </template> + <template #option="{title, icon, start, end, block}"> + <studip-icon :shape="icon" role="info"/> + {{ block ? block + ' / ' + title : title }}<br> + <small>{{start}} - {{end}}</small> + </template> + </studip-select> + </label> + </form> + </template> + </courseware-default-block> + <studip-dialog + v-if="exerciseResetId" + :title="$gettext('Bitte bestätigen Sie die Aktion')" + :question="$gettext('Wollen Sie die Lösung dieser Aufgabe wirklich löschen?')" + height="180" + @confirm="resetSolution" + @close="exerciseResetId = null" + ></studip-dialog> + </div> +</template> + +<script> +import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue'; +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue' + +export default { + name: 'courseware-test-block', + components: { CoursewareDefaultBlock, CoursewareCompanionBox }, + props: { + block: Object, + canEdit: Boolean, + isTeacher: Boolean + }, + data() { + return { + assignments: [], + assignment_id: '', + assignment: null, + errorMessage: null, + exercises: [], + exercise_pos: 0, + exerciseResetId: null + } + }, + methods: { + storeBlock() { + const attributes = { payload: { assignment: this.assignment_id } }; + const container = this.$store.getters['courseware-containers/related']({ + parent: this.block, + relationship: 'container', + }); + + return this.$store.dispatch('updateBlockInContainer', { + attributes, + blockId: this.block.id, + containerId: container.id, + }); + }, + initCurrentData() { + this.assignment_id = this.block.attributes.payload.assignment; + this.loadSelectedAssignment(); + }, + prevExercise() { + if (this.exercise_pos === 0) { + this.exercise_pos = this.exercises.length - 1; + } else { + this.exercise_pos = this.exercise_pos - 1; + } + }, + nextExercise() { + if (this.exercise_pos === this.exercises.length - 1) { + this.exercise_pos = 0; + } else { + this.exercise_pos = this.exercise_pos + 1; + } + }, + loadAssignments() { + // axios is this.$store.getters.httpClient + $.get(this.vips_url('api/assignments/' + this.$store.getters.context.id)) + .done(response => { + this.assignments = response; + }); + }, + loadSelectedAssignment() { + if (this.assignment_id === '') { + this.errorMessage = this.$gettext('Es wurde noch kein Aufgabenblatt ausgewählt.'); + return; + } + + this.assignment = null; + this.errorMessage = null; + this.exercises = []; + $.get(this.vips_url('api/assignment/' + this.assignment_id)) + .done(response => { + this.assignment = response; + this.exercises = response.exercises; + this.$nextTick(() => { + this.loadMathjax(); + STUDIP.Vips.vips_post_render(this.$refs.content); + }); + }) + .fail(xhr => { + this.errorMessage = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + }); + }, + reloadExercise(exercise_id) { + $.get(this.vips_url('api/exercise/' + this.assignment.id + '/' + exercise_id)) + .done(response => { + this.exercises[this.exercise_pos] = response; + this.$nextTick(() => { + this.loadMathjax(); + STUDIP.Vips.vips_post_render(this.$refs.content); + }); + }); + }, + loadMathjax() { + STUDIP.loadChunk('mathjax').then(({ Hub }) => { + Hub.Queue(['Typeset', Hub, this.$refs.content]); + }); + }, + vips_url(url, param_object) { + return STUDIP.URLHelper.getURL('dispatch.php/vips/' + url, param_object); + }, + submitSolution(event) { + let exercise_id = event.currentTarget.form.getAttribute('exercise'); + let data = new FormData(event.currentTarget.form); + data.set('block_id', this.block.id); + + $.ajax({ + type: 'POST', + url: this.vips_url('api/solution/' + this.assignment.id + '/' + exercise_id), + data: data, + enctype: 'multipart/form-data', + processData: false, + contentType: false + }) + .fail(xhr => { + let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + + if (xhr.status === 422) { + info = this.$gettext('Ihre Lösung ist leer und wurde nicht gespeichert.'); + } + this.$store.dispatch('companionError', { info: info }); + }) + .done(() => { + this.$store.dispatch('companionSuccess', { + info: this.$gettext('Ihre Lösung zur Aufgabe wurde gespeichert.'), + }); + this.reloadExercise(exercise_id); + }); + }, + resetDialogHandler(event) { + this.exerciseResetId = event.currentTarget.form.getAttribute('exercise'); + }, + resetSolution() { + $.ajax({ + type: 'DELETE', + url: this.vips_url('api/solution/' + this.assignment.id + '/' + this.exerciseResetId, { block_id: this.block.id }) + }) + .fail(xhr => { + let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + this.$store.dispatch('companionError', { info: info }); + this.exerciseResetId = null; + }) + .done(() => { + this.reloadExercise(this.exerciseResetId); + this.exerciseResetId = null; + }); + } + }, + created() { + this.initCurrentData(); + if (this.canEdit) { + this.loadAssignments(); + } + } +}; +</script> diff --git a/resources/vue/components/courseware/containers/container-components.js b/resources/vue/components/courseware/containers/container-components.js index 0a89bef5e89d4bc802a9be914f66038bab3cd948..c920dae34646965571e3eaae3113393c85c27f01 100644 --- a/resources/vue/components/courseware/containers/container-components.js +++ b/resources/vue/components/courseware/containers/container-components.js @@ -27,6 +27,7 @@ import CoursewareKeyPointBlock from '../blocks/CoursewareKeyPointBlock.vue'; import CoursewareLinkBlock from '../blocks/CoursewareLinkBlock.vue'; import CoursewareLtiBlock from '../blocks/CoursewareLtiBlock.vue'; import CoursewareTableOfContentsBlock from '../blocks/CoursewareTableOfContentsBlock.vue'; +import CoursewareTestBlock from '../blocks/CoursewareTestBlock.vue'; import CoursewareTextBlock from '../blocks/CoursewareTextBlock.vue'; import CoursewareTimelineBlock from '../blocks/CoursewareTimelineBlock.vue'; import CoursewareTypewriterBlock from '../blocks/CoursewareTypewriterBlock.vue'; @@ -66,6 +67,7 @@ const ContainerComponents = { CoursewareLinkBlock, CoursewareLtiBlock, CoursewareTableOfContentsBlock, + CoursewareTestBlock, CoursewareTextBlock, CoursewareTimelineBlock, CoursewareTypewriterBlock,