diff options
| author | Elmar Ludwig <elmar.ludwig@uni-osnabrueck.de> | 2025-01-10 14:55:22 +0000 |
|---|---|---|
| committer | Elmar Ludwig <elmar.ludwig@uni-osnabrueck.de> | 2025-01-10 14:55:22 +0000 |
| commit | 339493dbd88f45eee9d044123d13717558047fca (patch) | |
| tree | b5fc6959aaae455e25873804109742d053f3ac5b /app/controllers/vips | |
| parent | 10636268c2303409879014e01eadb3cbe05bd885 (diff) | |
add Vips as CorePlugin, re #4258
Merge request studip/studip!3432
Diffstat (limited to 'app/controllers/vips')
| -rw-r--r-- | app/controllers/vips/admin.php | 208 | ||||
| -rw-r--r-- | app/controllers/vips/api.php | 256 | ||||
| -rw-r--r-- | app/controllers/vips/config.php | 95 | ||||
| -rw-r--r-- | app/controllers/vips/exam_mode.php | 29 | ||||
| -rw-r--r-- | app/controllers/vips/pool.php | 473 | ||||
| -rw-r--r-- | app/controllers/vips/sheets.php | 2305 | ||||
| -rw-r--r-- | app/controllers/vips/solutions.php | 2521 |
7 files changed, 5887 insertions, 0 deletions
diff --git a/app/controllers/vips/admin.php b/app/controllers/vips/admin.php new file mode 100644 index 0000000..92c6cc7 --- /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 0000000..8c2dbe0 --- /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 0000000..d6d4e48 --- /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 0000000..914a0e0 --- /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 0000000..bcbe302 --- /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 0000000..036ff10 --- /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 0000000..ca46922 --- /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') : ''; + } +} |
