aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/vips/admin.php208
-rw-r--r--app/controllers/vips/api.php256
-rw-r--r--app/controllers/vips/config.php95
-rw-r--r--app/controllers/vips/exam_mode.php29
-rw-r--r--app/controllers/vips/pool.php473
-rw-r--r--app/controllers/vips/sheets.php2305
-rw-r--r--app/controllers/vips/solutions.php2521
-rw-r--r--app/views/vips/admin/edit_block.php47
-rw-r--r--app/views/vips/admin/edit_grades.php56
-rw-r--r--app/views/vips/config/index.php90
-rw-r--r--app/views/vips/config/pending_assignments.php76
-rw-r--r--app/views/vips/exam_mode/index.php46
-rw-r--r--app/views/vips/exercises/ClozeTask/correct.php42
-rw-r--r--app/views/vips/exercises/ClozeTask/edit.php67
-rw-r--r--app/views/vips/exercises/ClozeTask/print.php62
-rw-r--r--app/views/vips/exercises/ClozeTask/solve.php46
-rw-r--r--app/views/vips/exercises/ClozeTask/xml.php63
-rw-r--r--app/views/vips/exercises/MatchingTask/correct.php88
-rw-r--r--app/views/vips/exercises/MatchingTask/edit.php117
-rw-r--r--app/views/vips/exercises/MatchingTask/print.php95
-rw-r--r--app/views/vips/exercises/MatchingTask/solve.php38
-rw-r--r--app/views/vips/exercises/MatchingTask/xml.php50
-rw-r--r--app/views/vips/exercises/MatrixChoiceTask/correct.php42
-rw-r--r--app/views/vips/exercises/MatrixChoiceTask/edit.php91
-rw-r--r--app/views/vips/exercises/MatrixChoiceTask/print.php41
-rw-r--r--app/views/vips/exercises/MatrixChoiceTask/solve.php27
-rw-r--r--app/views/vips/exercises/MatrixChoiceTask/xml.php51
-rw-r--r--app/views/vips/exercises/MultipleChoiceTask/correct.php32
-rw-r--r--app/views/vips/exercises/MultipleChoiceTask/edit.php46
-rw-r--r--app/views/vips/exercises/MultipleChoiceTask/print.php30
-rw-r--r--app/views/vips/exercises/MultipleChoiceTask/solve.php13
-rw-r--r--app/views/vips/exercises/MultipleChoiceTask/xml.php43
-rw-r--r--app/views/vips/exercises/SequenceTask/correct.php85
-rw-r--r--app/views/vips/exercises/SequenceTask/edit.php50
-rw-r--r--app/views/vips/exercises/SequenceTask/print.php92
-rw-r--r--app/views/vips/exercises/SequenceTask/solve.php14
-rw-r--r--app/views/vips/exercises/SequenceTask/xml.php48
-rw-r--r--app/views/vips/exercises/SingleChoiceTask/correct.php41
-rw-r--r--app/views/vips/exercises/SingleChoiceTask/edit.php86
-rw-r--r--app/views/vips/exercises/SingleChoiceTask/print.php41
-rw-r--r--app/views/vips/exercises/SingleChoiceTask/solve.php23
-rw-r--r--app/views/vips/exercises/SingleChoiceTask/xml.php51
-rw-r--r--app/views/vips/exercises/TextLineTask/correct.php37
-rw-r--r--app/views/vips/exercises/TextLineTask/edit.php80
-rw-r--r--app/views/vips/exercises/TextLineTask/print.php35
-rw-r--r--app/views/vips/exercises/TextLineTask/solve.php9
-rw-r--r--app/views/vips/exercises/TextLineTask/xml.php53
-rw-r--r--app/views/vips/exercises/TextTask/correct.php184
-rw-r--r--app/views/vips/exercises/TextTask/edit.php47
-rw-r--r--app/views/vips/exercises/TextTask/print.php83
-rw-r--r--app/views/vips/exercises/TextTask/solve.php131
-rw-r--r--app/views/vips/exercises/TextTask/xml.php61
-rw-r--r--app/views/vips/exercises/correct_exercise.php39
-rw-r--r--app/views/vips/exercises/courseware_block.php79
-rw-r--r--app/views/vips/exercises/evaluation_mode_info.php22
-rw-r--r--app/views/vips/exercises/flexible_input.php25
-rw-r--r--app/views/vips/exercises/flexible_textarea.php17
-rw-r--r--app/views/vips/exercises/print_exercise.php64
-rw-r--r--app/views/vips/exercises/show_exercise_files.php20
-rw-r--r--app/views/vips/exercises/show_exercise_hint.php12
-rw-r--r--app/views/vips/pool/assignments.php25
-rw-r--r--app/views/vips/pool/copy_exercises_dialog.php50
-rw-r--r--app/views/vips/pool/exercises.php15
-rw-r--r--app/views/vips/pool/list_assignments.php174
-rw-r--r--app/views/vips/pool/list_exercises.php151
-rw-r--r--app/views/vips/pool/move_exercises_dialog.php50
-rw-r--r--app/views/vips/sheets/add_exercise_dialog.php26
-rw-r--r--app/views/vips/sheets/assign_block_dialog.php32
-rw-r--r--app/views/vips/sheets/assignment_type_tooltip.php18
-rw-r--r--app/views/vips/sheets/content_bar_icons.php19
-rw-r--r--app/views/vips/sheets/copy_assignment_dialog.php104
-rw-r--r--app/views/vips/sheets/copy_assignments_dialog.php34
-rw-r--r--app/views/vips/sheets/copy_exercise_dialog.php132
-rw-r--r--app/views/vips/sheets/copy_exercises_dialog.php52
-rw-r--r--app/views/vips/sheets/edit_assignment.php329
-rw-r--r--app/views/vips/sheets/edit_exercise.php161
-rw-r--r--app/views/vips/sheets/export_assignment.php82
-rw-r--r--app/views/vips/sheets/import_assignment_dialog.php21
-rw-r--r--app/views/vips/sheets/ip_range_tooltip.php26
-rw-r--r--app/views/vips/sheets/list_assignments.php27
-rw-r--r--app/views/vips/sheets/list_assignments_list.php207
-rw-r--r--app/views/vips/sheets/list_assignments_stud.php117
-rw-r--r--app/views/vips/sheets/list_exercises.php67
-rw-r--r--app/views/vips/sheets/move_assignments_dialog.php34
-rw-r--r--app/views/vips/sheets/move_exercises_dialog.php52
-rw-r--r--app/views/vips/sheets/print_assignment.php106
-rw-r--r--app/views/vips/sheets/print_assignments.php43
-rw-r--r--app/views/vips/sheets/print_layout.php9
-rw-r--r--app/views/vips/sheets/show_assignment.php179
-rw-r--r--app/views/vips/sheets/show_exercise.php109
-rw-r--r--app/views/vips/sheets/show_exercise_link.php23
-rw-r--r--app/views/vips/sheets/start_assignment_dialog.php40
-rw-r--r--app/views/vips/solutions/assignment_solutions.php308
-rw-r--r--app/views/vips/solutions/assignments.php18
-rw-r--r--app/views/vips/solutions/assignments_list.php190
-rw-r--r--app/views/vips/solutions/assignments_list_student.php135
-rw-r--r--app/views/vips/solutions/autocorrect_dialog.php29
-rw-r--r--app/views/vips/solutions/edit_assignment_attempt.php34
-rw-r--r--app/views/vips/solutions/edit_group_dialog.php30
-rw-r--r--app/views/vips/solutions/edit_solution.php216
-rw-r--r--app/views/vips/solutions/feedback_files.php20
-rw-r--r--app/views/vips/solutions/feedback_files_table.php51
-rw-r--r--app/views/vips/solutions/gradebook_dialog.php39
-rw-r--r--app/views/vips/solutions/participants_overview.php225
-rw-r--r--app/views/vips/solutions/show_assignment_log.php56
-rw-r--r--app/views/vips/solutions/solution_color_tooltip.php4
-rw-r--r--app/views/vips/solutions/statistics.php95
-rw-r--r--app/views/vips/solutions/student_assignment_solutions.php125
-rw-r--r--app/views/vips/solutions/student_grade.php109
-rw-r--r--app/views/vips/solutions/update_released_dialog.php42
-rw-r--r--app/views/vips/solutions/view_solution.php77
-rw-r--r--composer.json2
-rw-r--r--db/migrations/6.0.40_add_vips_module.php485
-rw-r--r--lib/classes/SimpleORMap.php4
-rw-r--r--lib/classes/sidebar/VipsSearchWidget.php42
-rw-r--r--lib/filesystem/ExerciseFolder.php111
-rw-r--r--lib/filesystem/FeedbackFolder.php96
-rw-r--r--lib/filesystem/ResponseFolder.php107
-rw-r--r--lib/models/Courseware/BlockTypes/TestBlock.php125
-rw-r--r--lib/models/FileRef.php17
-rw-r--r--lib/models/Folder.php27
-rw-r--r--lib/models/vips/ClozeTask.php505
-rw-r--r--lib/models/vips/DummyExercise.php83
-rw-r--r--lib/models/vips/Exercise.php855
-rw-r--r--lib/models/vips/MatchingTask.php341
-rw-r--r--lib/models/vips/MatrixChoiceTask.php268
-rw-r--r--lib/models/vips/MultipleChoiceTask.php196
-rw-r--r--lib/models/vips/SequenceTask.php255
-rw-r--r--lib/models/vips/SingleChoiceTask.php279
-rw-r--r--lib/models/vips/TextLineTask.php271
-rw-r--r--lib/models/vips/TextTask.php279
-rw-r--r--lib/models/vips/VipsAssignment.php1308
-rw-r--r--lib/models/vips/VipsAssignmentAttempt.php99
-rw-r--r--lib/models/vips/VipsBlock.php92
-rw-r--r--lib/models/vips/VipsExerciseRef.php137
-rw-r--r--lib/models/vips/VipsGroup.php79
-rw-r--r--lib/models/vips/VipsGroupMember.php50
-rw-r--r--lib/models/vips/VipsSolution.php160
-rw-r--r--lib/models/vips/VipsTest.php121
-rw-r--r--lib/modules/VipsModule.php471
-rw-r--r--public/assets/images/choice_checked.svg1
-rw-r--r--public/assets/images/choice_unchecked.svg1
-rw-r--r--public/assets/images/collapse.svg1
-rw-r--r--public/assets/images/expand.svg1
-rw-r--r--public/assets/images/icons/black/vips.svg1
-rw-r--r--public/assets/images/icons/blue/assessment-mc.svg1
-rw-r--r--public/assets/images/icons/blue/edit-line.svg1
-rw-r--r--public/assets/images/icons/blue/vips.svg1
-rw-r--r--public/assets/images/icons/red/vips.svg1
-rw-r--r--public/assets/images/icons/white/vips.svg1
-rw-r--r--public/assets/images/plus/screenshots/Vips/Vips_preview_1.pngbin0 -> 42033 bytes
-rw-r--r--public/assets/images/plus/screenshots/Vips/Vips_preview_2.pngbin0 -> 45908 bytes
-rw-r--r--public/assets/images/plus/screenshots/Vips/Vips_preview_3.pngbin0 -> 56688 bytes
-rw-r--r--resources/assets/javascripts/bootstrap/vips.js336
-rw-r--r--resources/assets/javascripts/entry-base.js1
-rw-r--r--resources/assets/javascripts/init.js2
-rw-r--r--resources/assets/javascripts/lib/vips.js122
-rw-r--r--resources/assets/stylesheets/scss/buttons.scss2
-rw-r--r--resources/assets/stylesheets/scss/courseware/variables.scss1
-rw-r--r--resources/assets/stylesheets/scss/forms.scss13
-rw-r--r--resources/assets/stylesheets/scss/jquery-ui/studip.scss42
-rw-r--r--resources/assets/stylesheets/scss/sidebar.scss2
-rw-r--r--resources/assets/stylesheets/scss/tables.scss15
-rw-r--r--resources/assets/stylesheets/scss/vips.scss592
-rw-r--r--resources/assets/stylesheets/studip.scss1
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareTestBlock.vue266
-rw-r--r--resources/vue/components/courseware/containers/container-components.js2
167 files changed, 21392 insertions, 12 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 &bdquo;%s&ldquo; 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 &bdquo;%s&ldquo; 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 &bdquo;%s&ldquo; 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') : '';
+ }
+}
diff --git a/app/views/vips/admin/edit_block.php b/app/views/vips/admin/edit_block.php
new file mode 100644
index 0000000..70710cf
--- /dev/null
+++ b/app/views/vips/admin/edit_block.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * @var Vips_AdminController $controller
+ * @var VipsBlock $block
+ * @var VipsGroup[] $groups
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/admin/store_block') ?>" data-secure method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <? if ($block->id): ?>
+ <input type="hidden" name="block_id" value="<?= $block->id ?>">
+ <? endif ?>
+
+ <label>
+ <span class="required"><?= _('Blockname') ?></span>
+ <input type="text" name="block_name" required value="<?= htmlReady($block->name) ?>">
+ </label>
+
+ <label>
+ <?= _('Sichtbarkeit') ?>
+ <?= tooltipIcon(_('Blöcke und zugeordnete Aufgabenblätter können nur für bestimmte Gruppen sichtbar oder auch komplett unsichtbar gemacht werden.')) ?>
+ <select name="group_id">
+ <option value="0">
+ <?= _('Alle Teilnehmenden (keine Beschränkung)') ?>
+ </option>
+ <option value="" <?= !$block->visible ? 'selected' : '' ?>>
+ <?= _('Für Teilnehmende unsichtbar') ?>
+ </option>
+ <? foreach ($groups as $group): ?>
+ <option value="<?= $group->id ?>" <?= $block->group_id === $group->id ? 'selected' : '' ?>>
+ <?= sprintf(_('Gruppe „%s“'), htmlReady($group->name)) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <label>
+ <input type="checkbox" name="block_grouped" value="1" <?= $block->weight !== null ? 'checked' : '' ?>>
+ <?= _('Aufgabenblätter in der Bewertung gruppieren') ?>
+ <?= tooltipIcon(_('In der Ergebnisübersicht wird nur der Block anstelle der enthaltenen Aufgabenblätter aufgeführt.')) ?>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store_block') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/admin/edit_grades.php b/app/views/vips/admin/edit_grades.php
new file mode 100644
index 0000000..d83db43
--- /dev/null
+++ b/app/views/vips/admin/edit_grades.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * @var Vips_AdminController $controller
+ * @var array $grades
+ * @var bool $grade_settings
+ * @var array $percentages
+ * @var string[] $comments
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<form class="default" action="<?= $controller->link_for('vips/admin/store_grades') ?>" data-secure method="post">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <table class="default">
+ <caption>
+ <?= _('Notenverteilung') ?>
+ </caption>
+ <thead>
+ <tr>
+ <th><?= _('Note') ?></th>
+ <th><?= _('Schwellwert') ?></th>
+ <th><?= _('Kommentar') ?></th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? for ($i = 0; $i < count($grades); ++$i): ?>
+ <? $class = $grade_settings && !$percentages[$i] ? 'quiet' : '' ?>
+ <tr class="<?= $class ?>">
+ <td><?= htmlReady($grades[$i]) ?></td>
+ <td>
+ <input type="text" class="percent_input" name="percentage[<?= $i ?>]" value="<?= sprintf('%g', $percentages[$i]) ?>"> %
+ </td>
+ <td>
+ <input type="text" name="comment[<?= $i ?>]" value="<?= htmlReady($comments[$i]) ?>" <?= $class ? 'disabled' : '' ?>>
+ </td>
+ </tr>
+ <? endfor ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td class="smaller" colspan="3">
+ <?= _('Wenn Sie eine bestimmte Notenstufe nicht verwenden wollen, lassen Sie das Feld für den Schwellwert leer.') ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
+ </footer>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/config/index.php b/app/views/vips/config/index.php
new file mode 100644
index 0000000..d4b1f69
--- /dev/null
+++ b/app/views/vips/config/index.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * @var Vips_ConfigController $controller
+ * @var Config $config
+ */
+?>
+<form class="default width-1200" action="<?= $controller->link_for('vips/config/save') ?>" data-secure method="post">
+ <?= CSRFProtection::tokenTag() ?>
+ <button hidden name="save"></button>
+
+ <fieldset>
+ <legend>
+ <?= _('Einstellungen für Klausuren') ?>
+ </legend>
+
+ <div class="label-text">
+ <?= _('Klausurmodus aktivieren') ?>
+ </div>
+
+ <label class="undecorated">
+ <input type="checkbox" name="exam_mode" value="1" <?= $config->VIPS_EXAM_RESTRICTIONS ? 'checked' : '' ?>>
+ <?= _('Während einer Klausur den Zugriff auf andere Bereiche von Stud.IP sperren') ?>
+ <?= tooltipIcon(_('Gilt nur für Klausuren mit beschränktem IP-Zugriffsbereich.')) ?>
+ </label>
+
+ <div class="label-text">
+ <?= _('Vordefinierte IP-Bereiche für PC-Räume') ?>
+ </div>
+
+ <table class="default">
+ <thead>
+ <tr>
+ <th style="width: 20%;">
+ <?= _('Raum') ?>
+ </th>
+ <th style="width: 75%;">
+ <?= _('IP-Bereiche') ?>
+ <?= tooltipIcon($this->render_partial('vips/sheets/ip_range_tooltip'), false, true) ?>
+ </th>
+ <th class="actions">
+ <?= _('Löschen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list">
+ <? foreach ($config->VIPS_EXAM_ROOMS ?: [] as $room => $ip_range): ?>
+ <tr class="dynamic_row">
+ <td>
+ <input type="text" name="room[]" value="<?= htmlReady($room) ?>">
+ </td>
+ <td>
+ <input type="text" class="size-l validate_ip_range" name="ip_range[]" value="<?= htmlReady($ip_range) ?>">
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+
+ <tr class="dynamic_row template">
+ <td>
+ <input type="text" name="room[]">
+ </td>
+ <td>
+ <input type="text" class="size-l validate_ip_range" name="ip_range[]">
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?>
+ </td>
+ </tr>
+
+ <tr>
+ <th colspan="3">
+ <?= Studip\Button::create(_('Eintrag hinzufügen'), 'add_room', ['class' => 'add_dynamic_row']) ?>
+ </th>
+ </tr>
+ </tbody>
+ </table>
+
+ <label>
+ <?= _('Teilnahmebedingungen vor Beginn einer Klausur') ?>
+ <textarea name="exam_terms" class="size-l wysiwyg"><?= wysiwygReady($config->VIPS_EXAM_TERMS) ?></textarea>
+ </label>
+ </fieldset>
+
+ <footer>
+ <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/config/pending_assignments.php b/app/views/vips/config/pending_assignments.php
new file mode 100644
index 0000000..8a21874
--- /dev/null
+++ b/app/views/vips/config/pending_assignments.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * @var Vips_ConfigController $controller
+ * @var VipsAssignment[] $assignments
+ */
+?>
+<? if (count($assignments) === 0): ?>
+ <?= MessageBox::info(_('Es gibt zur Zeit keine anstehenden Klausuren.')) ?>
+<? else: ?>
+ <table class="default sortable-table" data-sortlist="[[1,0]]">
+ <caption>
+ <?= _('Klausuren') ?>
+ <div class="actions">
+ <?= sprintf(ngettext('%d Klausur', '%d Klausuren', count($assignments)), count($assignments)) ?>
+ </div>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th data-sort="text" style="width: 35%;">
+ <?= _('Titel') ?>
+ </th>
+
+ <th data-sort="text" style="width: 10%;">
+ <?= _('Start') ?>
+ </th>
+
+ <th data-sort="text" style="width: 10%;">
+ <?= _('Ende') ?>
+ </th>
+
+ <th data-sort="text" style="width: 15%;">
+ <?= _('Autor/-in') ?>
+ </th>
+
+ <th data-sort="text" style="width: 30%;">
+ <?= _('Veranstaltung') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignments as $assignment): ?>
+ <tr>
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $assignment->range_id, 'assignment_id' => $assignment->id]) ?>">
+ <?= $assignment->getTypeIcon() ?>
+ <?= htmlReady($assignment->test->title) ?>
+ </a>
+ <? if ($assignment->isRunning() && !$assignment->active): ?>
+ (<?= _('unterbrochen') ?>)
+ <? endif ?>
+ </td>
+
+ <td data-text="<?= htmlReady($assignment->start) ?>">
+ <?= date('d.m.Y, H:i', $assignment->start) ?>
+ </td>
+
+ <td data-text="<?= htmlReady($assignment->end) ?>">
+ <?= date('d.m.Y, H:i', $assignment->end) ?>
+ </td>
+
+ <td>
+ <?= htmlReady($assignment->test->user->getFullName('no_title_rev')) ?>
+ </td>
+
+ <td>
+ <a href="<?= URLHelper::getLink('seminar_main.php', ['cid' => $assignment->range_id]) ?>">
+ <?= htmlReady($assignment->course->name) ?>
+ </a>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ </table>
+<? endif ?>
diff --git a/app/views/vips/exam_mode/index.php b/app/views/vips/exam_mode/index.php
new file mode 100644
index 0000000..50bf0a6
--- /dev/null
+++ b/app/views/vips/exam_mode/index.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @var array $courses
+ */
+?>
+<? if (count($courses)) : ?>
+ <table class="default width-1200">
+ <caption>
+ <?= _('Bitte wählen Sie den Kurs, in dem Sie die Klausur schreiben möchten:') ?>
+ </caption>
+
+ <thead>
+ <tr>
+ <th style="width: 5%;"></th>
+ <th style="width: 65%;"><?= _('Name') ?></th>
+ <th style="width: 30%;"><?= _('Inhalt') ?></th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($courses as $course_id => $course_name) : ?>
+ <? $nav = VipsModule::$instance->getIconNavigation($course_id, null, null) ?>
+ <? if ($nav): ?>
+ <tr>
+ <td>
+ <?= CourseAvatar::getAvatar($course_id)->getImageTag(Avatar::SMALL) ?>
+ </td>
+ <td>
+ <a href="<?= URLHelper::getLink($nav->getURL(), ['cid' => $course_id]) ?>">
+ <?= htmlReady($course_name) ?>
+ </a>
+ </td>
+ <td>
+ <a href="<?= URLHelper::getLink($nav->getURL(), ['cid' => $course_id]) ?>">
+ <?= $nav->getImage()->asImg($nav->getLinkAttributes()) ?>
+ </a>
+ </td>
+ </tr>
+ <? endif ?>
+ <? endforeach ?>
+ </tbody>
+ </table>
+<? else : ?>
+ <? /* this should never be shown, but can be reached directly by URL */ ?>
+ <?= MessageBox::info(_('Zur Zeit laufen keine Klausuren.')) ?>
+<? endif ?>
diff --git a/app/views/vips/exercises/ClozeTask/correct.php b/app/views/vips/exercises/ClozeTask/correct.php
new file mode 100644
index 0000000..8e4aac1
--- /dev/null
+++ b/app/views/vips/exercises/ClozeTask/correct.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ */
+?>
+<div class="description">
+ <!--
+ <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text) : ?>
+ --><?= $text ?><!--
+ <? if (isset($exercise->task['answers'][$blank])) : ?>
+ <? if ($solution->id): ?>
+ <? if ($results[$blank]['points'] == 1): ?>
+ --><span class="correct_item math-tex"><?= htmlReady($response[$blank]) ?><!--
+ --><?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_inline', 'title' => _('richtig')]) ?><!--
+ --></span><!--
+ <? elseif ($results[$blank]['points'] == 0.5): ?>
+ --><span class="fuzzy_item math-tex"><?= htmlReady($response[$blank]) ?><!--
+ --><?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['class' => 'correction_inline', 'title' => _('fast richtig')]) ?><!--
+ --></span><!--
+ <? elseif (empty($edit_solution) || $results[$blank]['safe']): ?>
+ --><span class="wrong_item math-tex"><?= htmlReady($response[$blank]) ?><!--
+ --><?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('falsch')]) ?><!--
+ --></span><!--
+ <? else: ?>
+ --><span class="wrong_item math-tex"><?= htmlReady($response[$blank]) ?><!--
+ --><?= Icon::create('question', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('unbekannte Antwort')]) ?><!--
+ --></span><!--
+ <? endif ?>
+ <? endif ?>
+ <? if ($show_solution && (empty($results) || $results[$blank]['points'] < 1) && $exercise->correctAnswers($blank)): ?>
+ --><span class="correct_item math-tex"><?= htmlReady(implode(' | ', $exercise->correctAnswers($blank))) ?></span><!--
+ <? endif ?>
+ <? endif ?>
+ <? endforeach ?>
+ -->
+</div>
+
+<?= $this->render_partial('exercises/evaluation_mode_info', ['evaluation_mode' => false]) ?>
diff --git a/app/views/vips/exercises/ClozeTask/edit.php b/app/views/vips/exercises/ClozeTask/edit.php
new file mode 100644
index 0000000..51c04a9
--- /dev/null
+++ b/app/views/vips/exercises/ClozeTask/edit.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<? $tooltip = sprintf('<p>%s:<br>[[ ... ]]</p><p>%s:<br>[[ ... | ... | ... ]]</p><p>%s:<br>[[ ... | ... | *... ]]</p><p>%s:<br>[[: ... | ... | *... ]]</p>',
+ _('Lücke hinzufügen'), _('Mehrere Lösungen mit | trennen'), _('Falsche Antworten mit * markieren'), _('Auswahl aus Liste statt Eingabe')) ?>
+
+<label>
+ <?= _('Lückentext') ?> <?= tooltipIcon($tooltip, false, true) ?>
+ <? $cloze_text = $exercise->getClozeText() ?>
+ <textarea name="cloze_text" class="character_input size-l wysiwyg" rows="<?= $exercise->textareaSize($cloze_text) ?>"><?= wysiwygReady($cloze_text) ?></textarea>
+</label>
+
+<label>
+ <?= _('Antwortmodus') ?>
+
+ <select name="layout" onchange="$(this).parent().next().toggle(this.value === '')">
+ <option value="">
+ <?= _('Texteingabe') ?>
+ </option>
+ <option value="select" <?= $exercise->interactionType() === 'select' ? 'selected' : '' ?>>
+ <?= _('Antwort aus Liste auswählen') ?>
+ </option>
+ <option value="drag" <?= $exercise->interactionType() === 'drag' ? 'selected' : '' ?>>
+ <?= _('Antwort in das Feld ziehen') ?>
+ </option>
+ </select>
+</label>
+
+<div style="<?= $exercise->interactionType() !== 'input' ? 'display: none;' : '' ?>">
+ <label>
+ <?= _('Art des Textvergleichs') ?>
+
+ <select name="compare" onchange="$(this).parent().next('label').toggle($(this).val() === 'numeric')">
+ <option value="">
+ <?= _('Groß-/Kleinschreibung unterscheiden') ?>
+ </option>
+ <option value="ignorecase" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'ignorecase' ? 'selected' : '' ?>>
+ <?= _('Groß-/Kleinschreibung ignorieren') ?>
+ </option>
+ <option value="numeric" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? 'selected' : '' ?>>
+ <?= _('Numerischer Wertevergleich (ggf. mit Einheit)') ?>
+ </option>
+ </select>
+ </label>
+
+ <label style="<?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? '' : 'display: none;' ?>">
+ <?= _('Erlaubte relative Abweichung vom korrekten Wert') ?>
+ <br>
+ <input type="text" class="size-s" style="display: inline; text-align: right;"
+ name="epsilon" value="<?= isset($exercise->task['epsilon']) ? sprintf('%g', $exercise->task['epsilon'] * 100) : '0' ?>"> %
+ </label>
+
+ <label>
+ <input type="checkbox" <?= isset($exercise->task['input_width']) ? 'checked' : '' ?> onchange="$(this).next('select').attr('disabled', !this.checked)">
+ <?= _('Feste Breite der Eingabefelder:') ?>
+
+ <select name="input_width" style="display: inline; width: auto;" <?= isset($exercise->task['input_width']) ? '' : 'disabled' ?>>
+ <? foreach ([_('kurz'), _('mittel'), _('lang'), _('maximal')] as $key => $label): ?>
+ <option value="<?= $key ?>" <?= isset($exercise->task['input_width']) && $exercise->task['input_width'] == $key ? 'selected' : '' ?>>
+ <?= htmlReady($label) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+</div>
diff --git a/app/views/vips/exercises/ClozeTask/print.php b/app/views/vips/exercises/ClozeTask/print.php
new file mode 100644
index 0000000..fce4bc4
--- /dev/null
+++ b/app/views/vips/exercises/ClozeTask/print.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var array $response
+ * @var array $results
+ * @var bool $print_correction
+ * @var bool $show_solution
+ */
+?>
+<div class="description">
+ <!--
+ <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text): ?>
+ --><?= $text ?><!--
+ <? if (isset($exercise->task['answers'][$blank])) : ?>
+ <? if ($solution->id && $response[$blank] !== ''): ?>
+ --><span class="math-tex" style="text-decoration: underline;">&nbsp;&nbsp;<?= htmlReady($response[$blank]) ?>&nbsp;&nbsp;</span><!--
+ <? if ($print_correction): ?>
+ <? if ($results[$blank]['points'] == 1): ?>
+ --><?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?><!--
+ <? elseif ($results[$blank]['points'] == 0.5): ?>
+ --><?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?><!--
+ <? else: ?>
+ --><?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?><!--
+ <? endif ?>
+ <? endif ?>
+ <? elseif ($exercise->isSelect($blank)): ?>
+ <? foreach ($exercise->task['answers'][$blank] as $index => $option) : ?>
+ --><?= $index ? ' | ' : '' ?><!--
+ --><?= Assets::img('choice_unchecked.svg', ['style' => 'vertical-align: text-bottom;']) ?> <!--
+ --><span class="math-tex" style="border-bottom: 1px dotted black;"><?= htmlReady($option['text']) ?></span><!--
+ <? endforeach ?>
+ <? else: ?>
+ --><?= str_repeat('_', $exercise->getInputWidth($blank)) ?><!--
+ <? endif ?>
+ <? if ($show_solution && (empty($results) || $results[$blank]['points'] < 1) && $exercise->correctAnswers($blank)): ?>
+ --><span class="correct_item math-tex"><?= htmlReady(implode(' | ', $exercise->correctAnswers($blank))) ?></span><!--
+ <? endif ?>
+ <? endif ?>
+ <? endforeach ?>
+ -->
+</div>
+
+<? if ($exercise->interactionType() === 'drag'): ?>
+ <div class="label-text">
+ <? if ($print_correction): ?>
+ <?= _('Nicht zugeordnete Antworten:') ?>
+ <? else: ?>
+ <?= _('Antwortmöglichkeiten:') ?>
+ <? endif ?>
+ </div>
+
+ <ol>
+ <? foreach ($exercise->availableAnswers($solution) as $item): ?>
+ <li>
+ <span class="math-tex"><?= htmlReady($item) ?></span>
+ </li>
+ <? endforeach ?>
+ </ol>
+<? endif ?>
+
+<?= $this->render_partial('exercises/evaluation_mode_info', ['evaluation_mode' => false]) ?>
diff --git a/app/views/vips/exercises/ClozeTask/solve.php b/app/views/vips/exercises/ClozeTask/solve.php
new file mode 100644
index 0000000..bc17a03
--- /dev/null
+++ b/app/views/vips/exercises/ClozeTask/solve.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var array $response
+ */
+?>
+<div class="description">
+ <!--
+ <? foreach (explode('[[]]', formatReady($exercise->task['text'])) as $blank => $text): ?>
+ --><?= $text ?><!--
+ <? if (isset($exercise->task['answers'][$blank])) : ?>
+ <? if ($exercise->interactionType() === 'drag'): ?>
+ --><span class="cloze_drop math-tex" title="<?= _('Elemente hier ablegen') ?>">
+ <input type="hidden" name="answer[<?= $blank ?>]" value="<?= htmlReady($response[$blank] ?? '') ?>">
+ <? if (isset($response[$blank]) && $response[$blank] !== ''): ?>
+ <span class="cloze_item drag-handle" data-value="<?= htmlReady($response[$blank]) ?>"><?= htmlReady($response[$blank]) ?></span>
+ <? endif ?>
+ </span><!--
+ <? elseif ($exercise->isSelect($blank)): ?>
+ --><select class="cloze_select" name="answer[<?= $blank ?>]">
+ <? if ($exercise->task['answers'][$blank][0]['text'] !== ''): ?>
+ <option value="">&nbsp;</option>
+ <? endif ?>
+ <? foreach ($exercise->task['answers'][$blank] as $option): ?>
+ <option value="<?= htmlReady($option['text']) ?>" <?= trim($option['text']) === ($response[$blank] ?? '') ? ' selected' : '' ?>>
+ <?= htmlReady($option['text']) ?>
+ </option>
+ <? endforeach ?>
+ </select><!--
+ <? else: ?>
+ --><input type="text" class="character_input cloze_input" name="answer[<?= $blank ?>]"
+ style="width: <?= $exercise->getInputWidth($blank) ?>em;" value="<?= htmlReady($response[$blank] ?? '') ?>"><!--
+ <? endif ?>
+ <? endif ?>
+ <? endforeach ?>
+ -->
+</div>
+
+<? if ($exercise->interactionType() === 'drag'): ?>
+ <span class="cloze_drop cloze_items math-tex">
+ <? foreach ($exercise->availableAnswers($solution) as $item): ?>
+ <span class="cloze_item drag-handle" data-value="<?= htmlReady($item) ?>"><?= htmlReady($item) ?></span>
+ <? endforeach ?>
+ </span>
+<? endif ?>
diff --git a/app/views/vips/exercises/ClozeTask/xml.php b/app/views/vips/exercises/ClozeTask/xml.php
new file mode 100644
index 0000000..65ebb98
--- /dev/null
+++ b/app/views/vips/exercises/ClozeTask/xml.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="cloze-<?= $exercise->interactionType() ?>">
+ <description>
+ <? foreach (explode('[[]]', $exercise->task['text']) as $blank => $text): ?>
+ <text><?= htmlReady($text) ?></text>
+ <? if (isset($exercise->task['answers'][$blank])): ?>
+ <answers<? if ($exercise->isSelect($blank, false)): ?> select="true"<? endif ?>>
+ <? foreach ($exercise->task['answers'][$blank] as $answer): ?>
+ <answer score="<?= $answer['score'] ?>"><?= htmlReady($answer['text']) ?></answer>
+ <? endforeach ?>
+ </answers>
+ <? endif ?>
+ <? endforeach ?>
+ </description>
+ <? if (isset($exercise->task['input_width'])): ?>
+ <submission-hints>
+ <input type="text" width="<?= (int) $exercise->task['input_width'] ?>"/>
+ </submission-hints>
+ <? endif ?>
+ <? if (!empty($exercise->task['compare'])): ?>
+ <evaluation-hints>
+ <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/>
+ <? if ($exercise->task['compare'] === 'numeric'): ?>
+ <input-data type="relative-epsilon">
+ <?= (float) $exercise->task['epsilon'] ?>
+ </input-data>
+ <? endif ?>
+ </evaluation-hints>
+ <? endif ?>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/MatchingTask/correct.php b/app/views/vips/exercises/MatchingTask/correct.php
new file mode 100644
index 0000000..64faca6
--- /dev/null
+++ b/app/views/vips/exercises/MatchingTask/correct.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ */
+?>
+<? $exercise->sortAnswersById(); ?>
+
+<table class="default description inline-content">
+ <thead>
+ <tr>
+ <th>
+ <?= _('Vorgegebener Text') ?>
+ </th>
+
+ <th>
+ <?= _('Zugeordnete Antworten') ?>
+ </th>
+
+ <? if ($show_solution): ?>
+ <th>
+ <?= _('Richtige Antworten') ?>
+ </th>
+ <? endif ?>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($exercise->task['groups'] as $i => $group) : ?>
+ <tr style="vertical-align: top;">
+ <td>
+ <div class="mc_item">
+ <?= formatReady($group) ?>
+ </div>
+ </td>
+
+ <td>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?>
+ <div class="<?= $exercise->isCorrectAnswer($answer, $i) ? 'correct_item' : 'mc_item' ?>">
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($exercise->isCorrectAnswer($answer, $i)): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+ </td>
+
+ <? if ($show_solution): ?>
+ <td>
+ <? foreach ($exercise->correctAnswers($i) as $correct_answer): ?>
+ <div class="correct_item">
+ <?= formatReady($correct_answer) ?>
+ </div>
+ <? endforeach ?>
+ </td>
+ <? endif ?>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+</table>
+
+<div class="label-text">
+ <?= _('Nicht zugeordnete Antworten:') ?>
+</div>
+
+<? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?>
+ <div class="inline-block inline-content <?= $exercise->isCorrectAnswer($answer, -1) ? 'correct_item' : 'mc_item' ?>">
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($solution->id): ?>
+ <? if ($exercise->isCorrectAnswer($answer, -1)): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_inline', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_inline', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+<? endforeach ?>
diff --git a/app/views/vips/exercises/MatchingTask/edit.php b/app/views/vips/exercises/MatchingTask/edit.php
new file mode 100644
index 0000000..3ddc8f5
--- /dev/null
+++ b/app/views/vips/exercises/MatchingTask/edit.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<label>
+ <input class="rh_select_type" type="checkbox" name="multiple" value="1" <?= $exercise->isMultiSelect() ? 'checked' : '' ?>>
+ <?= _('Mehrfachzuordnungen zu einem vorgegebenen Text erlauben') ?>
+</label>
+
+<table class="default description <?= $exercise->isMultiSelect() ? '' : 'rh_single' ?>">
+ <thead>
+ <tr>
+ <th style="width: 50%;">
+ <?= _('Vorgegebener Text') ?>
+ </th>
+ <th style="width: 50%;">
+ <?= _('Zuzuordnende Antworten') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list" style="vertical-align: top;">
+ <? foreach ($exercise->task['groups'] as $i => $group): ?>
+ <? $size = $exercise->flexibleInputSize($group) ?>
+
+ <tr class="dynamic_row">
+ <td class="size_toggle size_<?= $size ?>">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => "default[$i]", 'value' => $group, 'size' => $size]) ?>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Zuordnung löschen')]) ?>
+ </td>
+ <td class="dynamic_list">
+ <? $j = 0 ?>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if ($answer['group'] == $i): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']) ?>
+
+ <div class="dynamic_row size_toggle size_<?= $size ?>">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i][$j]", 'value' => $answer['text'], 'size' => $size]) ?>
+ <input type="hidden" name="id[<?= $i ?>][<?= $j++ ?>]" value="<?= $answer['id'] ?>">
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+
+ <div class="dynamic_row size_toggle size_small template">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => "answer[$i]", 'size' => 'small']) ?>
+ <input type="hidden" data-name="id[<?= $i ?>]">
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row rh_add_answer']) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+
+ <tr class="dynamic_row template">
+ <td class="size_toggle size_small">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'default', 'size' => 'small']) ?>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Zuordnung löschen')]) ?>
+ </td>
+ <td class="dynamic_list">
+ <div class="dynamic_row size_toggle size_small template">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => ':answer', 'size' => 'small']) ?>
+ <input type="hidden" data-name=":id">
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row rh_add_answer']) ?>
+ </td>
+ </tr>
+
+ <tr>
+ <th colspan="2">
+ <?= Studip\Button::create(_('Zuordnung hinzufügen'), 'add_pairs', ['class' => 'add_dynamic_row']) ?>
+ </th>
+ </tr>
+ </tbody>
+</table>
+
+<div class="label-text">
+ <?= _('Distraktoren (optional)') ?>
+ <?= tooltipIcon(_('Weitere Antworten, die keinem Text zugeordnet werden dürfen.')) ?>
+</div>
+
+<div class="dynamic_list">
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if ($answer['group'] == -1): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']) ?>
+
+ <div class="dynamic_row mc_row">
+ <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => '_answer[]', 'value' => $answer['text'], 'size' => $size]) ?>
+ <input type="hidden" name="_id[]" value="<?= $answer['id'] ?>">
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Distraktor löschen')]) ?>
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => '', 'name' => '_answer[]', 'size' => 'small']) ?>
+ <input type="hidden" name="_id[]">
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Distraktor löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Distraktor hinzufügen'), 'add_false_answer', ['class' => 'add_dynamic_row']) ?>
+</div>
diff --git a/app/views/vips/exercises/MatchingTask/print.php b/app/views/vips/exercises/MatchingTask/print.php
new file mode 100644
index 0000000..a9617df
--- /dev/null
+++ b/app/views/vips/exercises/MatchingTask/print.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var bool $print_correction
+ * @var bool $show_solution
+ */
+?>
+<? $exercise->sortAnswersById(); ?>
+
+<table class="content description inline-content" style="min-width: 40em;">
+ <thead>
+ <tr>
+ <th>
+ <?= _('Vorgegebener Text') ?>
+ </th>
+
+ <th>
+ <?= _('Zugeordnete Antworten') ?>
+ </th>
+
+ <? if ($show_solution) : ?>
+ <th>
+ <?= _('Richtige Antworten') ?>
+ </th>
+ <? endif ?>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($exercise->task['groups'] as $i => $group) : ?>
+ <tr style="vertical-align: top;">
+ <td>
+ <div class="mc_item">
+ <?= formatReady($group) ?>
+ </div>
+ </td>
+
+ <td>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?>
+ <div class="<?= $print_correction && $exercise->isCorrectAnswer($answer, $i) ? 'correct_item' : 'mc_item' ?>">
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($print_correction): ?>
+ <? if ($exercise->isCorrectAnswer($answer, $i)) : ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else : ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+ </td>
+
+ <? if ($show_solution) : ?>
+ <td>
+ <? foreach ($exercise->correctAnswers($i) as $correct_answer): ?>
+ <div class="mc_item">
+ <?= formatReady($correct_answer) ?>
+ </div>
+ <? endforeach ?>
+ </td>
+ <? endif ?>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+</table>
+
+<div class="label-text">
+ <? if ($print_correction): ?>
+ <?= _('Nicht zugeordnete Antworten:') ?>
+ <? else: ?>
+ <?= _('Antwortmöglichkeiten:') ?>
+ <? endif ?>
+</div>
+
+<ol class="inline-content">
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?>
+ <li>
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($solution->id && $print_correction): ?>
+ <? if ($exercise->isCorrectAnswer($answer, -1)): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </li>
+ <? endif ?>
+ <? endforeach ?>
+</ol>
diff --git a/app/views/vips/exercises/MatchingTask/solve.php b/app/views/vips/exercises/MatchingTask/solve.php
new file mode 100644
index 0000000..8202819
--- /dev/null
+++ b/app/views/vips/exercises/MatchingTask/solve.php
@@ -0,0 +1,38 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<? $exercise->sortAnswersById(); ?>
+
+<table class="rh_table inline-content">
+ <? foreach ($exercise->task['groups'] as $i => $group): ?>
+ <tr style="vertical-align: top">
+ <td class="rh_label">
+ <?= formatReady($group) ?>
+ </td>
+ <td class="rh_list <?= htmlReady($exercise->task['select']) ?>" data-group="<?= $i ?>" title="<?= _('Elemente hier ablegen') ?>">
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (isset($response[$answer['id']]) && $response[$answer['id']] == $i): ?>
+ <div class="rh_item drag-handle" tabindex="0">
+ <?= formatReady($answer['text']) ?>
+ <input type="hidden" name="answer[<?= $answer['id'] ?>]" value="<?= $i ?>">
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+ </td>
+ <? if ($i == 0): ?>
+ <td rowspan="<?= count($exercise->task['groups']) ?>" class="rh_list answer_container" data-group="-1">
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? if (!isset($response[$answer['id']]) || $response[$answer['id']] == -1): ?>
+ <div class="rh_item drag-handle" tabindex="0">
+ <?= formatReady($answer['text']) ?>
+ <input type="hidden" name="answer[<?= $answer['id'] ?>]" value="-1">
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+ </td>
+ <? endif ?>
+ </tr>
+ <? endforeach ?>
+</table>
diff --git a/app/views/vips/exercises/MatchingTask/xml.php b/app/views/vips/exercises/MatchingTask/xml.php
new file mode 100644
index 0000000..41d35af
--- /dev/null
+++ b/app/views/vips/exercises/MatchingTask/xml.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="<?= $exercise->isMultiSelect() ? 'matching-multiple' : 'matching' ?>">
+ <choices>
+ <? foreach ($exercise->task['groups'] as $group): ?>
+ <choice type="group">
+ <?= htmlReady($group) ?>
+ </choice>
+ <? endforeach ?>
+ </choices>
+ <answers>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="1" correct="<?= $answer['group'] ?>">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/MatrixChoiceTask/correct.php b/app/views/vips/exercises/MatrixChoiceTask/correct.php
new file mode 100644
index 0000000..1b4f8ba
--- /dev/null
+++ b/app/views/vips/exercises/MatrixChoiceTask/correct.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ * @var array $optional_choice
+ */
+?>
+<table class="description inline-content">
+ <? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <tr class="mc_row">
+ <td class="mc_item">
+ <?= formatReady($entry['text']) ?>
+ </td>
+
+ <td style="white-space: nowrap;">
+ <? if (isset($response[$key]) && $response[$key] !== '' && $response[$key] != -1): ?>
+ <? if ($response[$key] == $entry['choice']): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+
+ <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?>
+ <span class="<?= $show_solution && $entry['choice'] == $val ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$key]) && $response[$key] === "$val"): ?>
+ <?= Assets::img('choice_checked.svg', ['style' => 'margin-left: 1ex;']) ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg', ['style' => 'margin-left: 1ex;']) ?>
+ <? endif ?>
+ <?= htmlReady($label) ?>
+ </span>
+ <? endforeach ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+</table>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MatrixChoiceTask/edit.php b/app/views/vips/exercises/MatrixChoiceTask/edit.php
new file mode 100644
index 0000000..946a217
--- /dev/null
+++ b/app/views/vips/exercises/MatrixChoiceTask/edit.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<div class="label-text">
+ <?= _('Auswahlmöglichkeiten') ?>
+</div>
+
+<div class="choice_list dynamic_list mc_row">
+ <? foreach ($exercise->task['choices'] as $i => $choice): ?>
+ <span class="dynamic_row">
+ <input type="text" class="character_input size-s" name="choice[<?= $i ?>]" value="<?= htmlReady($choice) ?>">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Auswahlmöglichkeit löschen')]) ?>
+ /
+ </span>
+ <? endforeach ?>
+
+ <span class="dynamic_row template">
+ <input type="text" class="character_input size-s" data-name="choice">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Auswahlmöglichkeit löschen')]) ?>
+ /
+ </span>
+
+ <?= Icon::create('add')->asInput(['class' => 'add_dynamic_row', 'title' => _('Auswahlmöglichkeit hinzufügen')]) ?>
+</div>
+
+<label>
+ <input type="checkbox" name="optional" value="1" <?= $exercise->options['optional'] ? 'checked' : '' ?>>
+ <?= _('Auswahlmöglichkeit „keine Antwort“ hinzufügen (ohne Bewertung)') ?>
+</label>
+
+<div class="label-text">
+ <?= _('Fragen/Aussagen') ?>
+</div>
+
+<div class="dynamic_list">
+ <? foreach ($exercise->task['answers'] as $i => $answer): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']); ?>
+
+ <div class="dynamic_row mc_row">
+ <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i]", 'value' => $answer['text'], 'size' => $size]) ?>
+ </label>
+
+ <span class="choice_select dynamic_list">
+ <? foreach ($exercise->task['choices'] as $val => $choice): ?>
+ <label class="dynamic_row undecorated">
+ <input type="radio" name="correct[<?= $i ?>]" value="<?= $val ?>" <? if ($answer['choice'] === $val): ?>checked<? endif ?>>
+ <span><?= htmlReady($choice) ?></span>
+ </label>
+ <? endforeach ?>
+
+ <label class="dynamic_row undecorated template">
+ <input type="radio" name="correct[<?= $i ?>]" data-value>
+ <span></span>
+ </label>
+ </span>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Frage löschen')]) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'answer', 'size' => 'small']) ?>
+ </label>
+
+ <span class="choice_select dynamic_list">
+ <? foreach ($exercise->task['choices'] as $val => $choice): ?>
+ <label class="dynamic_row undecorated">
+ <input type="radio" data-name="correct" value="<?= $val ?>">
+ <span><?= htmlReady($choice) ?></span>
+ </label>
+ <? endforeach ?>
+
+ <label class="dynamic_row undecorated template">
+ <input type="radio" data-name="correct" data-value=":value">
+ <span></span>
+ </label>
+ </span>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Frage löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Frage hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+</div>
+
+<div class="smaller">
+ <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?>
+</div>
diff --git a/app/views/vips/exercises/MatrixChoiceTask/print.php b/app/views/vips/exercises/MatrixChoiceTask/print.php
new file mode 100644
index 0000000..314730f
--- /dev/null
+++ b/app/views/vips/exercises/MatrixChoiceTask/print.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var bool $print_correction
+ * @var bool $show_solution
+ * @var array $optional_choice
+ */
+?>
+<table class="description inline-content">
+ <? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <tr class="mc_row">
+ <td class="mc_item">
+ <?= formatReady($entry['text']) ?>
+ </td>
+
+ <td style="white-space: nowrap;">
+ <? if (isset($response[$key]) && $response[$key] !== '' && $response[$key] != -1 && $print_correction): ?>
+ <? if ($response[$key] == $entry['choice']): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+
+ <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?>
+ <span class="<?= $show_solution && $entry['choice'] == $val ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$key]) && $response[$key] === "$val"): ?>
+ <?= Assets::img('choice_checked.svg', ['style' => 'margin-left: 1ex;']) ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg', ['style' => 'margin-left: 1ex;']) ?>
+ <? endif ?>
+ <?= htmlReady($label) ?>
+ </span>
+ <? endforeach ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+</table>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MatrixChoiceTask/solve.php b/app/views/vips/exercises/MatrixChoiceTask/solve.php
new file mode 100644
index 0000000..5b76764
--- /dev/null
+++ b/app/views/vips/exercises/MatrixChoiceTask/solve.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var array $optional_choice
+ */
+?>
+<table class="description inline-content">
+ <? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <tr>
+ <td>
+ <?= formatReady($entry['text']) ?>
+ </td>
+
+ <td style="white-space: nowrap;">
+ <? foreach ($exercise->task['choices'] + $optional_choice as $val => $label): ?>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="answer[<?= $key ?>]" value="<?= $val ?>"
+ <? if (!isset($response[$key]) && $val == -1 || isset($response[$key]) && $response[$key] === "$val"): ?>checked<? endif ?>>
+ <?= htmlReady($label) ?>
+ </label>
+ <? endforeach ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+</table>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MatrixChoiceTask/xml.php b/app/views/vips/exercises/MatrixChoiceTask/xml.php
new file mode 100644
index 0000000..2b8238b
--- /dev/null
+++ b/app/views/vips/exercises/MatrixChoiceTask/xml.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ * @var array $optional_choice
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="choice-multiple">
+ <choices>
+ <? foreach ($exercise->task['choices'] + $optional_choice as $key => $choice): ?>
+ <choice type="<?= $key == 0 ? 'yes' : ($key == 1 ? 'no' : ($key == -1 ? 'none' : 'group')) ?>">
+ <?= htmlReady($choice) ?>
+ </choice>
+ <? endforeach ?>
+ </choices>
+ <answers>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="<?= $answer['choice'] ? 0 : 1 ?>" correct="<?= (int) $answer['choice'] ?>">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/MultipleChoiceTask/correct.php b/app/views/vips/exercises/MultipleChoiceTask/correct.php
new file mode 100644
index 0000000..2f2a6dc
--- /dev/null
+++ b/app/views/vips/exercises/MultipleChoiceTask/correct.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ */
+?>
+<div class="mc_list inline-content">
+ <? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <div class="mc_flex <?= $show_solution && $entry['score'] ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$key]) && $response[$key]): ?>
+ <?= Assets::img('choice_checked.svg') ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg') ?>
+ <? endif ?>
+
+ <?= formatReady($entry['text']) ?>
+
+ <? if (isset($response[$key])): ?>
+ <? if ((int) $response[$key] == $entry['score']): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endforeach ?>
+</div>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MultipleChoiceTask/edit.php b/app/views/vips/exercises/MultipleChoiceTask/edit.php
new file mode 100644
index 0000000..c1ab69f
--- /dev/null
+++ b/app/views/vips/exercises/MultipleChoiceTask/edit.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<div class="label-text">
+ <?= _('Antwortalternativen') ?>
+</div>
+
+<div class="dynamic_list">
+ <? foreach ($exercise->task['answers'] as $i => $answer): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']); ?>
+
+ <div class="dynamic_row mc_row">
+ <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$i]", 'value' => $answer['text'], 'size' => $size]) ?>
+ </label>
+
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="checkbox" name="correct[<?= $i ?>]" value="1"<? if ($answer['score']): ?> checked<? endif ?>>
+ <?= _('richtig') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => 'answer', 'size' => 'small']) ?>
+ </label>
+
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="checkbox" data-name="correct" value="1">
+ <?= _('richtig') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+</div>
+
+<div class="smaller">
+ <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?>
+</div>
diff --git a/app/views/vips/exercises/MultipleChoiceTask/print.php b/app/views/vips/exercises/MultipleChoiceTask/print.php
new file mode 100644
index 0000000..d352f17
--- /dev/null
+++ b/app/views/vips/exercises/MultipleChoiceTask/print.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var bool $print_correction
+ * @var bool $show_solution
+ */
+?>
+<div class="mc_list inline-content">
+ <? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <div class="mc_flex <?= $show_solution && $entry['score'] ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$key]) && $response[$key]): ?>
+ <?= Assets::img('choice_checked.svg') ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg') ?>
+ <? endif ?>
+
+ <?= formatReady($entry['text']) ?>
+
+ <? if (isset($response[$key]) && $print_correction): ?>
+ <? if ((int) $response[$key] == $entry['score']): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endforeach ?>
+</div>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MultipleChoiceTask/solve.php b/app/views/vips/exercises/MultipleChoiceTask/solve.php
new file mode 100644
index 0000000..3c716e4
--- /dev/null
+++ b/app/views/vips/exercises/MultipleChoiceTask/solve.php
@@ -0,0 +1,13 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<? foreach ($exercise->task['answers'] as $key => $entry): ?>
+ <label class="inline-content mc_flex">
+ <input type="checkbox" name="answer[<?= $key ?>]" value="1"<? if (isset($response[$key]) && $response[$key]): ?> checked<? endif ?>>
+ <?= formatReady($entry['text']) ?>
+ </label>
+<? endforeach ?>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/MultipleChoiceTask/xml.php b/app/views/vips/exercises/MultipleChoiceTask/xml.php
new file mode 100644
index 0000000..4c1389d
--- /dev/null
+++ b/app/views/vips/exercises/MultipleChoiceTask/xml.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="choice-multiple">
+ <answers>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="<?= (int) $answer['score'] ?>">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/SequenceTask/correct.php b/app/views/vips/exercises/SequenceTask/correct.php
new file mode 100644
index 0000000..72cfba5
--- /dev/null
+++ b/app/views/vips/exercises/SequenceTask/correct.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * @var bool $show_solution
+ * @var array|null $response
+ * @var Exercise $exercise
+ * @var array $results
+ */
+?>
+<table class="default description inline-content nohover">
+ <thead>
+ <tr>
+ <th>
+ <?= _('Anzuordnende Antworten') ?>
+ </th>
+
+ <? if ($show_solution): ?>
+ <th>
+ <?= _('Richtige Antworten') ?>
+ </th>
+ <? endif ?>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr style="vertical-align: top;">
+ <td>
+ <? if ($response): ?>
+ <? foreach ($response as $n => $id): ?>
+ <? foreach ($exercise->task['answers'] as $i => $answer): ?>
+ <? if ($answer['id'] === $id): ?>
+ <? if ($exercise->task['compare'] === 'sequence'): ?>
+ <div class="neutral_item">
+ <?= formatReady($answer['text']) ?>
+ </div>
+
+ <? if ($n + 1 < count($response)): ?>
+ <div class="correction_marker sequence">
+ <? if ($results[$i]['points'] == 1): ?>
+ <span style="color: green;">}</span>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?>
+ <? else: ?>
+ <span style="color: red;">}</span>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+ <? elseif ($exercise->task['compare'] === 'position'): ?>
+ <div class="<?= $results[$i]['points'] == 1 ? 'correct_item' : 'mc_item' ?>">
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($results[$i]['points'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ </div>
+ <? else: ?>
+ <div class="mc_item">
+ <?= formatReady($answer['text']) ?>
+ </div>
+ <? endif ?>
+ <? endif ?>
+ <? endforeach ?>
+ <? endforeach ?>
+ <? else: ?>
+ <? foreach ($exercise->orderedAnswers($response) as $answer): ?>
+ <div class="mc_item">
+ <?= formatReady($answer['text']) ?>
+ </div>
+ <? endforeach ?>
+ <? endif ?>
+ </td>
+
+ <? if ($show_solution): ?>
+ <td>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <div class="correct_item">
+ <?= formatReady($answer['text']) ?>
+ </div>
+ <? endforeach ?>
+ </td>
+ <? endif ?>
+ </tr>
+ </tbody>
+</table>
diff --git a/app/views/vips/exercises/SequenceTask/edit.php b/app/views/vips/exercises/SequenceTask/edit.php
new file mode 100644
index 0000000..d8d61cc
--- /dev/null
+++ b/app/views/vips/exercises/SequenceTask/edit.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<div class="label-text">
+ <?= _('Anzuordnende Antworten') ?>
+</div>
+
+<div class="dynamic_list sortable_list">
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']); ?>
+
+ <div class="dynamic_row mc_row sortable_item drag-handle" tabindex="0">
+ <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => 'answer[]', 'value' => $answer['text'], 'size' => $size]) ?>
+ <input type="hidden" name="id[]" value="<?= $answer['id'] ?>">
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row sortable_item drag-handle template" tabindex="0">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => '', 'name' => 'answer[]', 'size' => 'small']) ?>
+ <input type="hidden" name="id[]">
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+</div>
+
+<label>
+ <?= _('Verfahren zur Punktevergabe') ?>
+
+ <select name="compare">
+ <option value="">
+ <?= _('Punkte nur bei vollständig korrekter Lösung') ?>
+ </option>
+ <option value="position" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'position' ? 'selected' : '' ?>>
+ <?= _('Punkte für Antworten an den korrekten Positionen') ?>
+ </option>
+ <option value="sequence" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'sequence' ? 'selected' : '' ?>>
+ <?= _('Punkte für Paare von Antworten in korrekter Reihenfolge') ?>
+ </option>
+ </select>
+</label>
diff --git a/app/views/vips/exercises/SequenceTask/print.php b/app/views/vips/exercises/SequenceTask/print.php
new file mode 100644
index 0000000..1ccb76d
--- /dev/null
+++ b/app/views/vips/exercises/SequenceTask/print.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * @var bool $show_solution
+ * @var array|null $response
+ * @var Exercise $exercise
+ * @var bool $print_correction
+ * @var array $results
+ */
+?>
+<table class="content description inline-content" style="min-width: 40em;">
+ <thead>
+ <tr>
+ <th>
+ <?= _('Anzuordnende Antworten') ?>
+ </th>
+
+ <? if ($show_solution): ?>
+ <th>
+ <?= _('Richtige Antworten') ?>
+ </th>
+ <? endif ?>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr style="vertical-align: top;">
+ <td>
+ <ol>
+ <? if ($response): ?>
+ <? foreach ($response as $n => $id): ?>
+ <? foreach ($exercise->task['answers'] as $i => $answer): ?>
+ <? if ($answer['id'] === $id): ?>
+ <? if ($exercise->task['compare'] === 'sequence'): ?>
+ <li class="neutral_item">
+ <?= formatReady($answer['text']) ?>
+ </li>
+
+ <? if ($print_correction && $n + 1 < count($response)): ?>
+ <div class="correction_marker sequence">
+ <? if ($results[$i]['points'] == 1): ?>
+ <span style="color: green;">}</span>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?>
+ <? else: ?>
+ <span style="color: red;">}</span>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+ <? elseif ($exercise->task['compare'] === 'position'): ?>
+ <li class="<?= $print_correction && $results[$i]['points'] == 1 ? 'correct_item' : 'mc_item' ?>">
+ <?= formatReady($answer['text']) ?>
+
+ <? if ($print_correction): ?>
+ <? if ($results[$i]['points'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </li>
+ <? else: ?>
+ <li class="mc_item">
+ <?= formatReady($answer['text']) ?>
+ </li>
+ <? endif ?>
+ <? endif ?>
+ <? endforeach ?>
+ <? endforeach ?>
+ <? else: ?>
+ <? foreach ($exercise->orderedAnswers($response) as $answer): ?>
+ <li class="mc_item">
+ <?= formatReady($answer['text']) ?>
+ </li>
+ <? endforeach ?>
+ <? endif ?>
+ </ol>
+ </td>
+
+ <? if ($show_solution): ?>
+ <td>
+ <ol>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <li class="mc_item">
+ <?= formatReady($answer['text']) ?>
+ </li>
+ <? endforeach ?>
+ </ol>
+ </td>
+ <? endif ?>
+ </tr>
+ </tbody>
+</table>
diff --git a/app/views/vips/exercises/SequenceTask/solve.php b/app/views/vips/exercises/SequenceTask/solve.php
new file mode 100644
index 0000000..9ffe605
--- /dev/null
+++ b/app/views/vips/exercises/SequenceTask/solve.php
@@ -0,0 +1,14 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var array $response
+ */
+?>
+<div class="mc_list rh_list inline-content" title="<?= _('Elemente hier ablegen') ?>">
+ <? foreach ($exercise->orderedAnswers($response) as $answer): ?>
+ <div class="rh_item drag-handle" tabindex="0">
+ <?= formatReady($answer['text']) ?>
+ <input type="hidden" name="answer[]" value="<?= $answer['id'] ?>">
+ </div>
+ <? endforeach ?>
+</div>
diff --git a/app/views/vips/exercises/SequenceTask/xml.php b/app/views/vips/exercises/SequenceTask/xml.php
new file mode 100644
index 0000000..244784c
--- /dev/null
+++ b/app/views/vips/exercises/SequenceTask/xml.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="sequence">
+ <answers>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="1">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if (!empty($exercise->task['compare'])): ?>
+ <evaluation-hints>
+ <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/>
+ </evaluation-hints>
+ <? endif ?>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/SingleChoiceTask/correct.php b/app/views/vips/exercises/SingleChoiceTask/correct.php
new file mode 100644
index 0000000..cecc743
--- /dev/null
+++ b/app/views/vips/exercises/SingleChoiceTask/correct.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ * @var array $optional_answer
+ */
+?>
+<? foreach ($exercise->task as $group => $task): ?>
+ <div <?= $group ? 'class="group_separator"' : '' ?>>
+ <? if (isset($task['description'])): ?>
+ <?= formatReady($task['description']) ?>
+ <? endif ?>
+ </div>
+
+ <div class="mc_list inline-content">
+ <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?>
+ <div class="mc_flex <?= $show_solution && $entry['score'] == 1 ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$group]) && $response[$group] === "$key"): ?>
+ <?= Assets::img('choice_checked.svg') ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg') ?>
+ <? endif ?>
+
+ <?= formatReady($entry['text']) ?>
+
+ <? if (isset($response[$group]) && $response[$group] === "$key"): ?>
+ <? if ($entry['score'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? elseif ($key != -1): ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endforeach ?>
+ </div>
+<? endforeach ?>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/SingleChoiceTask/edit.php b/app/views/vips/exercises/SingleChoiceTask/edit.php
new file mode 100644
index 0000000..bd368c7
--- /dev/null
+++ b/app/views/vips/exercises/SingleChoiceTask/edit.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<label>
+ <input type="checkbox" name="optional" value="1" <?= $exercise->options['optional'] ? 'checked' : '' ?>>
+ <?= _('Antwortalternative „keine Antwort“ hinzufügen (ohne Bewertung)') ?>
+</label>
+
+<div class="label-text">
+ <?= _('Antwortalternativen') ?>
+</div>
+
+<div class="dynamic_list">
+ <? foreach ($exercise->task as $j => $task): ?>
+ <div class="dynamic_list dynamic_row" style="border-bottom: 1px dotted grey;">
+ <label class="hide_first">
+ <?= _('Zwischentext') ?>
+ <textarea name="description[<?= $j ?>]" class="character_input size-l wysiwyg"><?= isset($task['description']) ? wysiwygReady($task['description']) : '' ?></textarea>
+ </label>
+
+ <? foreach ($task['answers'] as $i => $answer): ?>
+ <? $size = $exercise->flexibleInputSize($answer['text']); ?>
+
+ <div class="dynamic_row mc_row">
+ <label class="dynamic_counter size_toggle size_<?= $size ?> undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['name' => "answer[$j][$i]", 'value' => $answer['text'], 'size' => $size]) ?>
+ </label>
+
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="correct[<?= $j ?>]" value="<?= $i ?>"<? if ($answer['score'] == 1): ?> checked<? endif ?>>
+ <?= _('richtig') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => "answer[$j]", 'size' => 'small']) ?>
+ </label>
+
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="correct[<?= $j ?>]" data-value>
+ <?= _('richtig') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+ <?= Studip\Button::create(_('Antwortblock löschen'), 'del_group', ['class' => 'delete_dynamic_row']) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_list dynamic_row template" style="border-bottom: 1px dotted grey;">
+ <label class="hide_first">
+ <?= _('Zwischentext') ?>
+ <textarea data-name="description" class="character_input size-l wysiwyg-hidden"></textarea>
+ </label>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter size_toggle size_small undecorated">
+ <?= $this->render_partial('exercises/flexible_input', ['data_name' => ':answer', 'size' => 'small']) ?>
+ </label>
+
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" data-name="correct" data-value=":value">
+ <?= _('richtig') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+ <?= Studip\Button::create(_('Antwortblock löschen'), 'del_group', ['class' => 'delete_dynamic_row']) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwortblock hinzufügen'), 'add_group', ['class' => 'add_dynamic_row']) ?>
+</div>
+
+<div class="smaller">
+ <?= _('Leere Antwortalternativen werden automatisch gelöscht.') ?>
+</div>
diff --git a/app/views/vips/exercises/SingleChoiceTask/print.php b/app/views/vips/exercises/SingleChoiceTask/print.php
new file mode 100644
index 0000000..a61bc84
--- /dev/null
+++ b/app/views/vips/exercises/SingleChoiceTask/print.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var bool $print_correction
+ * @var bool $show_solution
+ * @var array $optional_answer
+ */
+?>
+<? foreach ($exercise->task as $group => $task): ?>
+ <div <?= $group ? 'class="group_separator"' : '' ?>>
+ <? if (isset($task['description'])): ?>
+ <?= formatReady($task['description']) ?>
+ <? endif ?>
+ </div>
+
+ <div class="mc_list inline-content">
+ <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?>
+ <div class="mc_flex <?= $show_solution && $entry['score'] == 1 ? 'correct_item' : 'mc_item' ?>">
+ <? if (isset($response[$group]) && $response[$group] === "$key"): ?>
+ <?= Assets::img('choice_checked.svg') ?>
+ <? else: ?>
+ <?= Assets::img('choice_unchecked.svg') ?>
+ <? endif ?>
+
+ <?= formatReady($entry['text']) ?>
+
+ <? if ($print_correction): ?>
+ <? if (isset($response[$group]) && $response[$group] === "$key"): ?>
+ <? if ($entry['score'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['class' => 'correction_marker', 'title' => _('richtig')]) ?>
+ <? elseif ($key != -1): ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['class' => 'correction_marker', 'title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+ <? endforeach ?>
+ </div>
+<? endforeach ?>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/SingleChoiceTask/solve.php b/app/views/vips/exercises/SingleChoiceTask/solve.php
new file mode 100644
index 0000000..70689bf
--- /dev/null
+++ b/app/views/vips/exercises/SingleChoiceTask/solve.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var array $optional_answer
+ */
+?>
+<? foreach ($exercise->task as $group => $task): ?>
+ <div <?= $group ? 'class="group_separator"' : '' ?>>
+ <? if (isset($task['description'])): ?>
+ <?= formatReady($task['description']) ?>
+ <? endif ?>
+ </div>
+
+ <? foreach ($task['answers'] + $optional_answer as $key => $entry): ?>
+ <label class="inline-content mc_flex">
+ <input type="radio" name="answer[<?= $group ?>]" value="<?= $key ?>"
+ <? if (!isset($response[$group]) && $key == -1 || isset($response[$group]) && $response[$group] === "$key"): ?>checked<? endif ?>>
+ <?= formatReady($entry['text']) ?>
+ </label>
+ <? endforeach ?>
+<? endforeach ?>
+
+<?= $this->render_partial('exercises/evaluation_mode_info') ?>
diff --git a/app/views/vips/exercises/SingleChoiceTask/xml.php b/app/views/vips/exercises/SingleChoiceTask/xml.php
new file mode 100644
index 0000000..423a183
--- /dev/null
+++ b/app/views/vips/exercises/SingleChoiceTask/xml.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ * @var array $optional_answer
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <? foreach ($exercise->task as $group => $task): ?>
+ <item type="choice-single">
+ <? if (isset($task['description']) && $task['description'] != ''): ?>
+ <description>
+ <text><?= htmlReady($task['description']) ?></text>
+ </description>
+ <? endif ?>
+ <answers>
+ <? foreach ($task['answers'] + $optional_answer as $key => $answer): ?>
+ <answer score="<?= (int) $answer['score'] ?>"<? if ($key == -1): ?> default="true"<? endif ?>>
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ <? endforeach ?>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/TextLineTask/correct.php b/app/views/vips/exercises/TextLineTask/correct.php
new file mode 100644
index 0000000..27bcefc
--- /dev/null
+++ b/app/views/vips/exercises/TextLineTask/correct.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ * @var bool $edit_solution
+ */
+?>
+<? if ($solution->id): ?>
+ <div class="label-text">
+ <?= _('Antwort:') ?>
+ </div>
+
+ <?= htmlReady($response[0]) ?>
+
+ <? if ($results[0]['points'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?>
+ <? elseif ($results[0]['points'] == 0.5): ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?>
+ <? elseif (!$edit_solution || $results[0]['safe']): ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?>
+ <? else: ?>
+ <?= Icon::create('question', Icon::ROLE_STATUS_RED)->asImg(['title' => _('unbekannte Antwort')]) ?>
+ <? endif ?>
+<? endif ?>
+
+<? if ($show_solution && $exercise->correctAnswers()): ?>
+ <div class="label-text">
+ <?= _('Richtige Antworten:') ?>
+
+ <span class="correct_item">
+ <?= htmlReady(implode(' | ', $exercise->correctAnswers())) ?>
+ </span>
+ </div>
+<? endif ?>
diff --git a/app/views/vips/exercises/TextLineTask/edit.php b/app/views/vips/exercises/TextLineTask/edit.php
new file mode 100644
index 0000000..3d2af0a
--- /dev/null
+++ b/app/views/vips/exercises/TextLineTask/edit.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<div class="label-text">
+ <?= _('Automatisch bewertete Antworten') ?>
+</div>
+
+<div class="dynamic_list">
+ <? foreach ($exercise->task['answers'] as $i => $answer): ?>
+ <div class="dynamic_row mc_row">
+ <label class="dynamic_counter undecorated">
+ <input class="character_input" name="answer[<?= $i ?>]" type="text" value="<?= htmlReady($answer['text']) ?>">
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="correct[<?= $i ?>]" value="1"<? if ($answer['score'] == 1): ?> checked<? endif ?>>
+ <?= _('richtig') ?>
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="correct[<?= $i ?>]" value="0.5"<? if ($answer['score'] == 0.5): ?> checked<? endif ?>>
+ <?= _('teils richtig') ?>
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" name="correct[<?= $i ?>]" value="0"<? if ($answer['score'] == 0): ?> checked<? endif ?>>
+ <?= _('falsch') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+ <? endforeach ?>
+
+ <div class="dynamic_row mc_row template">
+ <label class="dynamic_counter undecorated">
+ <input class="character_input" data-name="answer" type="text">
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" data-name="correct" value="1">
+ <?= _('richtig') ?>
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" data-name="correct" value="0.5">
+ <?= _('teils richtig') ?>
+ </label>
+ <label class="undecorated" style="padding: 1ex;">
+ <input type="radio" data-name="correct" value="0" checked>
+ <?= _('falsch') ?>
+ </label>
+
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Antwort löschen')]) ?>
+ </div>
+
+ <?= Studip\Button::create(_('Antwort hinzufügen'), 'add_answer', ['class' => 'add_dynamic_row']) ?>
+</div>
+
+<label>
+ <?= _('Art des Textvergleichs') ?>
+
+ <select name="compare" onchange="$(this).parent().next('label').toggle($(this).val() === 'numeric')">
+ <option value="">
+ <?= _('Groß-/Kleinschreibung ignorieren') ?>
+ </option>
+ <option value="levenshtein" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'levenshtein' ? 'selected' : '' ?>>
+ <?= _('Textähnlichkeit (Levenshtein-Distanz)') ?>
+ </option>
+ <option value="soundex" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'soundex' ? 'selected' : '' ?>>
+ <?= _('Ähnlichkeit der Aussprache (Soundex)') ?>
+ </option>
+ <option value="numeric" <?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? 'selected' : '' ?>>
+ <?= _('Numerischer Wertevergleich (ggf. mit Einheit)') ?>
+ </option>
+ </select>
+</label>
+
+<label style="<?= isset($exercise->task['compare']) && $exercise->task['compare'] === 'numeric' ? '' : 'display: none;' ?>">
+ <?= _('Erlaubte relative Abweichung vom korrekten Wert') ?>
+ <br>
+ <input type="text" class="size-s" style="display: inline; text-align: right;"
+ name="epsilon" value="<?= isset($exercise->task['epsilon']) ? sprintf('%g', $exercise->task['epsilon'] * 100) : '0' ?>"> %
+</label>
diff --git a/app/views/vips/exercises/TextLineTask/print.php b/app/views/vips/exercises/TextLineTask/print.php
new file mode 100644
index 0000000..3bfbc2e
--- /dev/null
+++ b/app/views/vips/exercises/TextLineTask/print.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var array $response
+ * @var array $results
+ * @var bool $print_correction
+ * @var bool $show_solution
+ */
+?>
+<? if ($solution->id) : ?>
+ <?= htmlReady($response[0]) ?>
+
+ <? if ($print_correction): ?>
+ <? if ($results[0]['points'] == 1): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?>
+ <? elseif ($results[0]['points'] == 0.5): ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?>
+ <? else: ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?>
+ <? endif ?>
+ <? endif ?>
+<? else : ?>
+ <div style="height: 6em;"></div>
+<? endif ?>
+
+<? if ($show_solution && $exercise->correctAnswers()) : ?>
+ <div>
+ <?= _('Richtige Antworten:') ?>
+
+ <span class="correct_item">
+ <?= htmlReady(implode(' | ', $exercise->correctAnswers())) ?>
+ </span>
+ </div>
+<? endif ?>
diff --git a/app/views/vips/exercises/TextLineTask/solve.php b/app/views/vips/exercises/TextLineTask/solve.php
new file mode 100644
index 0000000..8fab98a
--- /dev/null
+++ b/app/views/vips/exercises/TextLineTask/solve.php
@@ -0,0 +1,9 @@
+<?php
+/**
+ * @var array $response
+ */
+?>
+<label>
+ <?= _('Antwort') ?>
+ <input type="text" class="character_input" name="answer[0]" value="<?= htmlReady($response[0] ?? '') ?>">
+</label>
diff --git a/app/views/vips/exercises/TextLineTask/xml.php b/app/views/vips/exercises/TextLineTask/xml.php
new file mode 100644
index 0000000..d4b46c0
--- /dev/null
+++ b/app/views/vips/exercises/TextLineTask/xml.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="text-line">
+ <answers>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="<?= (float) $answer['score'] ?>">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <? if (!empty($exercise->task['compare'])): ?>
+ <evaluation-hints>
+ <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/>
+ <? if ($exercise->task['compare'] === 'numeric'): ?>
+ <input-data type="relative-epsilon">
+ <?= (float) $exercise->task['epsilon'] ?>
+ </input-data>
+ <? endif ?>
+ </evaluation-hints>
+ <? endif ?>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/TextTask/correct.php b/app/views/vips/exercises/TextTask/correct.php
new file mode 100644
index 0000000..bdd99a6
--- /dev/null
+++ b/app/views/vips/exercises/TextTask/correct.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var array $results
+ * @var array $response
+ * @var bool $show_solution
+ * @var bool $edit_solution
+ */
+?>
+<? if ($exercise->getLayout() !== 'none' && $response[0] != ''): ?>
+ <div class="vips_tabs <?= $solution->commented_solution ? '' : 'edit-hidden' ?>">
+ <ul>
+ <li class="edit-tab">
+ <a href="#commented-<?= $exercise->id ?>">
+ <?= _('Kommentierte Lösung') ?>
+ </a>
+ </li>
+ <li>
+ <a href="#solution-<?= $exercise->id ?>">
+ <?= _('Lösung') ?>
+ </a>
+ </li>
+ <? if ($exercise->task['template'] != ''): ?>
+ <li>
+ <a href="#default-<?= $exercise->id ?>">
+ <?= _('Vorbelegung') ?>
+ </a>
+ </li>
+ <? endif ?>
+ </ul>
+
+ <div id="commented-<?= $exercise->id ?>">
+ <? if ($edit_solution): ?>
+ <? if ($exercise->getLayout() === 'markup'): ?>
+ <? $answer = $response[0] ?>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <? $answer = "[pre][nop]\n{$response[0]}\n[/nop][/pre]" ?>
+ <? elseif (Studip\Markup::editorEnabled()): ?>
+ <? $answer = Studip\Markup::markAsHtml(htmlReady($response[0], true, true)) ?>
+ <? else: ?>
+ <? $answer = $response[0] ?>
+ <? endif ?>
+ <textarea <?= $solution->commented_solution ? 'name="commented_solution"' : '' ?> class="character_input size-l wysiwyg" rows="20"
+ ><?= wysiwygReady($solution->commented_solution ?: $answer) ?></textarea>
+
+ <?= Studip\Button::create(_('Kommentierte Lösung löschen'), 'delete_commented_solution', ['data-confirm' => _('Wollen Sie die kommentierte Lösung löschen?')]) ?>
+
+ <? if ($solution->commented_solution): ?>
+ <? if (!Studip\Markup::editorEnabled()): ?>
+ <div class="label-text">
+ <?= _('Textvorschau') ?>
+ </div>
+
+ <div class="vips_output">
+ <?= formatReady($solution->commented_solution) ?>
+ </div>
+ <? endif ?>
+ <? endif ?>
+ <? else: ?>
+ <div class="vips_output">
+ <?= formatReady($solution->commented_solution) ?>
+ </div>
+ <? endif ?>
+ </div>
+
+ <div id="solution-<?= $exercise->id ?>">
+ <div class="vips_output">
+ <? if ($exercise->getLayout() === 'text'): ?>
+ <?= htmlReady($response[0], true, true) ?>
+ <? elseif ($exercise->getLayout() === 'markup'): ?>
+ <?= formatReady($response[0]) ?>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <pre><?= htmlReady($response[0]) ?></pre>
+ <input type="hidden" class="download" value="<?= htmlReady($response[0]) ?>">
+ <? endif ?>
+ </div>
+
+ <? if ($edit_solution): ?>
+ <?= Studip\Button::create(_('Lösung bearbeiten'), 'edit_solution', ['class' => 'edit_solution']) ?>
+
+ <? if ($exercise->getLayout() === 'code'): ?>
+ <a hidden download="<?= htmlReady($exercise->title) ?>.txt" target="_blank"></a>
+ <?= Studip\Button::create(_('Lösung herunterladen'), 'download', ['class' => 'vips_file_download']) ?>
+ <? endif ?>
+ <? endif ?>
+ </div>
+
+ <? if ($exercise->task['template'] != ''): ?>
+ <div id="default-<?= $exercise->id ?>">
+ <div class="vips_output">
+ <? if ($exercise->getLayout() === 'text'): ?>
+ <?= htmlReady($exercise->task['template'], true, true) ?>
+ <? elseif ($exercise->getLayout() === 'markup'): ?>
+ <?= formatReady($exercise->task['template']) ?>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <pre><?= htmlReady($exercise->task['template']) ?></pre>
+ <? endif ?>
+ </div>
+ </div>
+ <? endif ?>
+ </div>
+<? elseif ($exercise->getLayout() !== 'none'): ?>
+ <div class="description" style="font-style: italic;">
+ <?= _('Es wurde kein Text als Lösung abgegeben.') ?>
+ </div>
+<? endif ?>
+
+<? if ($exercise->options['file_upload'] && $solution->folder && count($solution->folder->file_refs)): ?>
+ <? foreach ($solution->folder->file_refs as $file_ref): ?>
+ <? if ($file_ref->isImage()): ?>
+ <div class="label-text">
+ <?= htmlReady($file_ref->name) ?>:
+ </div>
+ <div class="formatted-content">
+ <img src="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+
+ <div class="label-text">
+ <?= _('Hochgeladene Dateien') ?>
+ </div>
+
+ <table class="default">
+ <thead>
+ <tr>
+ <th style="width: 50%;">
+ <?= _('Name') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Größe') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Autor/-in') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Datum') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($solution->folder->file_refs as $file_ref): ?>
+ <tr>
+ <td>
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </td>
+ <td>
+ <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?>
+ </td>
+ <td>
+ <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <? if ($solution->folder && count($solution->folder->file_refs) > 1): ?>
+ <tfoot>
+ <tr>
+ <td colspan="4">
+ <?= Studip\LinkButton::create(_('Alle Dateien herunterladen'), $controller->url_for('file/download_folder', $solution->folder->id)) ?>
+ </td>
+ </tr>
+ </tfoot>
+ <? endif ?>
+ </table>
+<? endif ?>
+
+<? if ($show_solution && $exercise->task['answers'][0]['text'] != ''): ?>
+ <div class="label-text">
+ <?= _('Musterlösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= formatReady($exercise->task['answers'][0]['text']) ?>
+ </div>
+<? endif ?>
diff --git a/app/views/vips/exercises/TextTask/edit.php b/app/views/vips/exercises/TextTask/edit.php
new file mode 100644
index 0000000..df7304e
--- /dev/null
+++ b/app/views/vips/exercises/TextTask/edit.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ */
+?>
+<label>
+ <?= _('Art der Abgabe') ?>
+
+ <select class="tb_layout" name="layout" onchange="$(this).closest('fieldset').find('.none-hidden').toggle($(this).val() !== 'none')">
+ <option value="">
+ <?= _('Texteingabe - einfacher Text ohne Formatierungen') ?>
+ </option>
+ <option value="markup" <? if ($exercise->getLayout() === 'markup'): ?>selected<? endif ?>>
+ <?= _('Texteingabe - Textformatierungen bei Eingabe der Lösung anbieten') ?>
+ </option>
+ <option value="code" <? if ($exercise->getLayout() === 'code'): ?>selected<? endif ?>>
+ <?= _('Texteingabe - Programmcode (nichtproportionale Schriftart)') ?>
+ </option>
+ <option value="none" <? if ($exercise->getLayout() === 'none'): ?>selected<? endif ?>>
+ <?= _('keine Texteingabe - nur Hochladen von Dateien erlauben') ?>
+ </option>
+ </select>
+</label>
+
+<label class="none-hidden" style="<?= $exercise->getLayout() === 'none' ? 'display: none;' : '' ?>">
+ <?= _('Vorgegebener Text im Antwortfeld') ?>
+ <?= $this->render_partial('exercises/flexible_textarea',
+ ['name' => 'answer_default', 'value' => $exercise->task['template'], 'monospace' => $exercise->getLayout() === 'code', 'wysiwyg' => $exercise->getLayout() === 'markup']) ?>
+</label>
+
+<label>
+ <?= _('Musterlösung') ?>
+ <textarea class="character_input size-l wysiwyg" name="answer_0" rows="<?= $exercise->textareaSize($exercise->task['answers'][0]['text']) ?>"><?= wysiwygReady($exercise->task['answers'][0]['text']) ?></textarea>
+</label>
+
+<div class="none-hidden" style="<?= $exercise->getLayout() === 'none' ? 'display: none;' : '' ?>">
+ <label>
+ <input type="checkbox" name="file_upload" value="1" <?= $exercise->options['file_upload'] ? ' checked' : '' ?>>
+ <?= _('Hochladen von Dateien als Lösung erlauben') ?>
+ <?= tooltipIcon(_('Hochgeladene Dateien können nicht automatisch bewertet werden.')) ?>
+ </label>
+
+ <label>
+ <input type="checkbox" name="compare" value="levenshtein" <?= $exercise->task['compare'] === 'levenshtein' ? 'checked' : '' ?>>
+ <?= _('Punktevorschlag basierend auf Textähnlichkeit (Levenshtein-Distanz)') ?>
+ </label>
+</div>
diff --git a/app/views/vips/exercises/TextTask/print.php b/app/views/vips/exercises/TextTask/print.php
new file mode 100644
index 0000000..e8e29c5
--- /dev/null
+++ b/app/views/vips/exercises/TextTask/print.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var array $response
+ * @var array $results
+ * @var bool $print_correction
+ * @var bool $show_solution
+ * @var bool $print_files
+ */
+?>
+<? if ($exercise->getLayout() !== 'none'): ?>
+ <? if ($print_correction && $solution->commented_solution != '') : ?>
+ <div class="label-text">
+ <?= _('Kommentierte Lösung:') ?>
+ </div>
+
+ <?= formatReady($solution->commented_solution) ?>
+ <? elseif ($solution->id && $response[0] != '') : ?>
+ <div class="label-text">
+ <?= _('Lösung des Teilnehmers:') ?>
+ </div>
+
+ <div class="vips_output">
+ <? if ($exercise->getLayout() === 'markup'): ?>
+ <?= formatReady($response[0]) ?>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <pre><?= htmlReady($response[0]) ?></pre>
+ <? else: ?>
+ <?= htmlReady($response[0], true, true) ?>
+ <? endif ?>
+ </div>
+ <? elseif ($print_correction) : ?>
+ <div class="description" style="font-style: italic;">
+ <?= _('Es wurde kein Text als Lösung abgegeben.') ?>
+ </div>
+ <? else : ?>
+ <div class="vips_output" style="min-height: 30em;">
+ <? if ($exercise->getLayout() === 'markup'): ?>
+ <?= formatReady($exercise->task['template']) ?>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <pre><?= htmlReady($exercise->task['template']) ?></pre>
+ <? else: ?>
+ <?= htmlReady($exercise->task['template'], true, true) ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+<? endif ?>
+
+<? if ($exercise->options['file_upload'] && $solution && $solution->folder && count($solution->folder->file_refs)): ?>
+ <? foreach ($solution->folder->file_refs as $file_ref): ?>
+ <? if ($print_files && $file_ref->isImage()): ?>
+ <div class="label-text">
+ <?= htmlReady($file_ref->name) ?>:
+ </div>
+ <div class="formatted-content">
+ <img src="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ </div>
+ <? endif ?>
+ <? endforeach ?>
+
+ <div class="label-text">
+ <?= _('Hochgeladene Dateien:') ?>
+ </div>
+
+ <ul>
+ <? foreach ($solution->folder->file_refs as $file_ref): ?>
+ <li>
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </li>
+ <? endforeach ?>
+ </ul>
+<? endif ?>
+
+<? if ($show_solution && $exercise->task['answers'][0]['text'] != '') : ?>
+ <div class="label-text">
+ <?= _('Musterlösung:') ?>
+ </div>
+
+ <?= formatReady($exercise->task['answers'][0]['text']) ?>
+<? endif ?>
diff --git a/app/views/vips/exercises/TextTask/solve.php b/app/views/vips/exercises/TextTask/solve.php
new file mode 100644
index 0000000..ddafce6
--- /dev/null
+++ b/app/views/vips/exercises/TextTask/solve.php
@@ -0,0 +1,131 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var VipsSolution $solution
+ * @var VipsAssignment $assignment
+ */
+?>
+<? if ($exercise->getLayout() !== 'none'): ?>
+ <? if ($exercise->task['template'] != ''): ?>
+ <div class="vips_tabs">
+ <ul>
+ <li>
+ <a href="#solution-<?= $exercise->id ?>">
+ <?= _('Antwort') ?>
+ </a>
+ </li>
+ <li>
+ <a href="#default-<?= $exercise->id ?>">
+ <?= _('Vorbelegung') ?>
+ </a>
+ </li>
+ </ul>
+ <? else: ?>
+ <label>
+ <?= _('Antwort') ?>
+ <? endif ?>
+
+ <? /* student answer */ ?>
+ <div id="solution-<?= $exercise->id ?>">
+ <? $answer = isset($response) ? $response[0] : $exercise->task['template'] ?>
+ <? if ($exercise->getLayout() === 'markup'): ?>
+ <textarea name="answer[0]" class="character_input size-l wysiwyg" data-editor="removePlugins=studip-quote,studip-upload,ImageUpload" rows="20"><?= wysiwygReady($answer) ?></textarea>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <textarea name="answer[0]" class="character_input size-l monospace download" rows="20"><?= htmlReady($answer) ?></textarea>
+
+ <a hidden download="<?= htmlReady($exercise->title) ?>.txt" target="_blank"></a>
+ <?= Studip\Button::create(_('Antwort herunterladen'), 'download', ['class' => 'vips_file_download']) ?>
+ <input hidden class="file_upload inline" type="file">
+ <?= Studip\Button::create(_('Text in das Eingabefeld hochladen'), 'upload', ['class' => 'vips_file_upload']) ?>
+ <? else: ?>
+ <textarea name="answer[0]" class="character_input size-l" rows="20"><?= htmlReady($answer) ?></textarea>
+ <? endif ?>
+ </div>
+
+ <? if ($exercise->task['template'] == ''): ?>
+ </label>
+ <? else: ?>
+ <? /* default answer */ ?>
+ <div id="default-<?= $exercise->id ?>">
+ <? if ($exercise->getLayout() === 'markup'): ?>
+ <textarea readonly class="size-l wysiwyg" rows="20"><?= wysiwygReady($exercise->task['template']) ?></textarea>
+ <? elseif ($exercise->getLayout() === 'code'): ?>
+ <textarea readonly class="size-l monospace" rows="20"><?= htmlReady($exercise->task['template']) ?></textarea>
+ <? else: ?>
+ <textarea readonly class="size-l" rows="20"><?= htmlReady($exercise->task['template']) ?></textarea>
+ <? endif ?>
+ </div>
+ </div>
+ <? endif ?>
+<? endif ?>
+
+<? if ($exercise->options['file_upload']): ?>
+ <div class="label-text">
+ <? if ($solution && $solution->folder && count($solution->folder->file_refs)): ?>
+ <?= _('Hochgeladene Dateien') ?>
+ <? else: ?>
+ <?= _('Keine Dateien hochgeladen') ?>
+ <? endif ?>
+ (<?= sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576) ?>)
+ </div>
+
+ <table class="default">
+ <? if ($solution && $solution->folder && count($solution->folder->file_refs)): ?>
+ <thead>
+ <tr>
+ <th style="width: 50%;">
+ <?= _('Name') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Größe') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Autor/-in') ?>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Datum') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list">
+ <? foreach ($solution->folder->file_refs as $file_ref): ?>
+ <tr class="dynamic_row">
+ <td>
+ <input type="hidden" name="file_ids[]" value="<?= $file_ref->id ?>">
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </td>
+ <td>
+ <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?>
+ </td>
+ <td>
+ <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?>
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ <? endif ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="5">
+ <?= Studip\Button::create(_('Datei als Lösung hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?>
+ <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span>
+ <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+<? endif ?>
diff --git a/app/views/vips/exercises/TextTask/xml.php b/app/views/vips/exercises/TextTask/xml.php
new file mode 100644
index 0000000..dd79d53
--- /dev/null
+++ b/app/views/vips/exercises/TextTask/xml.php
@@ -0,0 +1,61 @@
+<?php
+/**
+ * @var ClozeTask $exercise
+ * @var float|int $points
+ */
+?>
+<exercise id="exercise-<?= $exercise->id ?>" points="<?= $points ?>"
+<? if ($exercise->options['comment']): ?> feedback="true"<? endif ?>>
+ <title>
+ <?= htmlReady($exercise->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($exercise->description) ?>
+ </description>
+ <? if ($exercise->options['hint'] != ''): ?>
+ <hint>
+ <?= htmlReady($exercise->options['hint']) ?>
+ </hint>
+ <? endif ?>
+ <items>
+ <item type="text-area">
+ <answers>
+ <? if ($exercise->task['template'] != ''): ?>
+ <answer score="0" default="true">
+ <?= htmlReady($exercise->task['template']) ?>
+ </answer>
+ <? endif ?>
+ <? foreach ($exercise->task['answers'] as $answer): ?>
+ <answer score="<?= (float) $answer['score'] ?>">
+ <?= htmlReady($answer['text']) ?>
+ </answer>
+ <? endforeach ?>
+ </answers>
+ <submission-hints>
+ <? if (!empty($exercise->task['layout'])): ?>
+ <input type="<?= htmlReady($exercise->task['layout']) ?>"/>
+ <? endif ?>
+ <? if ($exercise->options['file_upload']): ?>
+ <attachments upload="true"/>
+ <? endif ?>
+ </submission-hints>
+ <? if (!empty($exercise->task['compare'])): ?>
+ <evaluation-hints>
+ <similarity type="<?= htmlReady($exercise->task['compare']) ?>"/>
+ </evaluation-hints>
+ <? endif ?>
+ <? if ($exercise->options['feedback'] != ''): ?>
+ <feedback>
+ <?= htmlReady($exercise->options['feedback']) ?>
+ </feedback>
+ <? endif ?>
+ </item>
+ </items>
+ <? if ($exercise->folder): ?>
+ <file-refs<? if ($exercise->options['files_hidden']): ?> hidden="true"<? endif ?>>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <file-ref ref="file-<?= $file_ref->file_id ?>"/>
+ <? endforeach ?>
+ </file-refs>
+ <? endif ?>
+</exercise>
diff --git a/app/views/vips/exercises/correct_exercise.php b/app/views/vips/exercises/correct_exercise.php
new file mode 100644
index 0000000..c62cb0e
--- /dev/null
+++ b/app/views/vips/exercises/correct_exercise.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * @var int $exercise_position
+ * @var Exercise $exercise
+ * @var float|int $max_points
+ * @var VipsSolution $solution
+ */
+?>
+<fieldset>
+ <legend>
+ <?= $exercise_position ?>.
+ <?= htmlReady($exercise->title) ?>
+ <div style="float: right;">
+ <? if ($max_points == (int) $max_points): ?>
+ <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?>
+ <? else: ?>
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ <? endif ?>
+ </div>
+ </legend>
+
+ <div class="description">
+ <?= formatReady($exercise->description) ?>
+ </div>
+
+ <?= $this->render_partial('vips/exercises/show_exercise_hint') ?>
+ <?= $this->render_partial('vips/exercises/show_exercise_files') ?>
+
+ <?= $this->render_partial($exercise->getCorrectionTemplate($solution)) ?>
+
+ <? if (!empty($exercise->options['comment']) && $solution->student_comment != '') : ?>
+ <div class="label-text">
+ <?= _('Bemerkungen zur Lösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= htmlReady($solution->student_comment, true, true) ?>
+ </div>
+ <? endif ?>
+</fieldset>
diff --git a/app/views/vips/exercises/courseware_block.php b/app/views/vips/exercises/courseware_block.php
new file mode 100644
index 0000000..4259f90
--- /dev/null
+++ b/app/views/vips/exercises/courseware_block.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * @var int $tries_left
+ * @var bool $show_solution
+ * @var Exercise $exercise
+ * @var float|int $max_points
+ * @var VipsSolution $solution
+ * @var bool $sample_solution
+ * @var VipsAssignment $assignment
+ * @var string $user_id
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<? if ($tries_left > 0 && !$show_solution): ?>
+ <?= MessageBox::warning(sprintf(ngettext(
+ 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weiteren Versuch.',
+ 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weitere Versuche.', $tries_left), $tries_left)) ?>
+<? endif ?>
+
+<h4 class="exercise">
+ <?= htmlReady($exercise->title) ?>
+
+ <div class="points">
+ <? if ($max_points == (int) $max_points): ?>
+ <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?>
+ <? else: ?>
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ <? endif ?>
+ </div>
+</h4>
+
+<div class="description">
+ <?= formatReady($exercise->description) ?>
+</div>
+
+<?= $this->render_partial('vips/exercises/show_exercise_hint') ?>
+<?= $this->render_partial('vips/exercises/show_exercise_files') ?>
+
+<? if ($show_solution): ?>
+ <?= $this->render_partial($exercise->getCorrectionTemplate($solution), ['show_solution' => $sample_solution]) ?>
+
+ <? if ($exercise->options['comment'] && $solution->student_comment != ''): ?>
+ <div class="label-text">
+ <?= _('Bemerkungen zur Lösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= htmlReady($solution->student_comment, true, true) ?>
+ </div>
+ <? endif ?>
+
+ <header>
+ <?= _('Bewertung') ?>
+ </header>
+
+ <? if ($solution->feedback != ''): ?>
+ <div class="label-text">
+ <?= _('Anmerkungen zur Lösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= formatReady($solution->feedback) ?>
+ </div>
+ <? endif ?>
+
+ <div class="description">
+ <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?>
+ </div>
+<? else: ?>
+ <?= $this->render_partial($exercise->getSolveTemplate($solution, $assignment, $user_id)) ?>
+
+ <? if (!empty($exercise->options['comment'])): ?>
+ <label>
+ <?= _('Bemerkungen zur Lösung (optional)') ?>
+ <textarea name="student_comment"><?= htmlReady($solution->student_comment) ?></textarea>
+ </label>
+ <? endif ?>
+<? endif ?>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/exercises/evaluation_mode_info.php b/app/views/vips/exercises/evaluation_mode_info.php
new file mode 100644
index 0000000..d39d01e
--- /dev/null
+++ b/app/views/vips/exercises/evaluation_mode_info.php
@@ -0,0 +1,22 @@
+<?php
+/**
+ * @var bool $evaluation_mode
+ * @var Exercise $exercise
+ * @var bool $show_solution
+ */
+?>
+<? if ($evaluation_mode && $exercise->itemCount() > 1): ?>
+ <div class="description smaller">
+ <? if ($evaluation_mode == VipsAssignment::SCORING_NEGATIVE_POINTS) : ?>
+ <?= _('Vorsicht: Falsche Antworten geben Punktabzug!') ?>
+ <? elseif ($evaluation_mode == VipsAssignment::SCORING_ALL_OR_NOTHING) : ?>
+ <?= _('Vorsicht: Falsche Antworten führen zur Bewertung der Aufgabe mit 0 Punkten.') ?>
+ <? endif ?>
+ </div>
+<? endif ?>
+
+<? if ($show_solution): ?>
+ <div class="description smaller">
+ <?= sprintf(_('Richtige Antworten %shervorgehoben%s.'), '<span class="correct_item">', '</span>') ?>
+ </div>
+<? endif ?>
diff --git a/app/views/vips/exercises/flexible_input.php b/app/views/vips/exercises/flexible_input.php
new file mode 100644
index 0000000..f2e8940
--- /dev/null
+++ b/app/views/vips/exercises/flexible_input.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * @var string $size
+ */
+?>
+<div class="flexible_input">
+ <input type="text" class="character_input small_input size-l"
+ <? if ($size === 'small'): ?>
+ <?= isset($name) ? 'name="'.$name.'"' : (isset($data_name) ? 'data-name="'.$data_name.'"' : '') ?>
+ <? endif ?>
+ <? if (isset($value)): ?>
+ value="<?= htmlReady($value) ?>"
+ <? endif ?>
+ >
+ <div class="large_input">
+ <? $wysiwyg = isset($data_name) ? 'wysiwyg-hidden' : 'wysiwyg' ?>
+ <textarea class="character_input <?= $wysiwyg ?> size-l" data-editor="removePlugins=studip-quote,studip-settings;toolbar=small"
+ <? if ($size === 'large'): ?>
+ <?= isset($name) ? 'name="'.$name.'"' : (isset($data_name) ? 'data-name="'.$data_name.'"' : '') ?>
+ <? endif ?>
+ ><?= wysiwygReady($value ?? '') ?></textarea>
+ </div>
+</div>
+<?= Icon::create('arr_1down')->asInput(['class' => 'textarea_toggle small_input', 'title' => _('Auf mehrzeilige Eingabe umschalten')]) ?>
+<?= Icon::create('arr_1up')->asInput(['class' => 'textarea_toggle large_input', 'title' => _('Auf einzeilige Eingabe umschalten')]) ?>
diff --git a/app/views/vips/exercises/flexible_textarea.php b/app/views/vips/exercises/flexible_textarea.php
new file mode 100644
index 0000000..64db62f
--- /dev/null
+++ b/app/views/vips/exercises/flexible_textarea.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * @var Exercise $exercise
+ * @var bool $wysiwyg
+ * @var bool $monospace
+ * @var string $name
+ * @var string $value
+ */
+?>
+<div class="size_toggle <?= $wysiwyg ? 'size_large' : 'size_small' ?>">
+ <textarea class="character_input size-l small_input <?= $monospace ? 'monospace' : '' ?>" <?= $wysiwyg ? '' : 'name="'.$name.'"' ?>
+ rows="<?= $exercise->textareaSize($value) ?>"><?= htmlReady($value) ?></textarea>
+ <div class="large_input">
+ <textarea class="character_input size-l wysiwyg" <?= $wysiwyg ? 'name="'.$name.'"' : '' ?>><?= wysiwygReady($value) ?></textarea>
+ </div>
+ <button hidden class="textarea_toggle"></button>
+</div>
diff --git a/app/views/vips/exercises/print_exercise.php b/app/views/vips/exercises/print_exercise.php
new file mode 100644
index 0000000..a3da7ba
--- /dev/null
+++ b/app/views/vips/exercises/print_exercise.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * @var int $exercise_position
+ * @var Exercise $exercise
+ * @var float|int $max_points
+ * @var VipsSolution $solution
+ * @var VipsAssignment $assignment
+ * @var string $user_id
+ * @var bool $print_correction
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<div class="exercise">
+ <h3>
+ <?= $exercise_position ?>.
+ <?= htmlReady($exercise->title) ?>
+
+ <div class="points">
+ <? if ($max_points == (int) $max_points): ?>
+ <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?>
+ <? else: ?>
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ <? endif ?>
+ </div>
+ </h3>
+
+ <div class="description">
+ <?= formatReady($exercise->description) ?>
+ </div>
+
+ <?= $this->render_partial('vips/exercises/show_exercise_hint') ?>
+ <?= $this->render_partial('vips/exercises/show_exercise_files') ?>
+
+ <?= $this->render_partial($exercise->getPrintTemplate($solution, $assignment, $user_id)) ?>
+
+ <? if ($solution && $solution->student_comment != '') : ?>
+ <div class="label-text">
+ <?= _('Bemerkungen zur Lösung:') ?>
+ </div>
+
+ <?= htmlReady($solution->student_comment, true, true) ?>
+ <? endif ?>
+
+ <? if ($print_correction): ?>
+ <? if ($solution): ?>
+ <? if ($solution->feedback != ''): ?>
+ <div class="label-text">
+ <?= _('Anmerkung des Korrektors:') ?>
+ </div>
+
+ <?= formatReady($solution->feedback) ?>
+ <? endif ?>
+
+ <?= $this->render_partial('vips/solutions/feedback_files') ?>
+ <? endif ?>
+
+ <div class="label-text">
+ <?= sprintf(_('Erreichte Punkte: %g / %g'), $solution->points, $max_points) ?>
+ </div>
+ <? endif ?>
+</div>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/exercises/show_exercise_files.php b/app/views/vips/exercises/show_exercise_files.php
new file mode 100644
index 0000000..822fcee
--- /dev/null
+++ b/app/views/vips/exercises/show_exercise_files.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * @var Exercise $exercise
+ */
+?>
+<? if ($exercise->folder && count($exercise->folder->file_refs) > 0 && !$exercise->options['files_hidden']): ?>
+ <div class="label-text">
+ <?= _('Dateien zur Aufgabe:') ?>
+ </div>
+
+ <ul>
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <li>
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>" <?= $file_ref->getContentDisposition() === 'inline' ? 'target="_blank"' : '' ?>>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </li>
+ <? endforeach ?>
+ </ul>
+<? endif ?>
diff --git a/app/views/vips/exercises/show_exercise_hint.php b/app/views/vips/exercises/show_exercise_hint.php
new file mode 100644
index 0000000..8d68647
--- /dev/null
+++ b/app/views/vips/exercises/show_exercise_hint.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * @var Exercise $exercise
+ */
+?>
+<? if (isset($exercise->options['hint']) && $exercise->options['hint'] !== ''): ?>
+ <div class="exercise_hint inline-content">
+ <h4><?= _('Hinweis:') ?></h4>
+ <?= formatReady($exercise->options['hint']) ?>
+ </div>
+ <br>
+<? endif ?>
diff --git a/app/views/vips/pool/assignments.php b/app/views/vips/pool/assignments.php
new file mode 100644
index 0000000..795e2f6
--- /dev/null
+++ b/app/views/vips/pool/assignments.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * @var int $count
+ * @var array $search_filter
+ */
+?>
+<? if ($count == 0 && empty(array_filter($search_filter))): ?>
+ <div class="vips-teaser">
+ <header><?= _('Aufgaben und Prüfungen') ?></header>
+ <p>
+ <?= _('Mit diesem Werkzeug können Übungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' .
+ 'Die Lehrenden erhalten eine Übersicht darüber, welche Teilnehmenden eine Übung oder einen ' .
+ 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Übungszetteln ' .
+ 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' .
+ 'erhalten sofort einen Überblick darüber, was noch zu korrigieren ist. Neben allgemein ' .
+ 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' .
+ 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.') ?>
+ </p>
+ <?= Studip\LinkButton::create(_('Aufgabenblatt erstellen'), $controller->url_for('vips/sheets/edit_assignment')) ?>
+ </div>
+<? elseif ($count): ?>
+ <?= $this->render_partial('vips/pool/list_assignments') ?>
+<? else: ?>
+ <?= MessageBox::info(_('Mit den aktuellen Sucheinstellungen sind keine Aufgabenblätter mit Zugriffsberechtigung vorhanden.')) ?>
+<? endif ?>
diff --git a/app/views/vips/pool/copy_exercises_dialog.php b/app/views/vips/pool/copy_exercises_dialog.php
new file mode 100644
index 0000000..810be11
--- /dev/null
+++ b/app/views/vips/pool/copy_exercises_dialog.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * @var Vips_PoolController $controller
+ * @var int[] $exercise_ids
+ * @var Course[] $courses
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/pool/copy_exercises') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($exercise_ids as $exercise_id => $assignment_id): ?>
+ <input type="hidden" name="exercise_ids[<?= htmlReady($exercise_id) ?>]" value="<?= htmlReady($assignment_id) ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Aufgabenblatt auswählen') ?>
+
+ <select name="assignment_id" class="vips_nested_select">
+ <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= htmlReady($assignment->id) ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+
+ <? foreach ($courses as $course): ?>
+ <? $assignments = VipsAssignment::findByRangeId($course->id) ?>
+ <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= htmlReady($assignment->id) ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/pool/exercises.php b/app/views/vips/pool/exercises.php
new file mode 100644
index 0000000..de11e7a
--- /dev/null
+++ b/app/views/vips/pool/exercises.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * @var int $count
+ * @var array $search_filter
+ */
+?>
+<? if ($count == 0 && empty(array_filter($search_filter))): ?>
+ <?= MessageBox::info(_('Es wurden noch keine Aufgabenblätter eingerichtet.'), [
+ _('Auf dieser Seite finden Sie eine Übersicht über alle Aufgaben, auf die Sie Zugriff haben.')
+ ]) ?>
+<? elseif ($count): ?>
+ <?= $this->render_partial('vips/pool/list_exercises') ?>
+<? else: ?>
+ <?= MessageBox::info(_('Mit den aktuellen Sucheinstellungen sind keine Aufgaben mit Zugriffsberechtigung vorhanden.')) ?>
+<? endif ?>
diff --git a/app/views/vips/pool/list_assignments.php b/app/views/vips/pool/list_assignments.php
new file mode 100644
index 0000000..4caf303
--- /dev/null
+++ b/app/views/vips/pool/list_assignments.php
@@ -0,0 +1,174 @@
+<?php
+/**
+ * @var Vips_PoolController $controller
+ * @var string $sort
+ * @var bool $desc
+ * @var int $page
+ * @var array $search_filter
+ * @var int $count
+ * @var VipsAssignment[] $assignments
+ */
+?>
+<form action="" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="sort" value="<?= htmlReady($sort) ?>">
+ <input type="hidden" name="desc" value="<?= $desc ?>">
+ <input type="hidden" name="page" value="<?= $page ?>">
+ <input type="hidden" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>">
+ <input type="hidden" name="search_filter[assignment_type]" value="<?= htmlReady($search_filter['assignment_type']) ?>">
+
+ <table class="default">
+ <caption>
+ <?= _('Aufgabenblätter') ?>
+ <div class="actions">
+ <?= sprintf(ngettext('%d Aufgabenblatt', '%d Aufgabenblätter', $count), $count) ?>
+ </div>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 20px;">
+ <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>">
+ </th>
+
+ <th style="width: 35%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'Nachname', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'Nachname', 'desc' => $sort === 'Nachname' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Autor/-in') ?>
+ </a>
+ </th>
+
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'mkdate', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'mkdate', 'desc' => $sort === 'mkdate' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Datum') ?>
+ </a>
+ </th>
+
+ <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'Name', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'Name', 'desc' => $sort === 'Name' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Veranstaltung') ?>
+ </a>
+ </th>
+
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/assignments', ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Semester') ?>
+ </a>
+ </th>
+
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignments as $assignment): ?>
+ <? $assignment_obj = VipsAssignment::buildExisting($assignment) ?>
+ <? $course_id = $assignment['range_type'] === 'course' ? $assignment['range_id'] : null ?>
+ <tr>
+ <td>
+ <input class="batch_select" type="checkbox" name="assignment_ids[]" value="<?= htmlReady($assignment['id']) ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]) ?>">
+ <?= $assignment_obj->getTypeIcon() ?>
+ <?= htmlReady($assignment['test_title']) ?>
+ </a>
+ </td>
+
+ <td>
+ <? if (isset($assignment['Nachname']) || isset($assignment['Vorname'])): ?>
+ <?= htmlReady($assignment['Nachname'] . ', ' . $assignment['Vorname']) ?>
+ <? endif ?>
+ </td>
+
+ <td>
+ <?= date('d.m.Y, H:i', $assignment['mkdate']) ?>
+ </td>
+
+ <td>
+ <? if ($course_id): ?>
+ <a href="<?= URLHelper::getLink('seminar_main.php', ['cid' => $course_id]) ?>">
+ <?= htmlReady($assignment['Name']) ?>
+ </a>
+ <? endif ?>
+ </td>
+
+ <td>
+ <? if ($course_id && $assignment['start_time']): ?>
+ <?= htmlReady(Semester::findByTimestamp($assignment['start_time'])->name) ?>
+ <? endif ?>
+ </td>
+
+ <td class="actions">
+ <? $menu = ActionMenu::get(); ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/show_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]),
+ _('Studierendensicht anzeigen'),
+ Icon::create('community')
+ ) ?>
+
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment['id']]),
+ _('Aufgabenblatt drucken'),
+ Icon::create('print'),
+ ['target' => '_blank']
+ ) ?>
+
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/copy_assignments_dialog', ['assignment_ids[]' => $assignment['id']]),
+ _('Aufgabenblatt kopieren'),
+ Icon::create('copy'),
+ ['data-dialog' => 'size=auto']
+ ) ?>
+
+ <? if ($assignment_obj->isLocked()): ?>
+ <? $menu->addButton('reset', _('Alle Lösungen zurücksetzen'), Icon::create('refresh'), [
+ 'formaction' => $controller->url_for('vips/sheets/reset_assignment', ['assignment_id' => $assignment['id']]),
+ 'data-confirm' => _('Achtung: Wenn Sie die Lösungen zurücksetzen, werden die Lösungen aller Teilnehmenden archiviert!')
+ ]) ?>
+ <? else: ?>
+ <? $menu->addButton('delete', _('Aufgabenblatt löschen'), Icon::create('trash'), [
+ 'formaction' => $controller->url_for('vips/sheets/delete_assignments', ['assignment_ids[]' => $assignment['id']]),
+ 'data-confirm' => sprintf(_('Wollen Sie wirklich das Aufgabenblatt „%s“ löschen?'), $assignment['test_title'])
+ ]) ?>
+ <? endif ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="4">
+ <?= Studip\Button::create(_('Kopieren'), 'copy_selected', [
+ 'class' => 'batch_action',
+ 'data-dialog' => 'size=auto',
+ 'formaction' => $controller->url_for('vips/sheets/copy_assignments_dialog')
+ ]) ?>
+ <?= Studip\Button::create(_('Verschieben'), 'move_selected', [
+ 'class' => 'batch_action',
+ 'data-dialog' => 'size=auto',
+ 'formaction' => $controller->url_for('vips/sheets/move_assignments_dialog')
+ ]) ?>
+ <?= Studip\Button::create(_('Löschen'), 'delete_selected', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/sheets/delete_assignments'),
+ 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgabenblätter löschen?')
+ ]) ?>
+ </td>
+ <td colspan="3" class="actions">
+ <?= $controller->page_chooser($controller->url_for('vips/pool/assignments', ['page' => '%d', 'sort' => $sort, 'desc' => $desc, 'search_filter' => $search_filter]), $count, $page) ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+</form>
diff --git a/app/views/vips/pool/list_exercises.php b/app/views/vips/pool/list_exercises.php
new file mode 100644
index 0000000..83544f0
--- /dev/null
+++ b/app/views/vips/pool/list_exercises.php
@@ -0,0 +1,151 @@
+<?php
+/**
+ * @var Vips_PoolController $controller
+ * @var string $sort
+ * @var bool $desc
+ * @var int $page
+ * @var array $search_filter
+ * @var int $count
+ * @var Exercise[] $exercises
+ */
+?>
+<form action="" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="sort" value="<?= $sort ?>">
+ <input type="hidden" name="desc" value="<?= $desc ?>">
+ <input type="hidden" name="page" value="<?= $page ?>">
+ <input type="hidden" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>">
+ <input type="hidden" name="search_filter[exercise_type]" value="<?= htmlReady($search_filter['exercise_type']) ?>">
+
+ <table class="default">
+ <caption>
+ <?= _('Aufgaben') ?>
+ <div class="actions">
+ <?= sprintf(ngettext('%d Aufgabe', '%d Aufgaben', $count), $count) ?>
+ </div>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 20px;">
+ <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgaben auswählen') ?>">
+ </th>
+
+ <th style="width: 35%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Aufgabentyp') ?>
+ </a>
+ </th>
+
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'Nachname', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'Nachname', 'desc' => $sort === 'Nachname' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Autor/-in') ?>
+ </a>
+ </th>
+
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'mkdate', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'mkdate', 'desc' => $sort === 'mkdate' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Datum') ?>
+ </a>
+ </th>
+
+ <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/pool/exercises', ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc, 'search_filter' => $search_filter]) ?>">
+ <?= _('Aufgabenblatt') ?>
+ </a>
+ </th>
+
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($exercises as $exercise): ?>
+ <? $course_id = $exercise['range_type'] === 'course' ? $exercise['range_id'] : null ?>
+ <tr>
+ <td>
+ <input class="batch_select" type="checkbox" name="exercise_ids[<?= $exercise['id'] ?>]" value="<?= $exercise['assignment_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]) ?>">
+ <?= htmlReady($exercise['title']) ?>
+ </a>
+ </td>
+
+ <td>
+ <? if (isset($exercise_types[$exercise['type']])): ?>
+ <?= htmlReady($exercise_types[$exercise['type']]['name']) ?>
+ <? endif ?>
+ </td>
+
+ <td>
+ <? if (isset($exercise['Nachname']) || isset($exercise['Vorname'])): ?>
+ <?= htmlReady($exercise['Nachname'] . ', ' . $exercise['Vorname']) ?>
+ <? endif ?>
+ </td>
+
+ <td>
+ <?= date('d.m.Y, H:i', $exercise['mkdate']) ?>
+ </td>
+
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id']]) ?>">
+ <?= htmlReady($exercise['test_title']) ?>
+ </a>
+ </td>
+
+ <td class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <? $menu->addLink($controller->url_for('vips/sheets/show_exercise', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]),
+ _('Studierendensicht anzeigen'), Icon::create('community')
+ ) ?>
+
+ <? $menu->addLink($controller->url_for('vips/pool/copy_exercises_dialog', ["exercise_ids[{$exercise['id']}]" => $exercise['assignment_id']]),
+ _('Aufgabe kopieren'), Icon::create('copy'), ['data-dialog' => 'size=auto']
+ ) ?>
+
+ <? $menu->addButton('delete', _('Aufgabe löschen'), Icon::create('trash'), [
+ 'formaction' => $controller->url_for('vips/pool/delete_exercises', ["exercise_ids[{$exercise['id']}]" => $exercise['assignment_id']]),
+ 'data-confirm' => sprintf(_('Wollen Sie wirklich die Aufgabe „%s“ löschen?'), $exercise['title'])
+ ]) ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="4">
+ <?= Studip\Button::create(_('Kopieren'), 'copy_selected', [
+ 'class' => 'batch_action',
+ 'data-dialog' => 'size=auto',
+ 'formaction' => $controller->url_for('vips/pool/copy_exercises_dialog')
+ ]) ?>
+ <?= Studip\Button::create(_('Verschieben'), 'move_selected', [
+ 'class' => 'batch_action',
+ 'data-dialog' => 'size=auto',
+ 'formaction' => $controller->url_for('vips/pool/move_exercises_dialog')
+ ]) ?>
+ <?= Studip\Button::create(_('Löschen'), 'delete_selected', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/pool/delete_exercises'),
+ 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgaben löschen?')
+ ]) ?>
+ </td>
+ <td colspan="3" class="actions">
+ <?= $controller->page_chooser($controller->url_for('vips/pool/exercises', ['page' => '%d', 'sort' => $sort, 'desc' => $desc, 'search_filter' => $search_filter]), $count, $page) ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+</form>
diff --git a/app/views/vips/pool/move_exercises_dialog.php b/app/views/vips/pool/move_exercises_dialog.php
new file mode 100644
index 0000000..09e7ac3
--- /dev/null
+++ b/app/views/vips/pool/move_exercises_dialog.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * @var Vips_PoolController $controller
+ * @var int[] $exercise_ids
+ * @var Course[] $courses
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/pool/move_exercises') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($exercise_ids as $exercise_id => $assignment_id): ?>
+ <input type="hidden" name="exercise_ids[<?= $exercise_id ?>]" value="<?= $assignment_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Aufgabenblatt auswählen') ?>
+
+ <select name="assignment_id" class="vips_nested_select">
+ <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+
+ <? foreach ($courses as $course): ?>
+ <? $assignments = VipsAssignment::findByRangeId($course->id) ?>
+ <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/add_exercise_dialog.php b/app/views/vips/sheets/add_exercise_dialog.php
new file mode 100644
index 0000000..f13a08d
--- /dev/null
+++ b/app/views/vips/sheets/add_exercise_dialog.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var array<class-string<Exercise>, array> $exercise_types
+ */
+?>
+<form class="default" action="<?= $controller->edit_exercise() ?>" method="POST">
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+
+ <fieldset>
+ <legend>
+ <?= _('Aufgabentyp auswählen') ?>
+ </legend>
+
+ <div class="exercise_types">
+ <? foreach ($exercise_types as $type => $entry): ?>
+ <button class="exercise_type" name="exercise_type" value="<?= htmlReady($type) ?>"
+ style="<?= $type::getTypeIcon()->asCSS(40) ?>">
+ <b><?= htmlReady($entry['name']) ?></b><br>
+ <?= htmlReady($type::getTypeDescription()) ?>
+ </button>
+ <? endforeach ?>
+ </div>
+ </fieldset>
+</form>
diff --git a/app/views/vips/sheets/assign_block_dialog.php b/app/views/vips/sheets/assign_block_dialog.php
new file mode 100644
index 0000000..ba75f96
--- /dev/null
+++ b/app/views/vips/sheets/assign_block_dialog.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int[] $assignment_ids
+ * @var VipsBlock[] $blocks
+ */
+?>
+<form class="default" action="<?= $controller->assign_block() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($assignment_ids as $assignment_id): ?>
+ <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Block auswählen') ?>
+
+ <select name="block_id">
+ <option value="0">
+ <?= _('Keinem Block zuweisen') ?>
+ </option>
+ <? foreach ($blocks as $block): ?>
+ <option value="<?= $block->id ?>">
+ <?= htmlReady($block->name) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Zuweisen'), 'assign_block') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/assignment_type_tooltip.php b/app/views/vips/sheets/assignment_type_tooltip.php
new file mode 100644
index 0000000..a6bf99e
--- /dev/null
+++ b/app/views/vips/sheets/assignment_type_tooltip.php
@@ -0,0 +1,18 @@
+<dl style="margin-top: 0;">
+ <dt><?= _('Übung') ?></dt>
+ <dd>
+ <?= _('Hausaufgabe, freie Bearbeitung im festgelegten Zeitraum, auch Gruppenarbeit möglich') ?>
+ </dd>
+ <dt><?= _('Selbsttest') ?></dt>
+ <dd>
+ <?= _('Kontrolle des Lernfortschritts, Feedback nach der Abgabe einer Lösung, automatische Korrektur') ?>
+ </dd>
+ <dt><?= _('Klausur') ?></dt>
+ <dd>
+ <?= _('Online-Klausur mit individueller Bearbeitungszeit, konfigurierbare Zugangsbeschränkungen') ?>
+ </dd>
+</dl>
+
+<a href="<?= format_help_url(PageLayout::getHelpKeyword()) ?>" target="_blank">
+ <?= _('Weitere Informationen in der Hilfe') ?>
+</a>
diff --git a/app/views/vips/sheets/content_bar_icons.php b/app/views/vips/sheets/content_bar_icons.php
new file mode 100644
index 0000000..5d6268a
--- /dev/null
+++ b/app/views/vips/sheets/content_bar_icons.php
@@ -0,0 +1,19 @@
+<? if (isset($prev_exercise_url)): ?>
+ <a href="<?= htmlReady($prev_exercise_url) ?>">
+ <?= Icon::create('arr_1left')->asImg(24, ['title' => _('Vorige Aufgabe')]) ?>
+ </a>
+<? else: ?>
+ <span>
+ <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(24) ?>
+ </span>
+<? endif ?>
+
+<? if (isset($next_exercise_url)): ?>
+ <a href="<?= htmlReady($next_exercise_url) ?>">
+ <?= Icon::create('arr_1right')->asImg(24, ['title' => _('Nächste Aufgabe')]) ?>
+ </a>
+<? else: ?>
+ <span>
+ <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(24) ?>
+ </span>
+<? endif ?>
diff --git a/app/views/vips/sheets/copy_assignment_dialog.php b/app/views/vips/sheets/copy_assignment_dialog.php
new file mode 100644
index 0000000..49eecb9
--- /dev/null
+++ b/app/views/vips/sheets/copy_assignment_dialog.php
@@ -0,0 +1,104 @@
+<form class="default" action="<?= $controller->link_for('vips/sheets/copy_assignment') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="sort" value="<?= $sort ?>">
+ <input type="hidden" name="desc" value="<?= $desc ?>">
+
+ <input type="text" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>" aria-label="<?= _('Suchbegriff eingeben') ?>"
+ placeholder="<?= _('Aufgabenblatt oder Veranstaltung') ?>" style="max-width: 24em;">
+
+ <select name="search_filter[assignment_type]" class="inline_select" aria-label="<?= _('Modus auswählen') ?>">
+ <option value="">
+ <?= _('Beliebiger Modus') ?>
+ </option>
+ <? foreach ($assignment_types as $type => $entry): ?>
+ <option value="<?= $type ?>" <?= $search_filter['assignment_type'] == $type ? 'selected' : '' ?>>
+ <?= htmlReady($entry['name']) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+
+ <select name="search_filter[range_type]" class="inline_select" aria-label="<?= _('Quelle auswählen') ?>" style="margin-left: 1em;">
+ <option value="user" <?= $search_filter['range_type'] == 'user' ? 'selected' : '' ?>>
+ <?= _('Persönliche Aufgabensammlung') ?>
+ </option>
+ <option value="course" <?= $search_filter['range_type'] == 'course' ? 'selected' : '' ?>>
+ <?= _('Aufgaben in Veranstaltungen') ?>
+ </option>
+ </select>
+
+ <span style="margin-left: 1em;">
+ <?= Studip\Button::create(_('Suchen'), 'start_search', ['data-dialog' => 'size=1200x800', 'formaction' => $controller->url_for('vips/sheets/copy_assignment_dialog')]) ?>
+ <?= Studip\Button::create(_('Zurücksetzen'), 'reset_search', ['data-dialog' => 'size=1200x800', 'formaction' => $controller->url_for('vips/sheets/copy_assignment_dialog')]) ?>
+ </div>
+
+ <? if ($count): ?>
+ <table class="default">
+ <thead>
+ <tr class="sortable">
+ <th style="width: 45%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>">
+ <input type="checkbox" data-proxyfor=".batch_select_d" data-activates=".batch_action_d" aria-label="<?= _('Alle Aufgaben auswählen') ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog',
+ compact('search_filter') + ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc]) ?>" data-dialog="size=1200x800">
+ <?= _('Aufgabenblatt') ?>
+ </a>
+ </th>
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'course_name', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog',
+ compact('search_filter') + ['sort' => 'course_name', 'desc' => $sort === 'course_name' && !$desc]) ?>" data-dialog="size=1200x800">
+ <?= _('Veranstaltung') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_assignment_dialog',
+ compact('search_filter') + ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc]) ?>" data-dialog="size=1200x800">
+ <?= _('Semester') ?>
+ </a>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignments as $assignment): ?>
+ <? $course_id = $assignment['range_type'] === 'course' ? $assignment['range_id'] : null ?>
+ <tr>
+ <td>
+ <label class="undecorated">
+ <input class="batch_select_d" type="checkbox" name="assignment_ids[]" value="<?= $assignment['id'] ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ <?= htmlReady($assignment['test_title']) ?>
+
+ <a href="<?= $controller->link_for('vips/sheets/show_assignment', ['cid' => $course_id, 'assignment_id' => $assignment['id']]) ?>" target="_blank">
+ <?= Icon::create('link-intern')->asImg(['title' => _('Vorschau anzeigen')]) ?>
+ </a>
+ </label>
+ </td>
+ <td>
+ <? if ($course_id): ?>
+ <?= htmlReady($assignment['course_name']) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <? if ($course_id && $assignment['start_time']): ?>
+ <?= htmlReady(Semester::findByTimestamp($assignment['start_time'])->name) ?>
+ <? endif ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="3" class="actions">
+ <?= $controller->page_chooser($controller->url_for('vips/sheets/copy_assignment_dialog', ['page' => '%d'] + compact('search_filter', 'sort', 'desc')),
+ $count, $page, 'data-dialog="size=1200x800"', $size) ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ <? else: ?>
+ <?= MessageBox::info(_('Es wurden keine Aufgabenblätter gefunden.')) ?>
+ <? endif ?>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Kopieren'), 'copy_assignment', ['class' => 'batch_action_d']) ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/copy_assignments_dialog.php b/app/views/vips/sheets/copy_assignments_dialog.php
new file mode 100644
index 0000000..2c37661
--- /dev/null
+++ b/app/views/vips/sheets/copy_assignments_dialog.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int[] $assignment_ids
+ * @var Course[] $courses
+ * @var string $course_id
+ */
+?>
+<form class="default" action="<?= $controller->copy_assignments() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($assignment_ids as $assignment_id): ?>
+ <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Ziel auswählen') ?>
+
+ <select name="course_id" class="vips_nested_select">
+ <option value="">
+ <?= _('Persönliche Aufgabensammlung') ?>
+ </option>
+
+ <? foreach ($courses as $course): ?>
+ <option value="<?= $course->id ?>" <?= $course->id == $course_id ? 'selected' : '' ?>>
+ <?= htmlReady($course->name) ?> (<?= htmlReady($course->start_semester->name) ?>)
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/copy_exercise_dialog.php b/app/views/vips/sheets/copy_exercise_dialog.php
new file mode 100644
index 0000000..7eeec26
--- /dev/null
+++ b/app/views/vips/sheets/copy_exercise_dialog.php
@@ -0,0 +1,132 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var array $search_filter
+ * @var array $exercise_types
+ * @var string $sort
+ * @var bool $desc
+ * @var int $count
+ * @var Exercise[] $exercises
+ * @var int $page
+ * @var int $size
+ *
+ */
+?>
+<form class="default" action="<?= $controller->copy_exercise() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <input type="hidden" name="sort" value="<?= htmlReady($sort) ?>">
+ <input type="hidden" name="desc" value="<?= htmlReady($desc) ?>">
+
+ <input type="text" name="search_filter[search_string]" value="<?= htmlReady($search_filter['search_string']) ?>" aria-label="<?= _('Suchbegriff eingeben') ?>"
+ placeholder="<?= _('Titel der Aufgabe oder Veranstaltung') ?>" style="max-width: 24em;">
+
+ <select name="search_filter[exercise_type]" class="inline_select" aria-label="<?= _('Aufgabentyp auswählen') ?>">
+ <option value="">
+ <?= _('Alle Aufgabentypen') ?>
+ </option>
+ <? foreach ($exercise_types as $type => $entry): ?>
+ <option value="<?= $type ?>" <?= $search_filter['exercise_type'] == $type ? 'selected' : '' ?>>
+ <?= htmlReady($entry['name']) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+
+ <select name="search_filter[range_type]" class="inline_select" aria-label="<?= _('Quelle auswählen') ?>" style="margin-left: 1em;">
+ <option value="user" <?= $search_filter['range_type'] == 'user' ? 'selected' : '' ?>>
+ <?= _('Persönliche Aufgabensammlung') ?>
+ </option>
+ <option value="course" <?= $search_filter['range_type'] == 'course' ? 'selected' : '' ?>>
+ <?= _('Aufgaben in Veranstaltungen') ?>
+ </option>
+ </select>
+
+ <span style="margin-left: 1em;">
+ <?= Studip\Button::create(_('Suchen'), 'start_search', ['data-dialog' => 'size=big', 'formaction' => $controller->url_for('vips/sheets/copy_exercise_dialog')]) ?>
+ <?= Studip\Button::create(_('Zurücksetzen'), 'reset_search', ['data-dialog' => 'size=big', 'formaction' => $controller->url_for('vips/sheets/copy_exercise_dialog')]) ?>
+ </span>
+
+ <? if ($count): ?>
+ <table class="default">
+ <thead>
+ <tr class="sortable">
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <input type="checkbox" data-proxyfor=".batch_select_d" data-activates=".batch_action_d" aria-label="<?= _('Alle Aufgaben auswählen') ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog',
+ compact('assignment_id', 'search_filter') + ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>" data-dialog="size=big">
+ <?= _('Titel der Aufgabe') ?>
+ </a>
+ </th>
+ <th style="width: 25%;" class="<?= $controller->sort_class($sort === 'test_title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog',
+ compact('assignment_id', 'search_filter') + ['sort' => 'test_title', 'desc' => $sort === 'test_title' && !$desc]) ?>" data-dialog="size=big">
+ <?= _('Aufgabenblatt') ?>
+ </a>
+ </th>
+ <th style="width: 25%;" class="<?= $controller->sort_class($sort === 'course_name', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog',
+ compact('assignment_id', 'search_filter') + ['sort' => 'course_name', 'desc' => $sort === 'course_name' && !$desc]) ?>" data-dialog="size=big">
+ <?= _('Veranstaltung') ?>
+ </a>
+ </th>
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'start_time', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets/copy_exercise_dialog',
+ compact('assignment_id', 'search_filter') + ['sort' => 'start_time', 'desc' => $sort === 'start_time' && !$desc]) ?>" data-dialog="size=big">
+ <?= _('Semester') ?>
+ </a>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($exercises as $exercise): ?>
+ <? $course_id = $exercise['range_type'] === 'course' ? $exercise['range_id'] : null ?>
+ <tr>
+ <td>
+ <label class="undecorated">
+ <input class="batch_select_d" type="checkbox" name="exercise_ids[<?= $exercise['id'] ?>]" value="<?= $exercise['assignment_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ <?= htmlReady($exercise['title']) ?>
+
+ <a href="<?= $controller->link_for('vips/sheets/preview_exercise', ['assignment_id' => $exercise['assignment_id'], 'exercise_id' => $exercise['id']]) ?>"
+ data-dialog="id=vips_preview;size=800x600" target="_blank">
+ <?= Icon::create('question-circle')->asImg(['title' => _('Vorschau anzeigen')]) ?>
+ </a>
+ </label>
+ </td>
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['cid' => $course_id, 'assignment_id' => $exercise['assignment_id']]) ?>">
+ <?= htmlReady($exercise['test_title']) ?>
+ </a>
+ </td>
+ <td>
+ <? if ($course_id): ?>
+ <?= htmlReady($exercise['course_name']) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <? if ($course_id && $exercise['start_time']): ?>
+ <?= htmlReady(Semester::findByTimestamp($exercise['start_time'])->name) ?>
+ <? endif ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="4" class="actions">
+ <?= $controller->page_chooser($controller->url_for('vips/sheets/copy_exercise_dialog', ['page' => '%d'] + compact('assignment_id', 'search_filter', 'sort', 'desc')),
+ $count, $page, 'data-dialog="size=big"', $size) ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ <? else: ?>
+ <?= MessageBox::info(_('Es wurden keine Aufgaben gefunden.')) ?>
+ <? endif ?>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Kopieren'), 'copy_exercise', ['class' => 'batch_action_d']) ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/copy_exercises_dialog.php b/app/views/vips/sheets/copy_exercises_dialog.php
new file mode 100644
index 0000000..17f6678
--- /dev/null
+++ b/app/views/vips/sheets/copy_exercises_dialog.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var int[] $exercise_ids
+ * @var Course[] $courses
+ */
+?>
+<form class="default" action="<?= $controller->copy_exercises() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <? foreach ($exercise_ids as $exercise_id): ?>
+ <input type="hidden" name="exercise_ids[]" value="<?= $exercise_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Aufgabenblatt auswählen') ?>
+
+ <select name="target_assignment_id" class="vips_nested_select">
+ <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+
+ <? foreach ($courses as $course): ?>
+ <? $assignments = VipsAssignment::findByRangeId($course->id) ?>
+ <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Kopieren'), 'copy') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/edit_assignment.php b/app/views/vips/sheets/edit_assignment.php
new file mode 100644
index 0000000..1d2c66e
--- /dev/null
+++ b/app/views/vips/sheets/edit_assignment.php
@@ -0,0 +1,329 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var VipsAssignment $assignment
+ * @var VipsTest $test
+ * @var array $assignment_types
+ * @var VipsBlock[] $blocks
+ * @var array $exam_rooms
+ * @var bool $locked
+ */
+?>
+
+<?= $contentbar->render() ?>
+
+<form class="default width-1200" action="<?= $controller->store_assignment() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <button hidden name="store"></button>
+
+ <fieldset id="assignment" class="<?= htmlReady($assignment->type) ?>">
+ <legend>
+ <?= _('Grunddaten') ?>
+ </legend>
+
+ <? if ($this->locked): ?>
+ <?= MessageBox::info(_('Die Klausur kann nur eingeschränkt bearbeitet werden, da bereits Lösungen abgegeben wurden.')) ?>
+ <? endif ?>
+
+ <label>
+ <span class="required"><?= _('Titel') ?></span>
+ <input type="text" name="assignment_name" class="character_input size-l" value="<?= htmlReady($test->title) ?>" data-secure required>
+ </label>
+
+ <label>
+ <?= _('Beschreibung') ?>
+ <textarea name="assignment_description" class="character_input size-l wysiwyg" data-secure><?= wysiwygReady($test->description) ?></textarea>
+ </label>
+
+ <fieldset class="undecorated">
+ <legend>
+ <?= _('Bearbeitungsmodus') ?>
+ <?= tooltipIcon($this->render_partial('vips/sheets/assignment_type_tooltip'), false, true) ?>
+ </legend>
+
+ <? foreach ($assignment_types as $type => $entry) : ?>
+ <label class="undecorated">
+ <input type="radio" class="assignment_type" name="assignment_type" value="<?= $type ?>" <?= $assignment->type == $type ? 'checked' : '' ?> data-secure>
+ <?= htmlReady($entry['name']) ?>
+ </label>
+ <? endforeach ?>
+ </fieldset>
+
+ <label class="formpart undecorated" id="start_date">
+ <div class="label-text">
+ <span class="required"><?= _('Startzeitpunkt') ?></span>
+ </div>
+
+ <input type="text" name="start_date" class="has-date-picker size-s" value="<?= date('d.m.Y', $assignment->start) ?>" data-secure required>
+ <input type="text" name="start_time" class="has-time-picker size-s" value="<?= date('H:i', $assignment->start) ?>" data-secure required>
+ </label>
+
+ <? $required = $assignment->type !== 'selftest' ? 'required' : '' ?>
+
+ <label class="formpart undecorated" id="end_date">
+ <div class="label-text">
+ <span class="<?= $required ?>"><?= _('Endzeitpunkt') ?></span>
+ </div>
+
+ <input type="text" name="end_date" class="has-date-picker size-s" value="<?= $assignment->isUnlimited() ? '' : date('d.m.Y', $assignment->end) ?>" data-secure <?= $required ?>>
+ <input type="text" name="end_time" class="has-time-picker size-s" value="<?= $assignment->isUnlimited() ? '' : date('H:i', $assignment->end) ?>" data-secure <?= $required ?>>
+ </label>
+
+ <? $disabled = $assignment->type !== 'exam' ? 'disabled' : '' ?>
+
+ <label id="exam_length" class="practice-hidden selftest-hidden">
+ <span class="required"><?= _('Dauer in Minuten') ?></span>
+ <input type="number" name="exam_length" min="0" max="99999" value="<?= htmlReady($assignment->options['duration']) ?>" <?= $disabled ?> data-secure required>
+ </label>
+
+ <section>
+ <input id="options-toggle" class="options-toggle" type="checkbox" value="on" <?= $assignment_id ? '' : 'checked' ?>>
+ <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="<?= $assignment_id ? 'false' : 'true' ?>">
+ <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?>
+ <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?>
+ <?= _('Weitere Einstellungen') ?>
+ </a>
+
+ <div class="toggle-box" id="options-panel">
+ <? if ($assignment->range_type === 'course'): ?>
+ <label class="formpart undecorated">
+ <div class="label-text">
+ <?= _('Block') ?>
+ </div>
+
+ <select name="assignment_block" style="max-width: 22.7em;" data-secure>
+ <option value="0">
+ <?= _('Keinem Block zuweisen') ?>
+ </option>
+ <? foreach ($blocks as $block): ?>
+ <option value="<?= $block->id ?>" <?= $assignment->block_id == $block->id ? 'selected' : '' ?>>
+ <?= htmlReady($block->name) ?>
+ </option>
+ <? endforeach ?>
+ </select>
+ <?= _('oder') ?>
+ <input type="text" name="assignment_block_name" style="max-width: 22.7em;" placeholder="<?= _('Neuen Block anlegen') ?>" data-secure>
+ </label>
+ <? endif ?>
+
+ <label class="exam-hidden selftest-hidden">
+ <input type="checkbox" name="use_groups" value="1" <?= $assignment->options['use_groups'] !== 0 ? 'checked' : '' ?> data-secure>
+ <?= _('Aufgaben können in Übungsgruppen bearbeitet werden') ?>
+ <?= tooltipIcon(_('Hat keine Auswirkungen, wenn keine Übungsgruppen angelegt wurden.')) ?>
+ </label>
+
+ <label class="practice-hidden selftest-hidden">
+ <input type="checkbox" name="self_assessment" value="1" <?= $assignment->options['self_assessment'] ? 'checked' : '' ?> data-secure>
+ <?= _('Testklausur zur Selbsteinschätzung der Teilnehmenden') ?>
+ <?= tooltipIcon(_('Teilnehmende können beliebig oft neu starten, Ergebnisse können direkt nach Ablauf der Bearbeitungszeit zugänglich gemacht werden.')) ?>
+ </label>
+
+ <label class="practice-hidden selftest-hidden">
+ <input type="checkbox" name="shuffle_exercises" value="1" <?= $assignment->options['shuffle_exercises'] ? 'checked' : '' ?> data-secure>
+ <?= _('Zufällige Reihenfolge der Aufgaben bei Anzeige der Klausur') ?>
+ </label>
+
+ <label class="practice-hidden selftest-hidden">
+ <input type="checkbox" name="shuffle_answers" value="1" <?= $assignment->options['shuffle_answers'] !== 0 ? 'checked' : '' ?> data-secure>
+ <?= _('Zufällige Reihenfolge der Antworten in Multiple- und Single-Choice-Aufgaben') ?>
+ </label>
+
+ <label class="exam-hidden practice-hidden">
+ <input type="checkbox" name="resets" value="1" <?= $assignment->options['resets'] !== 0 ? 'checked' : '' ?> data-secure>
+ <?= _('Teilnehmende dürfen ihre Lösungen zurücksetzen und den Test neu starten') ?>
+ </label>
+
+ <label class="exam-hidden practice-hidden">
+ <input type="checkbox" value="1" <?= $assignment->options['max_tries'] !== 0 ? 'checked' : '' ?> data-activates=".max_tries" data-secure>
+ <?= _('Anzeige der Musterlösung nach eingesteller Anzahl von Fehlversuchen') ?>
+ </label>
+
+ <label class="exam-hidden practice-hidden">
+ <?= _('Anzahl der Lösungsversuche pro Aufgabe') ?>
+ <input type="number" name="max_tries" class="max_tries" min="1" value="<?= $assignment->options['max_tries'] ?: 3 ?>" data-secure>
+ </label>
+
+ <label>
+ <?= _('Falsche Antworten in Multiple- und Single-Choice-Aufgaben') ?>
+
+ <select name="evaluation_mode" data-secure>
+ <option value="0">
+ <?= _('&hellip; geben keinen Punktabzug') ?>
+ </option>
+ <option value="1" <?= $assignment->options['evaluation_mode'] == VipsAssignment::SCORING_NEGATIVE_POINTS ? 'selected' : '' ?>>
+ <?= _('… geben Punktabzug (Gesamtpunktzahl Aufgabe mind. 0)') ?>
+ </option>
+ <option value="2" <?= $assignment->options['evaluation_mode'] == VipsAssignment::SCORING_ALL_OR_NOTHING ? 'selected' : '' ?>>
+ <?= _('… führen zur Bewertung der Aufgabe mit 0 Punkten') ?>
+ </option>
+ </select>
+ </label>
+
+ <label>
+ <?= _('Notizen (für Teilnehmende unsichtbar)') ?>
+ <textarea name="assignment_notes" class="character_input" data-secure><?= htmlReady($assignment->options['notes']) ?></textarea>
+ </label>
+
+ <label class="practice-hidden selftest-hidden">
+ <?= _('Zugangscode zur Klausur (optional)') ?>
+ <input type="text" name="access_code" value="<?= htmlReady($assignment->options['access_code']) ?>" data-secure>
+ </label>
+
+ <label class="practice-hidden selftest-hidden">
+ <?= _('Zugriff auf Prüfungsräume oder IP-Bereiche beschränken (optional)') ?>
+ <?= tooltipIcon($this->render_partial('vips/sheets/ip_range_tooltip'), false, true) ?>
+ <input type="text" name="ip_range" class="validate_ip_range" value="<?= htmlReady($assignment->options['ip_range']) ?>" data-secure>
+ </label>
+
+ <? if ($exam_rooms): ?>
+ <div class="practice-hidden selftest-hidden smaller">
+ <?= _('Raum hinzufügen:') ?>
+ <? foreach (array_keys($exam_rooms) as $room_name): ?>
+ <a href="#" class="add_ip_range" data-value="#<?= htmlReady($room_name) ?>">
+ <?= htmlReady($room_name) ?>
+ </a>
+ <? endforeach ?>
+ </div>
+ <? endif?>
+ </div>
+
+ <div class="practice-hidden exam-hidden">
+ <input id="feedback-toggle" class="options-toggle" type="checkbox" value="on">
+ <a class="caption" href="#" role="button" data-toggles="#feedback-toggle" aria-controls="feedback-panel" aria-expanded="false">
+ <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?>
+ <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?>
+ <?= _('Automatisches Feedback') ?>
+ </a>
+
+ <div class="toggle-box" id="feedback-panel">
+ <table class="default description fixed">
+ <thead>
+ <tr>
+ <th style="width: 16%;">
+ <?= _('Erforderliche Punkte') ?>
+ </th>
+ <th style="width: 76%;">
+ <?= _('Kommentar zur Bewertung') ?>
+ </th>
+ <th class="actions" style="width: 8%;">
+ <?= _('Löschen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list" style="vertical-align: top;">
+ <? if (isset($assignment->options['feedback'])): ?>
+ <? foreach ($assignment->options['feedback'] as $threshold => $feedback): ?>
+ <tr class="dynamic_row">
+ <td>
+ <input type="number" name="threshold[]" min="0" max="100" value="<?= htmlReady($threshold) ?>" data-secure> %
+ </td>
+ <td>
+ <textarea name="feedback[]" class="character_input size-l wysiwyg" data-secure><?= wysiwygReady($feedback) ?></textarea>
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ <? endif ?>
+
+ <tr class="dynamic_row template">
+ <td>
+ <input type="number" name="threshold[]" min="0" max="100" data-secure> %
+ </td>
+ <td>
+ <textarea name="feedback[]" class="character_input size-l wysiwyg-hidden" data-secure></textarea>
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Eintrag löschen')]) ?>
+ </td>
+ </tr>
+
+ <tr>
+ <th colspan="3">
+ <?= Studip\Button::create(_('Eintrag hinzufügen'), 'add_feedback', ['class' => 'add_dynamic_row']) ?>
+ </th>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+
+ </fieldset>
+
+ <table class="default" id="exercises">
+ <? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+ <? if (count($test->exercise_refs)): ?>
+ <thead>
+ <tr>
+ <th style="padding-left: 2ex;">
+ <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgaben auswählen') ?>">
+ </th>
+ <th></th>
+ <th style="width: 60%;">
+ <?= _('Aufgaben') ?>
+ </th>
+ <th style="width: 22%;">
+ <?= _('Aufgabentyp') ?>
+ </th>
+ <th style="width: 5em;">
+ <span class="required"><?= _('Punkte') ?></span>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody id="list" class="dynamic_list" data-assignment="<?= $assignment_id ?>" role="list">
+ <?= $this->render_partial('vips/sheets/list_exercises') ?>
+ </tbody>
+ <? endif ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="4">
+ <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+ <? if ($assignment_id && !$locked): ?>
+ <?= Studip\LinkButton::create(_('Neue Aufgabe erstellen'),
+ $controller->url_for('vips/sheets/add_exercise_dialog', compact('assignment_id')),
+ ['data-dialog' => 'size=auto']) ?>
+ <? endif ?>
+ <? if (count($test->exercise_refs)): ?>
+ <?= Studip\Button::create(_('Kopieren'), 'copy_exercises', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/sheets/copy_exercises_dialog'),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ <? if (!$locked): ?>
+ <?= Studip\Button::create(_('Verschieben'), 'move_exercises', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/sheets/move_exercises_dialog'),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ <?= Studip\Button::create(_('Löschen'), 'delete_exercises', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/sheets/delete_exercises'),
+ 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgaben löschen?')
+ ]) ?>
+ <? endif ?>
+ <? endif ?>
+ </td>
+ <td colspan="2" style="padding-left: 0;">
+ <? if (count($test->exercise_refs) > 0): ?>
+ <div class="points">
+ <?= sprintf('%g', $test->getTotalPoints()) ?>
+ </div>
+ <? endif ?>
+ </td>
+ </tr>
+ </tfoot>
+
+ <? setlocale(LC_NUMERIC, 'C') ?>
+ </table>
+</form>
diff --git a/app/views/vips/sheets/edit_exercise.php b/app/views/vips/sheets/edit_exercise.php
new file mode 100644
index 0000000..156d789
--- /dev/null
+++ b/app/views/vips/sheets/edit_exercise.php
@@ -0,0 +1,161 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var VipsAssignment $assignment
+ * @var Exercise $exercise
+ * @var int $exercise_position
+ * @var int $max_points
+ */
+?>
+
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<?= $contentbar->render() ?>
+
+<form class="default width-1200" action="<?= $controller->store_exercise() ?>" data-secure method="POST" enctype="multipart/form-data">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="exercise_type" value="<?= htmlReady($exercise->type) ?>">
+ <? if ($exercise->id) : ?>
+ <input type="hidden" name="exercise_id" value="<?= $exercise->id ?>">
+ <? endif ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <button hidden name="store_exercise"></button>
+
+ <fieldset>
+ <legend>
+ <? if ($exercise->id): ?>
+ <?= $exercise_position ?>.
+ <? endif ?>
+ <?= htmlReady($exercise->getTypeName()) ?>
+ <? if ($exercise->id): ?>
+ <div style="float: right;">
+ <? if ($max_points == (int) $max_points): ?>
+ <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?>
+ <? else: ?>
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ <? endif ?>
+ </div>
+ <? endif ?>
+ </legend>
+
+ <label>
+ <span class="required"><?= _('Titel') ?></span>
+ <input type="text" name="exercise_name" class="character_input size-l" value="<?= htmlReady($exercise->title) ?>" required>
+ </label>
+
+ <label>
+ <?= _('Aufgabentext') ?>
+ <textarea name="exercise_question" class="character_input size-l wysiwyg" rows="<?= $exercise->textareaSize($exercise->description) ?>"><?= wysiwygReady($exercise->description) ?></textarea>
+ </label>
+
+ <table class="default">
+ <? if ($exercise->folder && count($exercise->folder->file_refs)): ?>
+ <thead>
+ <tr>
+ <th style="width: 60%;">
+ <?= _('Dateien zur Aufgabe') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Vorschau') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Größe') ?>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Datum') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list">
+ <? foreach ($exercise->folder->file_refs as $file_ref): ?>
+ <tr class="dynamic_row">
+ <td>
+ <input type="hidden" name="file_ids[]" value="<?= $file_ref->id ?>">
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>" <?= $file_ref->getContentDisposition() === 'inline' ? 'target="_blank"' : '' ?>>
+ <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </td>
+ <td>
+ <? if ($file_ref->isImage()): ?>
+ <img alt="<?= htmlReady($file_ref->name) ?>" src="<?= htmlReady($file_ref->getDownloadURL()) ?>"
+ style="max-height: 20px; vertical-align: bottom;">
+ <? endif ?>
+ </td>
+ <td>
+ <?= sprintf('%.1f KB', $file_ref->file->size / 1024) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?>
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ <? endif ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="5">
+ <?= Studip\Button::create(_('Dateien zur Aufgabe hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?>
+ <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span>
+ <?= tooltipIcon(sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576)) ?>
+ <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+
+ <? if ($exercise->folder && count($exercise->folder->file_refs)): ?>
+ <label>
+ <input type="checkbox" name="files_visible" value="1" <?= !$exercise->options['files_hidden'] ? 'checked' : '' ?>>
+ <?= _('Liste der Dateien unter dem Aufgabentext anzeigen') ?>
+ </label>
+ <? endif ?>
+
+ <?= $this->render_partial($exercise->getEditTemplate($assignment)) ?>
+
+ <input id="options-toggle" class="options-toggle" type="checkbox" value="on">
+ <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="false">
+ <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?>
+ <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?>
+ <?= _('Weitere Einstellungen') ?>
+ </a>
+
+ <div class="toggle-box" id="options-panel">
+ <label>
+ <?= _('Hinweise zur Bearbeitung der Aufgabe') ?>
+ <textarea name="exercise_hint" class="character_input size-l wysiwyg"><?= wysiwygReady($exercise->options['hint']) ?></textarea>
+ </label>
+
+ <label>
+ <? if ($assignment->type === 'selftest') : ?>
+ <?= _('Automatisches Feedback bei falscher Antwort') ?>
+ <? else : ?>
+ <?= _('Vorlage für den Bewertungskommentar (manuelle Korrektur)') ?>
+ <? endif ?>
+ <textarea name="feedback" class="character_input size-l wysiwyg"><?= wysiwygReady($exercise->options['feedback']) ?></textarea>
+ </label>
+
+ <? if ($assignment->type !== 'selftest') : ?>
+ <label>
+ <input type="checkbox" name="exercise_comment" value="1" <?= $exercise->options['comment'] ? 'checked' : '' ?>>
+ <?= _('Eingabe eines Kommentars durch Studierende erlauben') ?>
+ </label>
+ <? endif ?>
+ </div>
+ </fieldset>
+
+ <footer>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store_exercise') ?>
+ </footer>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/sheets/export_assignment.php b/app/views/vips/sheets/export_assignment.php
new file mode 100644
index 0000000..1a24f3c
--- /dev/null
+++ b/app/views/vips/sheets/export_assignment.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * @var VipsAssignment $assignment
+ * @var array $files
+ */
+?><?= '<?xml version="1.0" encoding="UTF-8"?>' ?>
+
+<test xmlns="urn:vips:test:v1.0" id="test-<?= $assignment->id ?>" type="<?= $assignment->type ?>"
+ start="<?= date('c', $assignment->start) ?>"
+ <? if (!$assignment->isUnlimited()): ?>
+ end="<?= date('c', $assignment->end) ?>"
+ <? endif ?>
+ <? if ($assignment->type === 'exam' && $assignment->options['duration']): ?>
+ duration="<?= $assignment->options['duration'] ?>"
+ <? endif ?>
+ <? if ($assignment->block_id): ?>
+ block="<?= htmlReady($assignment->block->name) ?>"
+ <? endif ?>
+ >
+ <title>
+ <?= htmlReady($assignment->test->title) ?>
+ </title>
+ <description>
+ <?= htmlReady($assignment->test->description) ?>
+ </description>
+ <? if ($assignment->options['notes'] != ''): ?>
+ <notes>
+ <?= htmlReady($assignment->options['notes']) ?>
+ </notes>
+ <? endif ?>
+ <limit
+ <? if (isset($assignment->options['access_code'])): ?>
+ access-code="<?= htmlReady($assignment->options['access_code']) ?>"
+ <? endif ?>
+ <? if (isset($assignment->options['ip_range'])): ?>
+ ip-ranges="<?= htmlReady($assignment->options['ip_range']) ?>"
+ <? endif ?>
+ <? if ($assignment->options['resets'] === 0): ?>
+ resets="0"
+ <? endif ?>
+ <? if (isset($assignment->options['max_tries'])): ?>
+ tries="<?= $assignment->options['max_tries'] ?>"
+ <? endif ?>
+ />
+ <option
+ <? if ($assignment->options['evaluation_mode'] == VipsAssignment::SCORING_NEGATIVE_POINTS): ?>
+ scoring-mode="negative_points"
+ <? elseif ($assignment->options['evaluation_mode'] == VipsAssignment::SCORING_ALL_OR_NOTHING): ?>
+ scoring-mode="all_or_nothing"
+ <? endif ?>
+ <? if ($assignment->isShuffled()): ?>
+ shuffle-answers="true"
+ <? endif ?>
+ <? if ($assignment->isExerciseShuffled()): ?>
+ shuffle-exercises="true"
+ <? endif ?>
+ >
+ </option>
+ <? if (isset($assignment->options['feedback'])): ?>
+ <feedback-items>
+ <? foreach ($assignment->options['feedback'] as $threshold => $feedback): ?>
+ <feedback score="<?= (float) $threshold / 100 ?>">
+ <?= htmlReady($feedback) ?>
+ </feedback>
+ <? endforeach ?>
+ </feedback-items>
+ <? endif ?>
+ <exercises>
+ <? foreach ($assignment->test->exercise_refs as $exercise_ref): ?>
+ <?= $this->render_partial($exercise_ref->exercise->getXMLTemplate($assignment), ['points' => $exercise_ref->points]) ?>
+ <? endforeach ?>
+ </exercises>
+ <? if ($files): ?>
+ <files>
+ <? foreach ($files as $file): ?>
+ <file id="file-<?= $file->id ?>" name="<?= htmlReady($file->name) ?>">
+ <?= base64_encode(file_get_contents($file->getPath())) ?>
+ </file>
+ <? endforeach ?>
+ </files>
+ <? endif ?>
+</test>
diff --git a/app/views/vips/sheets/import_assignment_dialog.php b/app/views/vips/sheets/import_assignment_dialog.php
new file mode 100644
index 0000000..f8a478b
--- /dev/null
+++ b/app/views/vips/sheets/import_assignment_dialog.php
@@ -0,0 +1,21 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/sheets/import_test') ?>" method="POST" enctype="multipart/form-data">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <h4>
+ <?= _('Aufgabenblätter aus Datei(en) importieren') ?>
+ </h4>
+
+ <label>
+ <?= _('Datei(en):') ?>
+ <input type="file" name="upload[]" multiple style="min-width: 40em;">
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Importieren'), 'import') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/ip_range_tooltip.php b/app/views/vips/sheets/ip_range_tooltip.php
new file mode 100644
index 0000000..24a407f
--- /dev/null
+++ b/app/views/vips/sheets/ip_range_tooltip.php
@@ -0,0 +1,26 @@
+<?= _('Beispiele:') ?>
+
+<dl>
+ <dt>131.173.73.42</dt>
+ <dd>
+ <?= _('Gibt nur diese IP-Adresse frei.') ?>
+ </dd>
+ <dt>131.173.73 <?= _('oder') ?> 131.173.73.0/24</dt>
+ <dd>
+ <?= _('Gibt alle IP-Adressen frei, die so beginnen.') ?>
+ </dd>
+ <dt>131.173.73-131.173.75</dt>
+ <dd>
+ <?= _('Gibt alle IP-Adressen aus dem Bereich 131.173.73 bis 131.173.75 frei.') ?>
+ </dd>
+ <? if (!empty($exam_rooms)): ?>
+ <dt>#94/E01</dt>
+ <dd>
+ <?= _('Gibt alle IP-Adressen in diesem Raum frei.') ?>
+ </dd>
+ <? endif?>
+</dl>
+
+<span class="smaller">
+ <?= _('Außerdem können Listen aller genannten Fälle eingetragen werden (durch Komma oder Leerzeichen getrennt).') ?>
+</span>
diff --git a/app/views/vips/sheets/list_assignments.php b/app/views/vips/sheets/list_assignments.php
new file mode 100644
index 0000000..29e85b3
--- /dev/null
+++ b/app/views/vips/sheets/list_assignments.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * @var int $num_assignments
+ * @var array $assignment_data
+ */
+?>
+<? if ($num_assignments == 0): ?>
+ <div class="vips-teaser">
+ <header><?= _('Aufgaben und Prüfungen') ?></header>
+ <p>
+ <?= _('Mit diesem Werkzeug können Übungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' .
+ 'Die Lehrenden erhalten eine Übersicht darüber, welche Teilnehmenden eine Übung oder einen ' .
+ 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Übungszetteln ' .
+ 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' .
+ 'erhalten sofort einen Überblick darüber, was noch zu korrigieren ist. Neben allgemein ' .
+ 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' .
+ 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.') ?>
+ </p>
+ <?= Studip\LinkButton::create(_('Aufgabenblatt erstellen'), $controller->url_for('vips/sheets/edit_assignment')) ?>
+ </div>
+<? endif ?>
+
+<? foreach ($assignment_data as $i => $assignment_list): ?>
+ <? if (count($assignment_list['assignments']) > 0 || isset($assignment_list['block']->id)): ?>
+ <?= $this->render_partial('vips/sheets/list_assignments_list', ['i' => $i] + $assignment_list) ?>
+ <? endif ?>
+<? endforeach ?>
diff --git a/app/views/vips/sheets/list_assignments_list.php b/app/views/vips/sheets/list_assignments_list.php
new file mode 100644
index 0000000..bc3fc1e
--- /dev/null
+++ b/app/views/vips/sheets/list_assignments_list.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var VipsBlock $block
+ * @var string $title
+ * @var string $sort
+ * @var bool $desc
+ * @var int $i
+ * @var VipsGroup $group
+ * @var VipsAssignment[] $assignments
+ * @var VipsBlock[] $blocks
+ */
+?>
+<form action="" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <table class="default">
+ <caption>
+ <?= htmlReady($title) ?>
+
+ <? if (isset($block->id)): ?>
+ <? if (!$block->visible): ?>
+ <?= _('(für Teilnehmende unsichtbar)') ?>
+ <? elseif ($block->group_id): ?>
+ <?= sprintf(_('(sichtbar für Gruppe „%s“)'), htmlReady($block->group->name)) ?>
+ <? else: ?>
+ <?= _('(für alle sichtbar)') ?>
+ <? endif ?>
+
+ <div class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/admin/edit_block', ['block_id' => $block->id]),
+ _('Block bearbeiten'),
+ Icon::create('edit'),
+ ['data-dialog' => 'size=auto']
+ ) ?>
+ <? $menu->addButton(
+ 'delete',
+ _('Block löschen'),
+ Icon::create('trash'),
+ [
+ 'formaction' => $controller->url_for('vips/admin/delete_block', ['block_id' => $block->id]),
+ 'data-confirm' => sprintf(_('Wollen Sie wirklich den Block „%s“ löschen?'), $title)
+ ]
+ ) ?>
+ <?= $menu->render() ?>
+ </div>
+ <? endif ?>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 20px;">
+ <input type="checkbox" data-proxyfor=".batch_select_<?= $i ?>" data-activates=".batch_action_<?= $i ?>" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>">
+ </th>
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>">
+ <?= _('Start') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>">
+ <?= _('Ende') ?>
+ </a>
+ </th>
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc]) ?>">
+ <?= _('Modus') ?>
+ </a>
+ </th>
+ <th style="width: 10%;">
+ <? if ($group == 1): ?>
+ <?= _('Status') ?>
+ <? else: ?>
+ <?= _('Block') ?>
+ <? endif ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignments as $assignment) : ?>
+ <tr>
+ <? $halted = $assignment->isRunning() && !$assignment->active ?>
+ <? $style = $halted ? 'color: red;' : '' ?>
+ <td>
+ <input class="batch_select_<?= $i ?>" type="checkbox" name="assignment_ids[]" value="<?= $assignment->id ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+ <td style="<?= $style ?>">
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment->id]) ?>">
+ <?= $assignment->getTypeIcon() ?>
+ <?= htmlReady($assignment->test->title) ?>
+ </a>
+ <? if ($halted): ?>
+ (<?= _('unterbrochen') ?>)
+ <? endif ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $assignment->start) ?>
+ </td>
+ <td>
+ <? if (!$assignment->isUnlimited()) : ?>
+ <?= date('d.m.Y, H:i', $assignment->end) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <?= htmlReady($assignment->getTypeName()) ?>
+ </td>
+ <td>
+ <? if ($group == 1): ?>
+ <? if ($assignment->isFinished()): ?>
+ <?= _('beendet') ?>
+ <? elseif ($assignment->isRunning()): ?>
+ <?= _('aktiv') ?>
+ <? endif ?>
+ <? elseif ($assignment->block_id): ?>
+ <?= htmlReady($assignment->block->name) ?>
+ <? endif ?>
+ </td>
+ <td class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <? if ($assignment->isRunning()): ?>
+ <? if (!$assignment->active): ?>
+ <? $menu->addButton('go', _('Bearbeitung fortsetzen'), Icon::create('play'), [
+ 'formaction' => $controller->url_for('vips/sheets/stopgo_assignment', ['assignment_id' => $assignment->id])
+ ]) ?>
+ <? else : ?>
+ <? $menu->addButton('stop', _('Bearbeitung anhalten'), Icon::create('pause'), [
+ 'formaction' => $controller->url_for('vips/sheets/stopgo_assignment', ['assignment_id' => $assignment->id])
+ ]) ?>
+ <? endif ?>
+ <? elseif (!$assignment->isFinished()) : ?>
+ <? $menu->addLink($controller->url_for('vips/sheets/start_assignment_dialog', ['assignment_id' => $assignment->id]),
+ _('Aufgabenblatt starten'), Icon::create('play'), ['data-dialog' => 'size=auto']
+ ) ?>
+ <? endif ?>
+
+ <? $menu->addLink($controller->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment->id]),
+ _('Studierendensicht anzeigen'), Icon::create('community')
+ ) ?>
+ <? $menu->addLink($controller->url_for('vips/solutions/assignment_solutions', ['assignment_id' => $assignment->id]),
+ _('Aufgaben korrigieren'), Icon::create('accept')
+ ) ?>
+ <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment->id]),
+ _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank']
+ ) ?>
+ <? $menu->addButton('copy', _('Aufgabenblatt duplizieren'), Icon::create('copy'), [
+ 'formaction' => $controller->url_for('vips/sheets/copy_assignment', ['assignment_id' => $assignment->id])
+ ]) ?>
+ <? if ($assignment->isLocked()): ?>
+ <? $menu->addButton('reset', _('Alle Lösungen zurücksetzen'), Icon::create('refresh'), [
+ 'formaction' => $controller->url_for('vips/sheets/reset_assignment', ['assignment_id' => $assignment->id]),
+ 'data-confirm' => _('Achtung: Wenn Sie die Lösungen zurücksetzen, werden die Lösungen aller Teilnehmenden archiviert!')
+ ]) ?>
+ <? else: ?>
+ <? $menu->addButton('delete', _('Aufgabenblatt löschen'), Icon::create('trash'), [
+ 'formaction' => $controller->url_for('vips/sheets/delete_assignment', ['assignment_id' => $assignment->id]),
+ 'data-confirm' => sprintf(_('Wollen Sie wirklich das Aufgabenblatt „%s“ löschen?'), $assignment->test->title)
+ ]) ?>
+ <? endif ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <? if (count($assignments)): ?>
+ <tfoot>
+ <tr>
+ <td colspan="7">
+ <? if (count($blocks) > 1): ?>
+ <?= Studip\Button::create(_('Block zuweisen'), 'assign_block', [
+ 'class' => 'batch_action_' . $i,
+ 'formaction' => $controller->url_for('vips/sheets/assign_block_dialog'),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ <? endif ?>
+ <?= Studip\Button::create(_('Kopieren'), 'copy_assignments', [
+ 'class' => 'batch_action_' . $i,
+ 'formaction' => $controller->url_for('vips/sheets/copy_assignments_dialog'),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ <?= Studip\Button::create(_('Verschieben'), 'move_assignments', [
+ 'class' => 'batch_action_' . $i,
+ 'formaction' => $controller->url_for('vips/sheets/move_assignments_dialog'),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ <?= Studip\Button::create(_('Löschen'), 'delete_assignments', [
+ 'class' => 'batch_action_' . $i,
+ 'formaction' => $controller->url_for('vips/sheets/delete_assignments'),
+ 'data-confirm' => _('Wollen Sie wirklich die ausgewählten Aufgabenblätter löschen?')
+ ]) ?>
+ </td>
+ </tr>
+ </tfoot>
+ <? endif ?>
+ </table>
+</form>
diff --git a/app/views/vips/sheets/list_assignments_stud.php b/app/views/vips/sheets/list_assignments_stud.php
new file mode 100644
index 0000000..6477557
--- /dev/null
+++ b/app/views/vips/sheets/list_assignments_stud.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * @var VipsBlock[] $blocks
+ * @var Vips_SheetsController $controller
+ * @var string $sort
+ * @var bool $desc
+ * @var string $user_id
+ */
+?>
+
+<? if (count($blocks) == 0): ?>
+ <?= MessageBox::info(_('Es gibt aktuell keine laufenden Aufgabenblätter.')) ?>
+<? endif ?>
+
+<? foreach ($blocks as $block_id => $block): ?>
+ <table class="default">
+ <caption>
+ <? if (count($blocks) > 1 || $block_id): ?>
+ <?= htmlReady($block['title']) ?>
+ <? else: ?>
+ <?= _('Laufende Aufgabenblätter') ?>
+ <? endif ?>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>">
+ <?= _('Start') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>">
+ <?= _('Ende') ?>
+ </a>
+ </th>
+ <th style="width: 10%;" class="<?= $controller->sort_class($sort === 'type', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/sheets', ['sort' => 'type', 'desc' => $sort === 'type' && !$desc]) ?>">
+ <?= _('Modus') ?>
+ </a>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Status') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktion') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($block['assignments'] as $assignment) : ?>
+ <tr>
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/show_assignment', ['assignment_id' => $assignment->id]) ?>">
+ <?= $assignment->getTypeIcon() ?>
+ <?= htmlReady($assignment->test->title) ?>
+ </a>
+ <? if (!$assignment->active): ?>
+ <span style="color: red;">
+ (<?= _('unterbrochen') ?>)
+ </span>
+ <? endif ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $assignment->start) ?>
+ </td>
+ <td>
+ <? if (!$assignment->isUnlimited()) : ?>
+ <?= date('d.m.Y, H:i', $assignment->end) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <?= htmlReady($assignment->getTypeName()) ?>
+ </td>
+ <td>
+ <? if ($assignment->type === 'exam'): ?>
+ <? $assignment_attempt = $assignment->getAssignmentAttempt($user_id) ?>
+ <? if ($assignment_attempt === null): ?>
+ &ndash;
+ <? elseif ($assignment_attempt->end < time()): ?>
+ <?= _('beendet') ?>
+ <? else: ?>
+ <?= _('angefangen') ?>
+ <? endif ?>
+ <? elseif ($assignment->isFinished($user_id)): ?>
+ <?= _('beendet') ?>
+ <? else: ?>
+ <? $num_solutions = $assignment->countSolutions($user_id) ?>
+ <? if ($num_solutions == 0): ?>
+ &ndash;
+ <? elseif ($num_solutions == count($assignment->test->exercise_refs)): ?>
+ <?= _('bearbeitet') ?>
+ <? else: ?>
+ <?= _('angefangen') ?>
+ <? endif ?>
+ <? endif ?>
+ </td>
+ <td class="actions">
+ <? if ($assignment->active && $assignment->type !== 'exam'): ?>
+ <? $menu = ActionMenu::get() ?>
+ <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment->id]),
+ _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank']
+ ) ?>
+ <?= $menu->render() ?>
+ <? endif ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ </table>
+<? endforeach ?>
diff --git a/app/views/vips/sheets/list_exercises.php b/app/views/vips/sheets/list_exercises.php
new file mode 100644
index 0000000..4591163
--- /dev/null
+++ b/app/views/vips/sheets/list_exercises.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var VipsTest $test
+ * @var Exercise[] $exercises
+ * @var int $assignment_id
+ * @var bool $locked
+ */
+?>
+<? foreach ($test->exercise_refs as $i => $exercise_ref): ?>
+ <? $exercise = $exercises[$i] ?>
+
+ <tr id="item_<?= $exercise->id ?>" role="listitem" tabindex="0">
+ <td class="drag-handle">
+ <input type="checkbox" class="batch_select" name="exercise_ids[]" value="<?= $exercise->id ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+ <td class="dynamic_counter" style="text-align: right;">
+ <!-- position -->
+ </td>
+ <td>
+ <!-- exercise title -->
+ <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) ?>">
+ <?= htmlReady($exercise->title) ?>
+ </a>
+ </td>
+ <td>
+ <!-- exercise type -->
+ <?= htmlReady($exercise->getTypeName()) ?>
+ </td>
+ <td>
+ <!-- max points -->
+ <input name="exercise_points[<?= $exercise->id ?>]" type="text" class="points" value="<?= sprintf('%g', $exercise_ref->points) ?>" data-secure required>
+ </td>
+
+ <td class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <!-- display button -->
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]),
+ _('Studierendensicht anzeigen'),
+ Icon::create('community')
+ ) ?>
+
+ <? if (!$locked): ?>
+ <!-- copy button -->
+ <? $menu->addButton(
+ 'copy',
+ _('Aufgabe duplizieren'),
+ Icon::create('copy'),
+ ['formaction' => $controller->url_for('vips/sheets/copy_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id])]
+ ) ?>
+
+ <!-- delete button -->
+ <? $menu->addButton(
+ 'delete',
+ _('Aufgabe löschen'),
+ Icon::create('trash'),
+ [
+ 'formaction' => $controller->url_for('vips/sheets/delete_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]),
+ 'data-confirm' => sprintf(_('Wollen Sie wirklich die Aufgabe „%s“ löschen?'), $exercise->title)
+ ]
+ ) ?>
+ <? endif ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+<? endforeach ?>
diff --git a/app/views/vips/sheets/move_assignments_dialog.php b/app/views/vips/sheets/move_assignments_dialog.php
new file mode 100644
index 0000000..c022fc1
--- /dev/null
+++ b/app/views/vips/sheets/move_assignments_dialog.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int[] $assignment_ids
+ * @var Course[] $courses
+ * @var string $course_id
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/sheets/move_assignments') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($assignment_ids as $assignment_id): ?>
+ <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Ziel auswählen') ?>
+
+ <select name="course_id" class="vips_nested_select">
+ <option value="">
+ <?= _('Persönliche Aufgabensammlung') ?>
+ </option>
+
+ <? foreach ($courses as $course): ?>
+ <option value="<?= $course->id ?>" <?= $course->id == $course_id ? 'selected' : '' ?>>
+ <?= htmlReady($course->name) ?> (<?= htmlReady($course->start_semester->name) ?>)
+ </option>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/move_exercises_dialog.php b/app/views/vips/sheets/move_exercises_dialog.php
new file mode 100644
index 0000000..4901564
--- /dev/null
+++ b/app/views/vips/sheets/move_exercises_dialog.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var int[] $exercise_ids
+ * @var Course[] $courses
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/sheets/move_exercises') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <? foreach ($exercise_ids as $exercise_id): ?>
+ <input type="hidden" name="exercise_ids[]" value="<?= $exercise_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <?= _('Aufgabenblatt auswählen') ?>
+
+ <select name="target_assignment_id" class="vips_nested_select">
+ <? $assignments = VipsAssignment::findByRangeId($GLOBALS['user']->id) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= _('Persönliche Aufgabensammlung') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+
+ <? foreach ($courses as $course): ?>
+ <? $assignments = VipsAssignment::findByRangeId($course->id) ?>
+ <? $assignments = array_filter($assignments, fn($a) => !$a->isLocked()) ?>
+ <? usort($assignments, fn($a, $b) => strcoll($a->test->title, $b->test->title)) ?>
+ <? if ($assignments): ?>
+ <optgroup label="<?= htmlReady($course->name . ' (' . $course->start_semester->name . ')') ?>">
+ <? foreach ($assignments as $assignment): ?>
+ <option value="<?= $assignment->id ?>" <?= $assignment->id == $assignment_id ? 'selected' : '' ?>>
+ <?= htmlReady($assignment->test->title) ?>
+ </option>
+ <? endforeach ?>
+ </optgroup>
+ <? endif ?>
+ <? endforeach ?>
+ </select>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Verschieben'), 'move') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/sheets/print_assignment.php b/app/views/vips/sheets/print_assignment.php
new file mode 100644
index 0000000..9e7b5e6
--- /dev/null
+++ b/app/views/vips/sheets/print_assignment.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * @var VipsAssignment $assignment
+ * @var string[] $lecturers
+ * @var string[]|null $students
+ * @var bool $print_student_ids
+ * @var string $user_id
+ * @var bool $print_sample_solution
+ * @var bool $print_correction
+ */
+?>
+<div class="assignment">
+ <h1>
+ <?= htmlReady($assignment->test->title) ?>
+ </h1>
+
+ <div class="description">
+ <?= formatReady($assignment->test->description) ?>
+ </div>
+
+ <p class="description">
+ <?= _('Beginn') ?>: <?= date('d.m.Y, H:i', $assignment->start) ?><br>
+ <? if (!$assignment->isUnlimited()): ?>
+ <?= _('Ende') ?>: <?= date('d.m.Y, H:i', $assignment->end) ?>
+ <? endif ?>
+ </p>
+
+ <? if ($assignment->range_type === 'course'): ?>
+ <p>
+ <?= _('Kurs') ?>: <?= htmlReady($assignment->course->name) ?>
+ <? if ($assignment->course->veranstaltungsnummer): ?>
+ (<?= htmlReady($assignment->course->veranstaltungsnummer) ?>)
+ <? endif ?>
+ <br>
+ <?= _('Semester') ?>: <?= htmlReady($assignment->course->start_semester->name) ?><br>
+ <?= _('Lehrende') ?>: <?= htmlReady(join(', ', $lecturers)) ?>
+ </p>
+
+ <p class="label-text">
+ <? if (isset($students)): ?>
+ <?= _('Name') ?>: <?= htmlReady(join(', ', $students)) ?><br>
+ <? else :?>
+ <?= _('Name') ?>: ________________________________________<br>
+ <? endif ?>
+ <? if ($assignment->type == 'exam'): ?>
+ <? if (isset($stud_ids) && $print_student_ids): ?>
+ <?= _('Matrikelnummer') ?>: <?= htmlReady(join(', ', $stud_ids)) ?>
+ <? else :?>
+ <br>
+ <?= _('Matrikelnummer') ?>: _______________________________
+ <? endif ?>
+ <? endif ?>
+ </p>
+ <? endif ?>
+
+ <? foreach ($assignment->getExerciseRefs($user_id) as $i => $exercise_ref): ?>
+ <? $exercise = $exercise_ref->exercise ?>
+ <? $solution = null ?>
+
+ <? if ($user_id): ?>
+ <? $solution = $assignment->getSolution($user_id, $exercise->id); ?>
+ <? endif ?>
+
+ <? if (!$solution): ?>
+ <? $solution = new VipsSolution(); ?>
+ <? $solution->assignment = $assignment; ?>
+ <? endif ?>
+
+ <?= $this->render_partial('vips/exercises/print_exercise', [
+ 'exercise' => $exercise,
+ 'exercise_position' => $i + 1,
+ 'max_points' => $exercise_ref->points,
+ 'solution' => $solution,
+ 'show_solution' => $print_sample_solution
+ ]) ?>
+ <? endforeach ?>
+
+ <? if ($print_correction): ?>
+ <? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+ <? $max_points = $assignment->test->getTotalPoints(); ?>
+ <? $reached_points = $assignment->getUserPoints($user_id); ?>
+ <? $feedback = $assignment->getUserFeedback($user_id); ?>
+ <div class="exercise">
+ <h2>
+ <?= _('Gesamtpunktzahl') ?>
+
+ <div class="points">
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ </div>
+ </h2>
+
+ <div class="label-text">
+ <?= sprintf(_('Erreichte Punkte: %g / %g'), $reached_points, $max_points) ?>
+ </div>
+
+ <? if ($feedback != ''): ?>
+ <div class="label-text">
+ <?= _('Kommentar zur Bewertung') ?>
+ </div>
+
+ <?= formatReady($feedback) ?>
+ <? endif ?>
+ </div>
+ <? setlocale(LC_NUMERIC, 'C') ?>
+ <? endif ?>
+</div>
diff --git a/app/views/vips/sheets/print_assignments.php b/app/views/vips/sheets/print_assignments.php
new file mode 100644
index 0000000..071e13c
--- /dev/null
+++ b/app/views/vips/sheets/print_assignments.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var VipsAssignment $assignment
+ * @var string[] $user_ids
+ * @var bool $print_files
+ * @var bool $print_correction
+ * @var bool $print_sample_solution
+ * @var array $assignment_data
+ */
+?>
+<? if ($assignment->checkEditPermission()): ?>
+ <form class="print_settings" action="<?= $controller->link_for('vips/sheets/print_assignments') ?>" method="POST">
+ <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>">
+
+ <? foreach ($user_ids as $user_id): ?>
+ <input type="hidden" name="user_ids[]" value="<?= htmlReady($user_id) ?>">
+ <? endforeach ?>
+
+ <?= _('Einstellungen:') ?>
+
+ <? if ($user_ids): ?>
+ <label>
+ <input type="checkbox" name="print_files" value="1" <?= $print_files ? 'checked' : '' ?> onchange="this.form.submit();">
+ <?= _('Dateiabgaben drucken') ?>
+ </label>
+
+ <label>
+ <input type="checkbox" name="print_correction" value="1" <?= $print_correction ? 'checked' : '' ?> onchange="this.form.submit();">
+ <?= _('Korrekturen drucken') ?>
+ </label>
+ <? endif ?>
+
+ <label>
+ <input type="checkbox" name="print_sample_solution" value="1" <?= $print_sample_solution ? 'checked' : '' ?> onchange="this.form.submit();">
+ <?= _('Musterlösung drucken') ?>
+ </label>
+ </form>
+<? endif ?>
+
+<? foreach ($assignment_data as $data): ?>
+ <?= $this->render_partial('vips/sheets/print_assignment', $data) ?>
+<? endforeach ?>
diff --git a/app/views/vips/sheets/print_layout.php b/app/views/vips/sheets/print_layout.php
new file mode 100644
index 0000000..866fa16
--- /dev/null
+++ b/app/views/vips/sheets/print_layout.php
@@ -0,0 +1,9 @@
+<?php
+/**
+ * @var string $content_for_layout
+ */
+?>
+
+<? include 'lib/include/html_head.inc.php' ?>
+<?= $content_for_layout ?>
+<? include 'lib/include/html_end.inc.php' ?>
diff --git a/app/views/vips/sheets/show_assignment.php b/app/views/vips/sheets/show_assignment.php
new file mode 100644
index 0000000..4f75521
--- /dev/null
+++ b/app/views/vips/sheets/show_assignment.php
@@ -0,0 +1,179 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var VipsAssignment $assignment
+ * @var int $remaining_time
+ * @var string $exam_terms
+ * @var int $user_end_time
+ * @var string $preview_exam_terms
+ * @var bool $needs_code
+ * @var string $access_code
+ * @var string $solver_id
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<? if ($assignment->type === 'exam' && isset($assignment_attempt) && $remaining_time > 0) : ?>
+ <div id="exam_timer" data-time="<?= $remaining_time ?>">
+ <?= _('Restzeit') ?>: <span class="time"><?= round($remaining_time / 60) ?></span> min
+ </div>
+<? endif ?>
+
+<?= $contentbar->render() ?>
+
+<h1 class="width-1200">
+ <?= htmlReady($assignment->test->title) ?>
+</h1>
+
+<div class="width-1200" style="margin: 10px 0;">
+ <?= formatReady($assignment->test->description) ?>
+</div>
+
+<? if ($assignment->isUnlimited()) : ?>
+ <?= _('Start:') ?>
+ <?= date('d.m.Y, H:i', $assignment->start) ?>
+<? else: ?>
+ <?= _('Zeitraum:') ?>
+ <?= date('d.m.Y, H:i', $assignment->start) ?> &ndash;
+ <?= date('d.m.Y, H:i', $assignment->end) ?>
+<? endif ?>
+
+<? if ($assignment->type === 'exam'): ?>
+ <p style="font-weight: bold;">
+ <? if ($exam_terms): ?>
+ <?= sprintf(_('Bearbeitungszeit: %d Minuten.'), round($remaining_time / 60)) ?>
+ <? elseif ($remaining_time > 0): ?>
+ <?= sprintf(_('Sie haben noch %d Minuten Zeit.'), round($remaining_time / 60)) ?>
+ <? else: ?>
+ <?= _('Ihre Bearbeitungszeit ist abgelaufen.') ?>
+ <? endif ?>
+ </p>
+<? elseif ($user_end_time && $remaining_time <= 0): ?>
+ <p style="font-weight: bold;">
+ <?= _('Die Bearbeitung ist bereits abgeschlossen.') ?>
+ </p>
+<? endif ?>
+
+<? if ($preview_exam_terms): ?>
+ <form class="default width-1200" style="margin-bottom: 1.5ex;">
+ <input id="options-toggle" class="options-toggle" type="checkbox" value="on">
+ <a class="caption" href="#" role="button" data-toggles="#options-toggle" aria-controls="options-panel" aria-expanded="false">
+ <?= Icon::create('arr_1down')->asImg(['class' => 'toggle-open']) ?>
+ <?= Icon::create('arr_1right')->asImg(['class' => 'toggle-closed']) ?>
+ <?= _('Teilnahmebedingungen') ?>
+ </a>
+
+ <div class="toggle-box" id="options-panel">
+ <div class="exercise_hint" style="display: block;">
+ <?= formatReady($preview_exam_terms) ?>
+
+ <label>
+ <input type="checkbox" value="1" disabled>
+ <?= _('Ich bestätige die vorstehenden Bedingungen zur Teilnahme an der Klausur') ?>
+ </label>
+ </div>
+ </div>
+ </form>
+<? endif ?>
+
+<? if ($exam_terms || $needs_code): ?>
+ <form class="default width-1200" action="<?= $controller->link_for('vips/sheets/begin_assignment') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>">
+
+ <div class="exercise_hint" style="display: block;">
+ <? if ($exam_terms): ?>
+ <?= formatReady($exam_terms) ?>
+
+ <label>
+ <input type="checkbox" name="terms_accepted" value="1" required>
+ <?= _('Ich bestätige die vorstehenden Bedingungen zur Teilnahme an der Klausur') ?>
+ </label>
+ <? endif ?>
+
+ <? if ($needs_code): ?>
+ <label>
+ <?= _('Es ist ein Zugangscode für den Zugriff auf die Klausur erforderlich:') ?>
+ <input type="text" name="access_code" value="<?= htmlReady($access_code) ?>" required>
+ </label>
+ <? endif ?>
+
+ <?= Studip\Button::createAccept(_('Klausur starten'), 'begin_assignment') ?>
+ </div>
+ </form>
+<? else: ?>
+ <? if (count($assignment->test->exercise_refs)): ?>
+ <table class="default dynamic_list width-1200">
+ <thead>
+ <tr>
+ <th style="width: 2em;">
+ </th>
+ <th style="width: 50%;">
+ <?= _('Aufgaben') ?>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Abgabedatum') ?>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Teilnehmende') ?>
+ </th>
+ <th style="width: 10%; text-align: center;">
+ <?= _('Bearbeitet') ?>
+ </th>
+ <th style="width: 10%; text-align: center;">
+ <?= _('Max. Punkte') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignment->getExerciseRefs($solver_id) as $exercise_ref): ?>
+ <? $exercise = $exercise_ref->exercise ?>
+ <? $solution = $assignment->getSolution($solver_id, $exercise->id) ?>
+ <tr>
+ <td class="dynamic_counter" style="text-align: right;">
+ </td>
+ <td>
+ <a href="<?= $controller->link_for('vips/sheets/show_exercise', ['assignment_id' => $assignment->id, 'exercise_id' => $exercise->id, 'solver_id' => $solver_id]) ?>">
+ <?= htmlReady($exercise->title) ?>
+ </a>
+ </td>
+ <td>
+ <? if ($solution): ?>
+ <?= date('d.m.Y, H:i', $solution->mkdate) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <? if ($solution): ?>
+ <?= htmlReady(get_fullname($solution->user_id, 'no_title')) ?>
+ <? endif ?>
+ </td>
+ <td style="text-align: center;">
+ <? if ($solution): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?>
+ <? else : ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?>
+ <? endif ?>
+ </td>
+ <td style="text-align: center;">
+ <?= sprintf('%g', $exercise_ref->points) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="5"></td>
+ <td style="text-align: center;">
+ <?= sprintf('%g', $assignment->test->getTotalPoints()) ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ <? else : ?>
+ <?= MessageBox::info(_('Keine Aufgaben gefunden.')) ?>
+ <? endif ?>
+<? endif ?>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/sheets/show_exercise.php b/app/views/vips/sheets/show_exercise.php
new file mode 100644
index 0000000..b680450
--- /dev/null
+++ b/app/views/vips/sheets/show_exercise.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * @var int $assignment_id
+ * @var VipsAssignment $assignment
+ * @var int $exercise_id
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var int $remaining_time
+ * @var int $user_end_time
+ * @var Vips_SheetsController $controller
+ * @var string|null $solver_id
+ * @var bool $show_solution
+ * @var float $max_points
+ * @var int|null $exercise_position
+ * @var int $tries_left
+ */
+?>
+
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<? if ($assignment->type == 'exam' && !$assignment->checkEditPermission()) : ?>
+ <div id="exam_timer" data-time="<?= $remaining_time ?>">
+ <?= _('Restzeit') ?>: <span class="time"><?= round($remaining_time / 60) ?></span> min
+ </div>
+
+ <div class="width-1200" style="font-weight: bold; text-align: center;">
+ <?= _('Abgabezeitpunkt:') ?>
+ <?= sprintf(_('%s Uhr'), date('H:i', $user_end_time)) ?>
+ </div>
+<? endif ?>
+
+<?= $contentbar->render() ?>
+
+<? if ($show_solution) : ?>
+ <form class="default width-1200">
+ <!-- show feedback for selftest -->
+ <?= $this->render_partial('vips/exercises/correct_exercise') ?>
+
+ <fieldset>
+ <legend>
+ <?= sprintf(_('Bewertung der Aufgabe „%s“'), htmlReady($exercise->title)) ?>
+ </legend>
+
+ <? if ($solution->feedback != '') : ?>
+ <div class="label-text">
+ <?= _('Anmerkungen zur Lösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= formatReady($solution->feedback) ?>
+ </div>
+ <? endif ?>
+
+ <div class="description">
+ <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?>
+ </div>
+ </fieldset>
+ </form>
+<? else : ?>
+ <!-- solve and submit exercise -->
+ <form class="default width-1200" name="jsfrm" action="<?= $controller->link_for('vips/sheets/submit_exercise') ?>" autocomplete="off" data-secure method="POST" enctype="multipart/form-data">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="solver_id" value="<?= $solver_id ?>">
+ <input type="hidden" name="exercise_id" value="<?= $exercise_id ?>">
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <input type="hidden" name="forced" value="0">
+
+ <fieldset>
+ <legend>
+ <?= $exercise_position ?>.
+ <?= htmlReady($exercise->title) ?>
+ <div style="float: right;">
+ <? if ($max_points == (int) $max_points): ?>
+ <?= sprintf(ngettext('%d Punkt', '%d Punkte', $max_points), $max_points) ?>
+ <? else: ?>
+ <?= sprintf(_('%g Punkte'), $max_points) ?>
+ <? endif ?>
+ </div>
+ </legend>
+
+ <? if ($tries_left > 0): ?>
+ <?= MessageBox::warning(sprintf(ngettext(
+ 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weiteren Versuch.',
+ 'Ihr Lösungsversuch war nicht korrekt. Sie haben noch %d weitere Versuche.', $tries_left), $tries_left)) ?>
+ <? endif ?>
+
+ <div class="description">
+ <?= formatReady($exercise->description) ?>
+ </div>
+
+ <?= $this->render_partial('vips/exercises/show_exercise_hint') ?>
+ <?= $this->render_partial('vips/exercises/show_exercise_files') ?>
+
+ <?= $this->render_partial($exercise->getSolveTemplate($solution, $assignment, $solver_id)) ?>
+
+ <? if (!empty($exercise->options['comment'])) : ?>
+ <label>
+ <?= _('Bemerkungen zur Lösung (optional)') ?>
+ <textarea name="student_comment"><?= $solution ? htmlReady($solution->student_comment) : '' ?></textarea>
+ </label>
+ <? endif ?>
+ </fieldset>
+
+ <footer>
+ <?= Studip\Button::createAccept(_('Speichern'), 'submit_exercise', $exercise->itemCount() ? [] : ['disabled' => 'disabled']) ?>
+ </footer>
+ </form>
+<? endif ?>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/sheets/show_exercise_link.php b/app/views/vips/sheets/show_exercise_link.php
new file mode 100644
index 0000000..6f41a6a
--- /dev/null
+++ b/app/views/vips/sheets/show_exercise_link.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var VipsAssignment $assignment
+ * @var int $assignment_id
+ * @var int $position
+ * @var string $solver_id
+ * @var Exercise $item
+ */
+?>
+<a href="<?= $controller->link_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $item->task_id, 'solver_id' => $solver_id]) ?>">
+ <div class="sidebar_exercise_label">
+ <?= sprintf(_('Aufgabe %d'), $position) ?>
+ </div>
+ <div class="sidebar_exercise_points">
+ <?= sprintf(_('%g Punkte'), $item->points) ?>
+ </div>
+ <div class="sidebar_exercise_state">
+ <? if ($assignment->getSolution($solver_id, $item->task_id)): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('Aufgabe bearbeitet')]) ?>
+ <? endif ?>
+ </div>
+</a>
diff --git a/app/views/vips/sheets/start_assignment_dialog.php b/app/views/vips/sheets/start_assignment_dialog.php
new file mode 100644
index 0000000..4dd47d9
--- /dev/null
+++ b/app/views/vips/sheets/start_assignment_dialog.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * @var Vips_SheetsController $controller
+ * @var int $assignment_id
+ * @var VipsAssignment $assignment
+ */
+?>
+<form class="default" action="<?= $controller->link_for('vips/sheets/start_assignment') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>">
+
+ <div class="description">
+ <?= _('Bitte bestätigen Sie den Endzeitpunkt:') ?>
+ </div>
+
+ <label class="undecorated">
+ <div class="label-text">
+ <span class="required"><?= _('Startzeitpunkt') ?></span>
+ </div>
+
+ <input type="text" name="start_date" class="size-s" value="<?= date('d.m.Y') ?>" disabled>
+ <input type="text" name="start_time" class="size-s" value="<?= date('H:i') ?>" disabled>
+ </label>
+
+ <? $required = $assignment->type !== 'selftest' ? 'required' : '' ?>
+
+ <label class="undecorated">
+ <div class="label-text">
+ <span class="<?= $required ?>"><?= _('Endzeitpunkt') ?></span>
+ </div>
+
+ <input type="text" name="end_date" class="size-s" value="<?= $assignment->isUnlimited() ? '' : date('d.m.Y', $assignment->end) ?>" <?= $required ?>>
+ <input type="text" name="end_time" class="size-s" value="<?= $assignment->isUnlimited() ? '' : date('H:i', $assignment->end) ?>" <?= $required ?>>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'submit') ?>
+ <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/assignment_solutions.php b/app/views/vips/solutions/assignment_solutions.php
new file mode 100644
index 0000000..86b4e8a
--- /dev/null
+++ b/app/views/vips/solutions/assignment_solutions.php
@@ -0,0 +1,308 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var VipsAssignment $assignment
+ * @var int $overall_uncorrected_solutions
+ * @var int $assignment_id
+ * @var array $first_uncorrected_solution
+ * @var string $expand
+ * @var string $view
+ * @var array $solvers
+ * @var int $overall_max_points
+ * @var array $exercises
+ *
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<form action="" method="POST" id="post_form">
+ <?= CSRFProtection::tokenTag() ?>
+</form>
+
+<form action="<?= $controller->link_for('vips/solutions/assignment_solutions') ?>">
+ <input type="hidden" name="cid" value="<?= htmlReady($assignment->range_id) ?>">
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+
+ <table class="default dynamic_list">
+ <caption>
+ <?= sprintf(_('Aufgabenblatt „%s“'), htmlReady($assignment->test->title)) ?>
+ <?= tooltipIcon($this->render_partial('vips/solutions/solution_color_tooltip'), false, true) ?>
+
+ <span class="actions">
+ <label>
+ <?= _('Anzeigefilter:') ?>
+
+ <select name="view" class="submit-upon-select">
+ <? if ($assignment->type !== 'exam') : ?>
+ <option value="">
+ <?= _('Studierende mit abgegebenen Lösungen') ?>
+ </option>
+ <option value="todo" <?= $view == 'todo' ? 'selected' : '' ?>>
+ <?= _('Studierende mit unkorrigierten Lösungen') ?>
+ </option>
+ <option value="all" <?= $view == 'all' ? 'selected' : '' ?>>
+ <?= _('Alle Studierende') ?>
+ </option>
+ <? else : ?>
+ <option value="">
+ <?= _('Beendete Klausuren') ?>
+ </option>
+ <option value="working" <?= $view == 'working' ? 'selected' : '' ?>>
+ <?= _('Laufende Klausuren') ?>
+ </option>
+ <option value="pending" <?= $view == 'pending' ? 'selected' : '' ?>>
+ <?= _('Noch nicht begonnene Klausuren') ?>
+ </option>
+ <? endif ?>
+ </select>
+ </label>
+ </span>
+ </caption>
+
+ <thead>
+ <tr>
+ <th style="width: 20px;">
+ <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Teilnehmenden auswählen') ?>">
+ </th>
+ <th style="width: 1em;"></th>
+ <th>
+ <a href="#" class="solution-toggle">
+ <?= Icon::create('arr_1right')->asImg(['class' => 'arrow_all', 'title' => _('Aufgaben aller Teilnehmenden anzeigen')]) ?>
+ <?= Icon::create('arr_1down')->asImg(['class' => 'arrow_all', 'title' => _('Aufgaben aller Teilnehmenden verstecken'), 'style' => 'display: none;']) ?>
+ <?= _('Teilnehmende') ?>
+ </a>
+ </th>
+ <th style="text-align: center;">
+ <?= _('Punkte') ?>
+ </th>
+ <th style="text-align: center;">
+ <?= _('Prozent') ?>
+ </th>
+ <th style="text-align: center;">
+ <?= _('Fortschritt') ?>
+ </th>
+ <th style="text-align: center;">
+ <?= _('Unkorrigierte Lösungen') ?>
+ </th>
+ <th style="text-align: center;">
+ <?= _('Unbearbeitete Aufgaben') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($solvers as $solver) : ?>
+ <? /* extra info */ ?>
+ <? $reached_points = $solver['extra_info']['points']; ?>
+ <? $progress = $solver['extra_info']['progress']; ?>
+ <? $uncorrected_solutions = $solver['extra_info']['uncorrected']; ?>
+ <? $unanswered_exercises = $solver['extra_info']['unanswered']; ?>
+ <? $uploaded_files = $solver['extra_info']['files']; ?>
+ <tr id="row_<?= $solver['id'] ?>" class="solution <?= $expand == $solver['id'] ? '' : 'solution-closed' ?>">
+ <td>
+ <input class="batch_select" type="checkbox" name="user_ids[]" value="<?= $solver['user_id'] ?>" aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+ <td class="dynamic_counter" style="text-align: right;">
+ </td>
+
+ <td>
+ <a href="#" class="solution-toggle">
+ <?= Icon::create('arr_1right')->asImg(['class' => 'solution-open', 'title' => _('Aufgaben anzeigen')]) ?>
+ <?= Icon::create('arr_1down')->asImg(['class' => 'solution-close', 'title' => _('Aufgaben verstecken')]) ?>
+ <?= htmlReady($solver['name']) ?>
+ </a>
+
+ <? if ($solver['type'] == 'single') : ?>
+ <? /* running info */ ?>
+ <? if ($assignment->type == 'exam' && $view === 'working') : ?>
+ <? $ip = $solver['running_info']['ip'] ?>
+ <? $start = $solver['running_info']['start'] ?>
+ <? $remaining = $solver['running_info']['remaining'] ?>
+ <div class="smaller">
+ <?= _('IP-Adresse') ?>: <?= htmlReady($ip) ?> (<?= htmlReady(gethostbyaddr($ip)) ?>)<br>
+ <?= _('Start') ?>: <span title="<?= strftime('%A, %d.%m.%Y', $start) ?>"><?= sprintf(_('%s Uhr'), date('H:i', $start)) ?></span>
+ <? if ($remaining > 0): ?>
+ (<?= sprintf(ngettext('noch %d Minute', 'noch %d Minuten', $remaining), $remaining) ?>)
+ <? endif ?>
+ </div>
+ <? endif ?>
+ <? elseif ($solver['type'] == 'group') : ?>
+ <? /* list members in group */ ?>
+ <? foreach ($solver['members'] as $member) : ?>
+ <div class="smaller" style="padding-left: 20px;">
+ <?= htmlReady($member['name']) ?>
+ </div>
+ <? endforeach ?>
+ <? endif ?>
+ </td>
+
+ <? /* reached points */ ?>
+ <td style="text-align: center;">
+ <?= sprintf('%g / %g', $reached_points, $overall_max_points) ?>
+ </td>
+
+ <? /* percent */ ?>
+ <td style="text-align: center;">
+ <? if ($overall_max_points != 0) : ?>
+ <?= sprintf('%.1f %%', round($reached_points / $overall_max_points * 100, 1)) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <? /* progress */ ?>
+ <td style="text-align: center;">
+ <? if ($overall_max_points != 0) : ?>
+ <? $value = round($progress / $overall_max_points * 100) ?>
+ <progress class="assignment" value="<?= $value ?>" max="100" title="<?= $value ?> %"><?= $value ?> %</progress>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <? /* uncorrected solutions */ ?>
+ <td style="text-align: center;">
+ <? if ($uncorrected_solutions > 0) : ?>
+ <?= $uncorrected_solutions ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <? /* unanswered exercises */ ?>
+ <td style="text-align: center;">
+ <? if ($unanswered_exercises > 0) : ?>
+ <?= $unanswered_exercises ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <td class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <? if ($assignment->type === 'exam' && $view !== 'pending') : ?>
+ <? if ($assignment->isRunning()) : ?>
+ <? $menu->addLink($controller->url_for('vips/solutions/edit_assignment_attempt', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]),
+ _('Abgabezeitpunkt bearbeiten'), Icon::create('edit'), ['data-dialog' => 'size=auto']
+ ) ?>
+ <? $menu->addButton('reset', _('Teilnahme und Lösungen zurücksetzen'), Icon::create('refresh'), [
+ 'form' => 'post_form',
+ 'formaction' => $controller->url_for('vips/solutions/delete_solutions', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]),
+ 'data-confirm' => _('Achtung: Wenn Sie die Teilnahme zurücksetzen, werden alle Lösungen der teilnehmenden Person archiviert!')
+ ]) ?>
+ <? endif ?>
+ <? $menu->addLink($controller->url_for('vips/solutions/show_assignment_log', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]),
+ _('Abgabeprotokoll anzeigen'), Icon::create('log'), ['data-dialog' => 'size=auto']
+ ) ?>
+ <? endif ?>
+ <? if ($uploaded_files > 0): ?>
+ <? $menu->addLink($controller->url_for('vips/solutions/download_uploads', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]),
+ _('Abgegebene Dateien herunterladen'), Icon::create('download')
+ ) ?>
+ <? endif ?>
+ <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'user_ids[]' => $solver['user_id'], 'print_files' => 1, 'print_correction' => !$view]),
+ _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank']
+ ) ?>
+ <? if ($solver['type'] == 'single') : ?>
+ <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $solver['username']]),
+ sprintf(_('Nachricht an „%s“ schreiben'), $solver['name']), Icon::create('mail'), ['data-dialog' => '']
+ ) ?>
+ <? elseif ($solver['type'] == 'group') : ?>
+ <? $receivers = array_column($solver['members'], 'username') ?>
+ <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $receivers]),
+ _('Nachricht an die Gruppe schreiben'), Icon::create('mail'), ['data-dialog' => '']
+ ) ?>
+ <? if ($assignment->isFinished()) : ?>
+ <? $menu->addLink($controller->url_for('vips/solutions/edit_group_dialog', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]),
+ _('Personen aus der Gruppe entfernen'), Icon::create('community'), ['data-dialog' => 'size=auto']
+ ) ?>
+ <? endif ?>
+ <? endif ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+
+ <tr class="nohover">
+ <td colspan="2"></td>
+ <td colspan="7">
+ <table class="smaller" style="width: 100%;">
+ <tr>
+ <? $col_count = 0; ?>
+ <? foreach ($exercises as $exercise) : ?>
+ <td class="solution-col-5" style="padding: 2px;">
+ <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise['id'], 'solver_id' => $solver['user_id'], 'view' => $view]) ?>">
+ <? if (!isset($solutions[$solver['id']][$exercise['id']])) : ?>
+ <? $class = 'solution-none'; ?>
+ <? elseif (!$solutions[$solver['id']][$exercise['id']]['corrected']) : ?>
+ <? $class = 'solution-uncorrected'; ?>
+ <? elseif (!isset($solutions[$solver['id']][$exercise['id']]['grader_id'])) : ?>
+ <? $class = 'solution-autocorrected'; ?>
+ <? else : ?>
+ <? $class = 'solution-corrected'; ?>
+ <? endif ?>
+ <span class="<?= $class ?>">
+ <?= $exercise['position'] ?>.
+ <?= htmlReady($exercise['title']) ?>
+ </span>
+ </a>
+ <br>
+
+ <? /* reached / max points */ ?>
+ <? $max_points = $exercises[$exercise['id']]['points'] ?>
+ <? if (isset($solutions[$solver['id']][$exercise['id']])) : ?>
+ <? $points = $solutions[$solver['id']][$exercise['id']]['points'] ?>
+ <? $title = sprintf('Punkte: %g von %g', $points, $max_points) ?>
+ <? if ($points > $max_points || $points < 0) : ?>
+ <span style="color: red;" title="<?= htmlReady($title) ?>">
+ (<?= sprintf('%g/%g', $points, $max_points) ?>)
+ </span>
+ <? else : ?>
+ <span title="<?= htmlReady($title) ?>">
+ (<?= sprintf('%g/%g', $points, $max_points) ?>)
+ </span>
+ <? endif ?>
+ <? else : ?>
+ <span class="solution-none">
+ (<?= sprintf('%g/%g', 0, $max_points) ?>)
+ </span>
+ <? endif ?>
+ </td>
+ <? if (++$col_count % 5 == 0): ?>
+ </tr>
+ <tr>
+ <? endif ?>
+ <? endforeach ?>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+
+ <? if (count($solvers)): ?>
+ <tfoot>
+ <tr>
+ <td colspan="9">
+ <?= Studip\Button::create(_('Drucken'), 'print', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/sheets/print_assignments', ['print_files' => 1, 'print_correction' => !$view]),
+ 'formmethod' => 'post',
+ 'formtarget' => '_blank'
+ ]) ?>
+ <?= Studip\Button::create(_('Nachricht schreiben'), 'message', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->url_for('vips/solutions/write_message'),
+ 'formmethod' => 'post',
+ 'data-dialog' => ''
+ ]) ?>
+ </td>
+ </tr>
+ </tfoot>
+ <? endif ?>
+ </table>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/assignments.php b/app/views/vips/solutions/assignments.php
new file mode 100644
index 0000000..d1629cf
--- /dev/null
+++ b/app/views/vips/solutions/assignments.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * @var array $test_data
+ * @var string $course_id
+ */
+?>
+<? if (count($test_data['assignments'])): ?>
+ <? if (VipsModule::hasStatus('tutor', $course_id)): ?>
+ <?= $this->render_partial('vips/solutions/assignments_list', $test_data) ?>
+ <? else: ?>
+ <?= $this->render_partial('vips/solutions/assignments_list_student', $test_data) ?>
+ <? if (isset($overview_data)): ?>
+ <?= $this->render_partial('vips/solutions/student_grade', $overview_data) ?>
+ <? endif ?>
+ <? endif ?>
+<? else: ?>
+ <?= MessageBox::info(_('Es ist kein beendetes Aufgabenblatt vorhanden.')) ?>
+<? endif ?>
diff --git a/app/views/vips/solutions/assignments_list.php b/app/views/vips/solutions/assignments_list.php
new file mode 100644
index 0000000..b9865d1
--- /dev/null
+++ b/app/views/vips/solutions/assignments_list.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var string $sort
+ * @var bool $desc
+ * @var VipsBlock[] $blocks
+ * @var bool $use_weighting
+ * @var float $sum_max_points
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<form class="default" action="<?= $controller->link_for('vips/admin/store_weight') ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <button hidden name="store_weight"></button>
+
+ <table class="default collapsable">
+ <caption>
+ <?= _('Aufgabenblätter') ?>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 20px;">
+ <input type="checkbox" data-proxyfor=".batch_select" data-activates=".batch_action" aria-label="<?= _('Alle Aufgabenblätter auswählen') ?>">
+ </th>
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->assignments(['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>">
+ <a href="<?= $controller->assignments(['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>">
+ <?= _('Start') ?>
+ </a>
+ </th>
+ <th style="width: 15%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>">
+ <a href="<?= $controller->assignments(['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>">
+ <?= _('Ende') ?>
+ </a>
+ </th>
+ <th style="width: 5%; text-align: center;">
+ <?= _('Korrigiert') ?>
+ </th>
+ <th style="width: 5%; text-align: center;">
+ <?= _('Freigabe') ?>
+ </th>
+ <th style="width: 5%; text-align: right;">
+ <?= _('Punkte') ?>
+ </th>
+ <th style="width: 10%; text-align: right;">
+ <?= _('Gewichtung') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <? foreach ($blocks as $block) :?>
+ <? if (isset($block_assignments[$block->id]) || $block->weight !== null): ?>
+ <tbody>
+ <? if (count($blocks) > 1): ?>
+ <tr class="header-row">
+ <th class="toggle-indicator" colspan="7">
+ <a class="toggler" href="#">
+ <?= htmlReady($block->name) ?>
+ <? if (!$block->visible): ?>
+ <?= _('(für Teilnehmende unsichtbar)') ?>
+ <? elseif ($block->group_id): ?>
+ <?= sprintf(_('(sichtbar für Gruppe „%s“)'), htmlReady($block->group->name)) ?>
+ <? elseif ($block->id): ?>
+ <?= _('(für alle sichtbar)') ?>
+ <? endif ?>
+ </a>
+ </th>
+ <th class="dont-hide" style="text-align: right;">
+ <? if ($block->weight !== null): ?>
+ <input type="text" class="percent_input" name="block_weight[<?= $block->id ?>]"
+ value="<?= $use_weighting ? sprintf('%g', $block->weight) : '' ?>"> %
+ <? endif ?>
+ </th>
+ <th class="actions">
+ </th>
+ </tr>
+ <? endif ?>
+
+ <? if (isset($block_assignments[$block->id])): ?>
+ <? foreach ($block_assignments[$block->id] as $ass): ?>
+ <tr>
+ <td>
+ <input type="checkbox" class="batch_select" name="assignment_ids[]" value="<?= $ass['assignment']->id ?>"
+ aria-label="<?= _('Zeile auswählen') ?>">
+ </td>
+ <td>
+ <a href="<?= $controller->assignment_solutions(['assignment_id' => $ass['assignment']->id]) ?>">
+ <?= $ass['assignment']->getTypeIcon() ?>
+ <?= htmlReady($ass['assignment']->test->title) ?>
+ </a>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $ass['assignment']->start) ?>
+ </td>
+ <td>
+ <? if (!$ass['assignment']->isUnlimited()): ?>
+ <?= date('d.m.Y, H:i', $ass['assignment']->end) ?>
+ <? endif ?>
+ </td>
+
+ <td style="text-align: center;">
+ <? if (!isset($ass['uncorrected_solutions'])): ?>
+ &ndash;
+ <? elseif ($ass['uncorrected_solutions'] == 0): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?>
+ <? else : ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?>
+ <? endif ?>
+ </td>
+
+ <td style="text-align: center;">
+ <? if ($ass['released'] == VipsAssignment::RELEASE_STATUS_POINTS): ?>
+ <?= _('Punkte') ?>
+ <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_COMMENTS): ?>
+ <?= _('Kommentare') ?>
+ <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?>
+ <?= _('Korrektur') ?>
+ <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS): ?>
+ <?= _('Lösungen') ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%g', $ass['max_points']) ?>
+ </td>
+ <td style="text-align: right;">
+ <? if ($ass['assignment']->type !== 'selftest' && $block->weight === null): ?>
+ <input type="text" class="percent_input" name="assignment_weight[<?= $ass['assignment']->id ?>]"
+ value="<?= $use_weighting ? sprintf('%g', $ass['assignment']->weight) : '' ?>"> %
+ <? endif ?>
+ </td>
+ <td class="actions">
+ <? $menu = ActionMenu::get() ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/solutions/update_released_dialog', ['assignment_ids[]' => $ass['assignment']->id]),
+ _('Freigabe ändern'),
+ Icon::create('lock-locked'),
+ ['data-dialog' => 'size=auto']
+ ) ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/edit_assignment', ['assignment_id' => $ass['assignment']->id]),
+ _('Aufgabenblatt bearbeiten'),
+ Icon::create('edit')
+ ) ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]),
+ _('Aufgabenblatt drucken'),
+ Icon::create('print'),
+ ['target' => '_blank']
+ ) ?>
+ <?= $menu->render() ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ <? endif ?>
+ </tbody>
+ <? endif ?>
+ <? endforeach ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="6">
+ <?= Studip\Button::create(_('Freigabe ändern'), 'change_released', [
+ 'class' => 'batch_action',
+ 'formaction' => $controller->update_released_dialogURL(),
+ 'data-dialog' => 'size=auto'
+ ]) ?>
+ </td>
+ <td style="padding-right: 5px; text-align: right;">
+ <?= sprintf('%g', $sum_max_points) ?>
+ </td>
+ <td colspan="2" style="text-align: center;">
+ <?= Studip\Button::create(_('Speichern'), 'store_weight') ?>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/assignments_list_student.php b/app/views/vips/solutions/assignments_list_student.php
new file mode 100644
index 0000000..a19a361
--- /dev/null
+++ b/app/views/vips/solutions/assignments_list_student.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var string $sort
+ * @var bool $desc
+ * @var VipsBlock[] $blocks
+ * @var float $sum_reached_points
+ * @var float $sum_max_points
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<table class="default collapsable">
+ <caption>
+ <?= _('Freigegebene Ergebnisse') ?>
+ </caption>
+
+ <thead>
+ <tr class="sortable">
+ <th style="width: 40%;" class="<?= $controller->sort_class($sort === 'title', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'title', 'desc' => $sort === 'title' && !$desc]) ?>">
+ <?= _('Titel') ?>
+ </a>
+ </th>
+ <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'start', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'start', 'desc' => $sort === 'start' && !$desc]) ?>">
+ <?= _('Start') ?>
+ </a>
+ </th>
+ <th style="width: 20%;" class="<?= $controller->sort_class($sort === 'end', $desc) ?>">
+ <a href="<?= $controller->link_for('vips/solutions', ['sort' => 'end', 'desc' => $sort === 'end' && !$desc]) ?>">
+ <?= _('Ende') ?>
+ </a>
+ </th>
+ <th colspan="3" style="width: 5%; text-align: right;">
+ <?= _('Punkte') ?>
+ </th>
+ <th style="width: 10%; text-align: right;">
+ <?= _('Prozent') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktion') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <? foreach ($blocks as $block) :?>
+ <? if (isset($block_assignments[$block->id])): ?>
+ <tbody>
+ <? if (count($block_assignments) > 1): ?>
+ <tr class="header-row">
+ <th class="toggle-indicator" colspan="8">
+ <a class="toggler" href="#">
+ <?= htmlReady($block->name) ?>
+ </a>
+ </th>
+ </tr>
+ <? endif ?>
+
+ <? foreach ($block_assignments[$block->id] as $ass): ?>
+ <tr>
+ <td>
+ <a href="<?= $controller->student_assignment_solutions(['assignment_id' => $ass['assignment']->id]) ?>">
+ <?= $ass['assignment']->getTypeIcon() ?>
+ <?= htmlReady($ass['assignment']->test->title) ?>
+ </a>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $ass['assignment']->start) ?>
+ </td>
+ <td>
+ <? if (!$ass['assignment']->isUnlimited()) : ?>
+ <?= date('d.m.Y, H:i', $ass['assignment']->end) ?>
+ <? endif ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%g', $ass['reached_points']) ?>
+ </td>
+ <td style="text-align: center;">
+ /
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%g', $ass['max_points']) ?>
+ </td>
+ <td style="text-align: right;">
+ <? if ($ass['max_points'] != 0) : ?>
+ <?= sprintf('%.1f %%', round(100 * $ass['reached_points'] / $ass['max_points'], 1)) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+ <td class="actions">
+ <? if ($ass['released'] >= VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?>
+ <? $menu = ActionMenu::get() ?>
+ <? $menu->addLink(
+ $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]),
+ _('Aufgabenblatt drucken'),
+ Icon::create('print'),
+ ['target' => '_blank']
+ ) ?>
+ <?= $menu->render() ?>
+ <? endif ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ <? endif ?>
+ <? endforeach ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="3"></td>
+ <td style="padding: 5px; text-align: right;">
+ <?= sprintf('%g', $sum_reached_points) ?>
+ </td>
+ <td style="padding: 5px; text-align: center;">
+ /
+ </td>
+ <td style="padding: 5px; text-align: right;">
+ <?= sprintf('%g', $sum_max_points) ?>
+ </td>
+ <td style="padding: 5px; text-align: right;">
+ <? if ($sum_max_points != 0) : ?>
+ <?= sprintf('%.1f %%', round(100 * $sum_reached_points / $sum_max_points, 1)) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+ <td>
+ </td>
+ </tr>
+ </tfoot>
+</table>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/autocorrect_dialog.php b/app/views/vips/solutions/autocorrect_dialog.php
new file mode 100644
index 0000000..29392e9
--- /dev/null
+++ b/app/views/vips/solutions/autocorrect_dialog.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var int $assignment_id
+ * @var string $view
+ * @var string $expand
+ */
+?>
+<form class="default" action="<?= $controller->autocorrect_solutions() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <input type="hidden" name="view" value="<?= htmlReady($view) ?>">
+ <input type="hidden" name="expand" value="<?= htmlReady($expand) ?>">
+
+ <h4>
+ <?= _('Manuell durchgeführte Korrekturen werden durch diese Aktion nicht überschrieben.') ?>
+ </h4>
+
+ <label>
+ <input type="checkbox" name="corrected" value="1">
+ <?= _('Unbekannte Eingaben als sicher falsch bewerten') ?>
+ <?= tooltipIcon(_('Wird diese Option nicht ausgewält, bleiben die betroffenen Aufgaben als unkorrigiert markiert.')) ?>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Autokorrektur starten'), 'autocorrect_solutions') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/edit_assignment_attempt.php b/app/views/vips/solutions/edit_assignment_attempt.php
new file mode 100644
index 0000000..55f13ec
--- /dev/null
+++ b/app/views/vips/solutions/edit_assignment_attempt.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var VipsAssignment $assignment
+ * @var string $solver_id
+ * @var string $view
+ * @var VipsAssignmentAttempt $assignment_attempt
+ */
+?>
+<form class="default" action="<?= $controller->store_assignment_attempt() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>">
+ <input type="hidden" name="solver_id" value="<?= htmlReady($solver_id) ?>">
+ <input type="hidden" name="view" value="<?= htmlReady($view) ?>">
+
+ <label>
+ <?= _('Teilnehmer/-in') ?>
+ <input type="text" disabled value="<?= htmlReady(get_fullname($solver_id, 'no_title_rev')) ?>">
+ </label>
+
+ <label>
+ <?= _('Startzeitpunkt') ?>
+ <input type="text" disabled value="<?= date('H:i:s', $assignment_attempt->start) ?>">
+ </label>
+
+ <label>
+ <span class="required"><?= _('Abgabezeitpunkt') ?></span>
+ <input type="text" name="end_time" value="<?= date('H:i:s', $assignment->getUserEndTime($solver_id)) ?>" required>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'submit') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/edit_group_dialog.php b/app/views/vips/solutions/edit_group_dialog.php
new file mode 100644
index 0000000..378aa73
--- /dev/null
+++ b/app/views/vips/solutions/edit_group_dialog.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var VipsAssignment $assignment
+ * @var VipsGroup $group
+ * @var string $view
+ * @var VipsGroupMember[] $members
+ */
+?>
+<form class="default" action="<?= $controller->edit_group() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="assignment_id" value="<?= $assignment->id ?>">
+ <input type="hidden" name="group_id" value="<?= htmlReady($group->id) ?>">
+ <input type="hidden" name="view" value="<?= htmlReady($view) ?>">
+
+ <div class="description">
+ <?= _('Wählen Sie aus, wen Sie aus der Gruppe entfernen möchten:') ?>
+ </div>
+
+ <? foreach ($members as $member): ?>
+ <label>
+ <input type="checkbox" name="user_ids[]" value="<?= $member->user_id ?>">
+ <?= htmlReady($member->user->getFullName('no_title_rev')) ?>
+ </label>
+ <? endforeach ?>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Entfernen'), 'edit') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/edit_solution.php b/app/views/vips/solutions/edit_solution.php
new file mode 100644
index 0000000..955fd40
--- /dev/null
+++ b/app/views/vips/solutions/edit_solution.php
@@ -0,0 +1,216 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var int $assignment_id
+ * @var VipsAssignment $assignment
+ * @var string $view
+ * @var int $exercise_id
+ * @var string $solver_or_group_id
+ * @var string $solver_name
+ * @var string $solver_id
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var float $max_points
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<? /* breadcrumb navigation */ ?>
+<div class="breadcrumb width-1200">
+ <? /* overview */ ?>
+ <a href="<?= $controller->assignment_solutions(['assignment_id' => $assignment_id, 'view' => $view]) ?>">
+ <?= htmlReady($assignment->test->title) ?>
+ </a>
+
+ &nbsp;/&nbsp;
+
+ <? /* previous solver */ ?>
+ <? if (isset($prev_solver)): ?>
+ <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $prev_solver['user_id'], 'view' => $view]) ?>">
+ <?= Icon::create('arr_1left')->asImg(['title' => _('Voriger Teilnehmer / vorige Teilnehmerin')]) ?>
+ </a>
+ <? else: ?>
+ <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der vorhergehenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?>
+ <? endif ?>
+
+ <? /* overview */ ?>
+ <a href="<?= $controller->assignment_solutions(['assignment_id' => $assignment_id, 'expand' => $solver_or_group_id, 'view' => $view]) ?>#row_<?= $solver_or_group_id ?>">
+ <?= htmlReady($solver_name) ?>
+ </a>
+
+ <? /* next solver */ ?>
+ <? if (isset($next_solver)): ?>
+ <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $next_solver['user_id'], 'view' => $view]) ?>">
+ <?= Icon::create('arr_1right')->asImg(['title' => _('Nächster Teilnehmer / nächste Teilnehmerin')]) ?>
+ </a>
+ <? else: ?>
+ <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der nachfolgenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?>
+ <? endif ?>
+
+ &nbsp;/&nbsp;
+
+ <? /* previous exercise */ ?>
+ <? if (isset($prev_exercise)): ?>
+ <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $prev_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>">
+ <?= Icon::create('arr_1left')->asImg(['title' => _('Vorige Aufgabe')]) ?>
+ </a>
+ <? else: ?>
+ <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der vorhergehenden Aufgaben bearbeitet')]) ?>
+ <? endif ?>
+
+ <? /* exercise name */ ?>
+ <?= htmlReady($exercise->title) ?>
+
+ <? /* next exercise */ ?>
+ <? if (isset($next_exercise)): ?>
+ <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $next_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>">
+ <?= Icon::create('arr_1right')->asImg(['title' => _('Nächste Aufgabe')]) ?>
+ </a>
+ <? else: ?>
+ <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der nachfolgenden Aufgaben bearbeitet')]) ?>
+ <? endif ?>
+</div>
+
+<form class="default width-1200" action="<?= $controller->store_correction() ?>" data-secure method="POST" enctype="multipart/form-data">
+ <?= CSRFProtection::tokenTag() ?>
+ <input type="hidden" name="solution_id" value="<?= $solution->id ?>">
+ <input type="hidden" name="exercise_id" value="<?= $exercise_id ?>">
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <input type="hidden" name="solver_id" value="<?= htmlReady($solver_id) ?>">
+ <input type="hidden" name="view" value="<?= htmlReady($view) ?>">
+ <input type="hidden" name="max_points" value="<?= $max_points ?>">
+
+ <?= Studip\Button::createAccept(_('Speichern'), 'store_solution', ['style' => 'display: none;']) ?>
+
+ <?= $this->render_partial('vips/exercises/correct_exercise') ?>
+
+ <fieldset>
+ <legend>
+ <?= sprintf(_('Bewertung der Lösung von „%s“'), htmlReady($solver_name)) ?>
+ <div style="float: right;">
+ <? if (isset($solution->grader_id)): ?>
+ <?= _('Manuell korrigiert') ?>
+ <? elseif ($solution->state): ?>
+ <?= _('Automatisch korrigiert') ?>
+ <? elseif ($solution->id): ?>
+ <?= _('Unkorrigiert') ?>
+ <? else: ?>
+ <?= _('Nicht abgegeben') ?>
+ <? endif ?>
+ </div>
+ </legend>
+
+ <? if ($solution->isArchived()): ?>
+ <? if ($solution->feedback) : ?>
+ <div class="label-text">
+ <?= _('Anmerkungen zur Lösung') ?>
+ </div>
+ <div class="vips_output">
+ <?= formatReady($solution->feedback) ?>
+ </div>
+ <? endif ?>
+
+ <?= $this->render_partial('vips/solutions/feedback_files_table') ?>
+
+ <div class="description">
+ <?= sprintf(_('Vergebene Punkte: %g von %g'), $solution->points, $max_points) ?>
+ </div>
+ <? else: ?>
+ <label>
+ <?= _('Anmerkungen zur Lösung') ?>
+ <textarea name="feedback" class="character_input size-l wysiwyg"><?= wysiwygReady($solution->feedback) ?></textarea>
+ </label>
+
+ <table class="default">
+ <? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs)): ?>
+ <thead>
+ <tr>
+ <th style="width: 50%;">
+ <?= _('Dateien zur Korrektur') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Größe') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Autor/-in') ?>
+ </th>
+ <th style="width: 15%;">
+ <?= _('Datum') ?>
+ </th>
+ <th class="actions">
+ <?= _('Aktionen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody class="dynamic_list">
+ <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?>
+ <tr class="dynamic_row">
+ <td>
+ <input type="hidden" name="file_ids[]" value="<?= htmlReady($file_ref->id) ?>">
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </td>
+ <td>
+ <?= relsize($file_ref->file->size) ?>
+ </td>
+ <td>
+ <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?>
+ </td>
+ <td class="actions">
+ <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ <? endif ?>
+
+ <tfoot>
+ <tr>
+ <td colspan="5">
+ <?= Studip\Button::create(_('Dateien zur Korrektur hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?>
+ <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span>
+ <?= tooltipIcon(sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576)) ?>
+ <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+
+ <? if ($solution->feedback != '' && !Studip\Markup::editorEnabled()): ?>
+ <div class="label-text">
+ <?= _('Textvorschau') ?>
+ </div>
+ <div class="vips_output">
+ <?= formatReady($solution->feedback) ?>
+ </div>
+ <? endif ?>
+
+ <label>
+ <span class="required"><?= sprintf(_('Vergebene Punkte (von %g)'), $max_points) ?></span>
+ <input name="reached_points" type="text" class="size-s" pattern="-?[0-9,.]+" data-message="<?= _('Bitte geben Sie eine Zahl ein') ?>"
+ value="<?= isset($solution->points) ? sprintf('%g', $solution->points) : '' ?>" required>
+ </label>
+ <? endif ?>
+ </fieldset>
+
+ <footer>
+ <? if ($solution->isArchived()): ?>
+ <?= Studip\Button::create(_('Als aktuelle Lösung speichern'), 'restore_solution', ['formaction' => $controller->url_for('vips/solutions/restore_solution')]) ?>
+ <? else: ?>
+ <?= Studip\Button::createAccept(_('Speichern'), 'store_solution') ?>
+ <? endif ?>
+
+ <label style="float: right; margin-top: 0.5ex;">
+ <input type="checkbox" name="corrected" value="1" <?= !$solution->grader_id || $solution->state ? 'checked' : ''?>>
+ <?= _('Lösung als korrigiert markieren') ?>
+ </label>
+ </footer>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/feedback_files.php b/app/views/vips/solutions/feedback_files.php
new file mode 100644
index 0000000..4630bb5
--- /dev/null
+++ b/app/views/vips/solutions/feedback_files.php
@@ -0,0 +1,20 @@
+<?php
+/**
+ * @var VipsSolution $solution
+ */
+?>
+<? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs) > 0): ?>
+ <div class="label-text">
+ <?= _('Dateien zur Korrektur:') ?>
+ </div>
+
+ <ul>
+ <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?>
+ <li>
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </li>
+ <? endforeach ?>
+ </ul>
+<? endif ?>
diff --git a/app/views/vips/solutions/feedback_files_table.php b/app/views/vips/solutions/feedback_files_table.php
new file mode 100644
index 0000000..dff9869
--- /dev/null
+++ b/app/views/vips/solutions/feedback_files_table.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * @var VipsSolution $solution
+ */
+?>
+<? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs)): ?>
+ <div class="label-text">
+ <?= _('Dateien zur Korrektur') ?>
+ </div>
+
+ <table class="default">
+ <thead>
+ <tr>
+ <th style="width: 50%;">
+ <?= _('Name') ?>
+ </th>
+ <th style="width: 10%;">
+ <?= _('Größe') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Autor/-in') ?>
+ </th>
+ <th style="width: 20%;">
+ <?= _('Datum') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?>
+ <tr>
+ <td>
+ <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>">
+ <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?>
+ <?= htmlReady($file_ref->name) ?>
+ </a>
+ </td>
+ <td>
+ <?= relsize($file_ref->file->size) ?>
+ </td>
+ <td>
+ <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+ </table>
+<? endif ?>
diff --git a/app/views/vips/solutions/gradebook_dialog.php b/app/views/vips/solutions/gradebook_dialog.php
new file mode 100644
index 0000000..9ebc684
--- /dev/null
+++ b/app/views/vips/solutions/gradebook_dialog.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var int $assignment_id
+ * @var string $view
+ * @var string $expand
+ * @var VipsAssignment $assignment
+ * @var int $weights
+ */
+?>
+<form class="default gradebook-lecturer-weights" action="<?= $controller->gradebook_publish() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>">
+ <input type="hidden" name="view" value="<?= htmlReady($view) ?>">
+ <input type="hidden" name="expand" value="<?= htmlReady($expand) ?>">
+
+ <label>
+ <span class="required"><?= _('Name im Gradebook') ?></span>
+ <input name="title" type="text" required value="<?= htmlReady($assignment->test->title) ?>">
+ </label>
+
+ <div hidden>
+ <input type="number" disabled value="<?= $weights ?>">
+ <output></output>
+ </div>
+
+ <label class="gradebook-weight">
+ <span class="required"><?= _('Gewichtung') ?></span>
+ <div>
+ <input name="weight" type="number" required min="0" value="1">
+ <output><?= round(100 / ($weights + 1), 1) ?></output>
+ </div>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Eintragen'), 'publish') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/participants_overview.php b/app/views/vips/solutions/participants_overview.php
new file mode 100644
index 0000000..72bd316
--- /dev/null
+++ b/app/views/vips/solutions/participants_overview.php
@@ -0,0 +1,225 @@
+<?php
+/**
+ * @var string $display
+ * @var Vips_SolutionsController $controller
+ * @var string $course_id
+ * @var string $view
+ * @var array $items
+ * @var bool $has_grades
+ * @var string $sort
+ * @var bool $desc
+ * @var array $overall
+ * @var array $participants
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<table class="default">
+ <caption>
+ <? if ($display === 'points') : ?>
+ <?= _('Punkteübersicht') ?>
+
+ <span class="actions">
+ <form action="<?= $controller->participants_overview() ?>">
+ <input type="hidden" name="cid" value="<?= htmlReady($course_id) ?>">
+ <input type="hidden" name="display" value="points">
+
+ <label>
+ <?= _('Anzeigefilter:') ?>
+
+ <select name="view" class="submit-upon-select">
+ <option value="">
+ <?= _('Übungen und Klausuren') ?>
+ </option>
+ <option value="selftest" <?= $view === 'selftest' ? 'selected' : '' ?>>
+ <?= _('Selbsttests') ?>
+ </option>
+ </select>
+ </label>
+ </form>
+ </span>
+ <? else : ?>
+ <?= _('Notenübersicht') ?>
+ <? endif ?>
+ </caption>
+
+ <colgroup>
+ <col>
+
+ <? if (count($items['tests']) > 0) : ?>
+ <col style="border-left: 1px dotted gray;">
+ <? endif ?>
+ <? if (count($items['tests']) > 1) : ?>
+ <col span="<?= count($items['tests']) - 1 ?>">
+ <? endif ?>
+
+ <? if (count($items['blocks']) > 0) : ?>
+ <col style="border-left: 1px dotted gray;">
+ <? endif ?>
+ <? if (count($items['blocks']) > 1) : ?>
+ <col span="<?= count($items['blocks']) - 1 ?>">
+ <? endif ?>
+
+ <? if (count($items['exams']) > 0) : ?>
+ <col style="border-left: 1px dotted gray;">
+ <? endif ?>
+ <? if (count($items['exams']) > 1) : ?>
+ <col span="<?= count($items['exams']) - 1 ?>">
+ <? endif ?>
+
+ <col style="border-left: 1px dotted gray;">
+ <? if ($display == 'weighting' && $has_grades) : ?>
+ <col>
+ <? endif ?>
+ </colgroup>
+
+ <thead>
+ <tr>
+ <th><? /* participant */ ?></th>
+
+ <? if (count($items['tests']) > 0) : ?>
+ <th colspan="<?= count($items['tests']) ?>" style="text-align: center;">
+ <?= $view === 'selftest' ? _('Selbsttests') : _('Übungen') ?>
+ </th>
+ <? endif ?>
+
+ <? if (count($items['blocks']) > 0) : ?>
+ <th colspan="<?= count($items['blocks']) ?>" style="text-align: center;">
+ <?= _('Blöcke') ?>
+ </th>
+ <? endif ?>
+
+ <? if (count($items['exams']) > 0) : ?>
+ <th colspan="<?= count($items['exams']) ?>" style="text-align: center;">
+ <?= _('Klausuren') ?>
+ </th>
+ <? endif ?>
+
+ <th><? /* sum */ ?></th>
+ <? if ($display == 'weighting' && $has_grades) : ?>
+ <th><? /* grade */ ?></th>
+ <? endif ?>
+ </tr>
+
+ <tr class="sortable">
+ <th class="nowrap <?= $controller->sort_class($sort === 'name', $desc) ?>">
+ <a href="<?= $controller->participants_overview(['display' => $display, 'view' => $view, 'sort' => 'name', 'desc' => $sort === 'name' && !$desc]) ?>">
+ <?= _('Nachname, Vorname') ?>
+ </a>
+ </th>
+
+ <? foreach ($items as $category => $list) : ?>
+ <? foreach ($list as $item) : ?>
+ <th class="gradebook_header" title="<?= htmlReady($item['tooltip']) ?>">
+ <?= htmlReady($item['name']) ?>
+ </th>
+ <? endforeach ?>
+ <? endforeach ?>
+
+ <th class="nowrap <?= $controller->sort_class($sort === 'sum', $desc) ?>">
+ <a href="<?= $controller->participants_overview(['display' => $display, 'view' => $view, 'sort' => 'sum', 'desc' => $sort !== 'sum' || !$desc]) ?>">
+ <?= _('Summe') ?>
+ </a>
+ </th>
+
+ <? if ($display == 'weighting' && $has_grades) : ?>
+ <th class="nowrap <?= $controller->sort_class($sort === 'grade', $desc) ?>">
+ <a href="<?= $controller->participants_overview(['display' => $display, 'sort' => 'grade', 'desc' => $sort !== 'grade' || !$desc]) ?>">
+ <?= _('Note') ?>
+ </a>
+ </th>
+ <? endif ?>
+ </tr>
+
+ <? if ($display == 'points' || $this->overall['weighting']): ?>
+ <tr class="smaller" style="background-color: #D1D1D1;">
+ <td>
+ <? if ($display == 'points') : ?>
+ <?= _('Maximalpunktzahl') ?>
+ <? else : ?>
+ <?= _('Gewichtung') ?>
+ <? endif ?>
+ </td>
+
+ <? foreach ($items as $category => $list) : ?>
+ <? foreach ($list as $item) : ?>
+ <td style="text-align: right; white-space: nowrap;">
+ <? if ($display == 'points') : ?>
+ <?= sprintf('%g', $item['points']) ?>
+ <? else : ?>
+ <?= sprintf('%d %%', round($item['weighting'], 1)) ?>
+ <? endif ?>
+ </td>
+ <? endforeach ?>
+ <? endforeach ?>
+
+ <td style="text-align: right; white-space: nowrap;">
+ <? if ($display == 'points') : ?>
+ <?= sprintf('%g', $overall['points']) ?>
+ <? else : ?>
+ 100 %
+ <? endif ?>
+ </td>
+
+ <? if ($display == 'weighting' && $has_grades) : ?>
+ <td></td>
+ <? endif ?>
+ </tr>
+ <? endif ?>
+ </thead>
+
+ <tbody>
+ <? /* each participant */ ?>
+ <? foreach ($participants as $p) : ?>
+ <tr>
+ <td>
+ <?= htmlReady($p['name']) ?>
+ </td>
+
+ <? foreach ($items as $category => $list) : ?>
+ <? foreach ($list as $item) : ?>
+ <td style="text-align: right; white-space: nowrap;">
+ <? if ($display == 'points') : ?>
+ <? if (isset($p['items'][$category][$item['id']]['points'])) : ?>
+ <?= sprintf('%.1f', $p['items'][$category][$item['id']]['points']) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ <? else : ?>
+ <? if (isset($p['items'][$category][$item['id']]['percent'])) : ?>
+ <?= sprintf('%.1f %%', $p['items'][$category][$item['id']]['percent']) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ <? endif ?>
+ </td>
+ <? endforeach ?>
+ <? endforeach ?>
+
+ <td style="text-align: right; white-space: nowrap;">
+ <? if ($display == 'points') : ?>
+ <? if (isset($p['overall']['points'])): ?>
+ <?= sprintf('%.1f', $p['overall']['points']) ?>
+ <? else: ?>
+ &ndash;
+ <? endif ?>
+ <? else : ?>
+ <? if (isset($p['overall']['weighting'])): ?>
+ <?= sprintf('%.1f %%', $p['overall']['weighting']) ?>
+ <? else: ?>
+ &ndash;
+ <? endif ?>
+ <? endif ?>
+ </td>
+
+ <? if ($display == 'weighting' && $has_grades) : ?>
+ <td style="text-align: right;">
+ <?= htmlReady($p['grade']) ?>
+ </td>
+ <? endif ?>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+</table>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/show_assignment_log.php b/app/views/vips/solutions/show_assignment_log.php
new file mode 100644
index 0000000..0b1b5c8
--- /dev/null
+++ b/app/views/vips/solutions/show_assignment_log.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * @var User $user
+ * @var array $logs
+ */
+?>
+<table class="default" style="min-width: 960px;">
+ <caption>
+ <?= sprintf(_('Abgabeprotokoll für %s, %s (%s)'), $user->nachname, $user->vorname, $user->username) ?>
+ </caption>
+
+ <thead>
+ <tr>
+ <th>
+ <?= _('Ereignis') ?>
+ </th>
+ <th>
+ <?= _('Zeit') ?>
+ </th>
+ <th>
+ <?= _('IP-Adresse') ?>
+ </th>
+ <th>
+ <?= _('Rechnername') ?>
+ </th>
+ <th>
+ <?= _('Sitzungs-ID') ?>
+ <?= tooltipIcon(_('Die Sitzungs-ID wird beim Login in Stud.IP vergeben und bleibt bis zum Abmelden gültig.')) ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($logs as $log): ?>
+ <tr>
+ <td class="<?= $log['archived'] ? 'quiet' : '' ?>">
+ <?= htmlReady($log['label']) ?>
+ </td>
+ <td>
+ <?= date('d.m.Y, H:i:s', strtotime($log['time'])) ?>
+ </td>
+ <td>
+ <?= htmlReady($log['ip_address']) ?>
+ </td>
+ <td>
+ <? if ($log['ip_address']): ?>
+ <?= htmlReady($controller->gethostbyaddr($log['ip_address'])) ?>
+ <? endif ?>
+ </td>
+ <td>
+ <?= htmlReady($log['session_id']) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ </tbody>
+</table>
diff --git a/app/views/vips/solutions/solution_color_tooltip.php b/app/views/vips/solutions/solution_color_tooltip.php
new file mode 100644
index 0000000..41c4337
--- /dev/null
+++ b/app/views/vips/solutions/solution_color_tooltip.php
@@ -0,0 +1,4 @@
+<?= sprintf(_('%sTürkis dargestellte Aufgaben%s wurden automatisch und sicher korrigiert.'), '<span class="solution-autocorrected">', '</span>') ?><br>
+<?= sprintf(_('%sGrün dargestellte Aufgaben%s wurden von Hand korrigiert.'), '<span class="solution-corrected">', '</span>') ?><br>
+<?= sprintf(_('%sRot dargestellte Aufgaben%s wurden noch nicht fertig korrigiert.'), '<span class="solution-uncorrected">', '</span>') ?><br>
+<?= sprintf(_('%sAusgegraute Aufgaben%s wurden nicht bearbeitet.'), '<span class="solution-none">', '</span>') ?><br>
diff --git a/app/views/vips/solutions/statistics.php b/app/views/vips/solutions/statistics.php
new file mode 100644
index 0000000..d0327b3
--- /dev/null
+++ b/app/views/vips/solutions/statistics.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * @var array $assignments
+ * @var Vips_SolutionsController $controller
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<? if (count($assignments)) : ?>
+ <table class="default">
+ <caption>
+ <?= _('Statistik der Aufgabenblätter') ?>
+ </caption>
+
+ <thead>
+ <tr>
+ <th>
+ <?= _('Titel / Aufgabe') ?>
+ </th>
+ <th style="text-align: right;">
+ <?= _('Erreichbare Punkte') ?>
+ </th>
+ <th style="text-align: right;">
+ <?= _('Durchschn. Punkte') ?>
+ </th>
+ <th style="text-align: right;">
+ <?= _('Korrekte Lösungen') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <? foreach ($assignments as $assignment): ?>
+ <? if (count($assignment['exercises'])): ?>
+ <tr style="font-weight: bold;">
+ <td style="width: 70%;">
+ <a href="<?= $controller->link_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment['assignment']->id]) ?>">
+ <?= $assignment['assignment']->getTypeIcon() ?>
+ <?= htmlReady($assignment['assignment']->test->title) ?>
+ </a>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $assignment['points']) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $assignment['average']) ?>
+ </td>
+ <td>
+ </td>
+ </tr>
+
+ <? foreach ($assignment['exercises'] as $exercise): ?>
+ <tr>
+ <td style="width: 70%; padding-left: 2em;">
+ <a href="<?= $controller->link_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment['assignment']->id, 'exercise_id' => $exercise['id']]) ?>">
+ <?= $exercise['position'] ?>. <?= htmlReady($exercise['name']) ?>
+ </a>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $exercise['points']) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $exercise['average']) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f %%', $exercise['correct'] * 100) ?>
+ </td>
+ </tr>
+
+ <? if (count($exercise['items']) > 1): ?>
+ <? foreach ($exercise['items'] as $index => $item): ?>
+ <tr>
+ <td style="width: 70%; padding-left: 4em;">
+ <?= sprintf(_('Item %d'), $index + 1) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $exercise['points'] / count($exercise['items'])) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f', $item) ?>
+ </td>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f %%', $exercise['items_c'][$index] * 100) ?>
+ </td>
+ </tr>
+ <? endforeach ?>
+ <? endif ?>
+ <? endforeach ?>
+ <? endif ?>
+ <? endforeach ?>
+ </tbody>
+ </table>
+<? endif ?>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/student_assignment_solutions.php b/app/views/vips/solutions/student_assignment_solutions.php
new file mode 100644
index 0000000..5a4238b
--- /dev/null
+++ b/app/views/vips/solutions/student_assignment_solutions.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * @var VipsAssignment $assignment
+ * @var string $user_id
+ * @var int $released
+ * @var Vips_SolutionsController $controller
+ * @var string $feedback
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<h1 class="width-1200">
+ <?= htmlReady($assignment->test->title) ?>
+</h1>
+
+<div class="width-1200" style="margin: 10px 0;">
+ <?= formatReady($assignment->test->description) ?>
+</div>
+
+<table class="default dynamic_list collapsable width-1200">
+ <caption>
+ <?= _('Ergebnisse des Aufgabenblatts') ?>
+ </caption>
+
+ <thead>
+ <tr>
+ <th style="width: 2em;">
+ </th>
+
+ <th style="width: 60%;">
+ <?= _('Aufgaben') ?>
+ </th>
+
+ <th style="width: 10%; text-align: center;">
+ <?= _('Bearbeitet') ?>
+ </th>
+
+ <th style="width: 15%; text-align: center;">
+ <?= _('Erreichte Punkte') ?>
+ </th>
+
+ <th style="width: 15%; text-align: center;">
+ <?= _('Max. Punkte') ?>
+ </th>
+ </tr>
+ </thead>
+
+ <? foreach ($assignment->getExerciseRefs($user_id) as $exercise_ref) : ?>
+ <? $solution = $assignment->getSolution($user_id, $exercise_ref->task_id); ?>
+ <tbody class="collapsed">
+ <tr class="header-row">
+ <td class="dynamic_counter" style="text-align: right;">
+ </td>
+ <td>
+ <? if ($released >= VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?>
+ <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $exercise_ref->task_id]) ?>">
+ <?= htmlReady($exercise_ref->exercise->title) ?>
+ </a>
+ <? elseif ($released == VipsAssignment::RELEASE_STATUS_COMMENTS && $solution && $solution->hasFeedback()) : ?>
+ <a class="toggler" href="#">
+ <?= htmlReady($exercise_ref->exercise->title) ?>
+ <a>
+ <? else: ?>
+ <?= htmlReady($exercise_ref->exercise->title) ?>
+ <? endif ?>
+ </td>
+ <td style="text-align: center;">
+ <? if ($solution): ?>
+ <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?>
+ <? else : ?>
+ <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?>
+ <? endif ?>
+ </td>
+ <td style="text-align: center;">
+ <?= sprintf('%g', $solution ? $solution->points : 0) ?>
+ </td>
+ <td style="text-align: center;">
+ <?= sprintf('%g', $exercise_ref->points) ?>
+ </td>
+ </tr>
+
+ <? if ($released == VipsAssignment::RELEASE_STATUS_COMMENTS && $solution && $solution->hasFeedback()): ?>
+ <tr>
+ <td>
+ </td>
+ <td colspan="4">
+ <?= formatReady($solution->feedback) ?>
+ <?= $this->render_partial('vips/solutions/feedback_files', compact('solution')) ?>
+ </td>
+ </tr>
+ <? endif ?>
+ </tbody>
+ <? endforeach ?>
+
+ <tfoot>
+ <tr style="font-weight: bold;">
+ <td>
+ </td>
+
+ <td colspan="2" style="padding: 5px;">
+ <?= _('Gesamtpunktzahl') ?>
+ </td>
+
+ <td style="text-align: center;">
+ <?= sprintf('%g', $assignment->getUserPoints($user_id)) ?>
+ </td>
+
+ <td style="text-align: center;">
+ <?= sprintf('%g', $assignment->test->getTotalPoints()) ?>
+ </td>
+ </tr>
+ </tfoot>
+</table>
+
+<? if ($released >= VipsAssignment::RELEASE_STATUS_COMMENTS && $feedback != ''): ?>
+ <div class="width-1200">
+ <h3>
+ <?= _('Kommentar zur Bewertung') ?>
+ </h3>
+
+ <?= formatReady($feedback) ?>
+ </div>
+<? endif ?>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/student_grade.php b/app/views/vips/solutions/student_grade.php
new file mode 100644
index 0000000..421434f
--- /dev/null
+++ b/app/views/vips/solutions/student_grade.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * @var bool $use_weighting
+ * @var array $participants
+ * @var array $items
+ * @var string $user_id
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<table class="default">
+ <caption>
+ <?= _('Note') ?>
+ </caption>
+
+ <thead>
+ <tr>
+ <th>
+ <?= _('Titel') ?>
+ </th>
+ <th colspan="3" style="text-align: center; width: 1%;">
+ <?= _('Punkte') ?>
+ </th>
+ <th style="text-align: right;">
+ <?= _('Prozent') ?>
+ </th>
+ <? if ($use_weighting) : ?>
+ <th style="text-align: right;">
+ <?= _('Gewichtung') ?>
+ </th>
+ <? endif ?>
+ </tr>
+ </thead>
+
+ <? /* here, $participants contains only one entry */ ?>
+ <? foreach ($participants as $me) : ?>
+
+ <tbody>
+ <? foreach (['tests', 'blocks', 'exams'] as $category) : ?>
+ <? foreach ($items[$category] as $item) : ?>
+ <? if ($item['item']->isVisible($user_id) && $item['weighting']) : ?>
+ <tr>
+ <td>
+ <?= htmlReady($item['name']) ?>
+ </td>
+
+ <td style="text-align: right;">
+ <? if (isset($me['items'][$category][$item['id']]['points'])) : ?>
+ <?= sprintf('%g', $me['items'][$category][$item['id']]['points']) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <td style="text-align: center;">
+ /
+ </td>
+
+ <td style="text-align: right;">
+ <?= sprintf('%g', $item['points']) ?>
+ </td>
+
+ <td style="text-align: right;">
+ <? if (isset($me['items'][$category][$item['id']]['percent'])) : ?>
+ <?= sprintf('%.1f %%', $me['items'][$category][$item['id']]['percent']) ?>
+ <? else : ?>
+ &ndash;
+ <? endif ?>
+ </td>
+
+ <? if ($use_weighting) : ?>
+ <td style="text-align: right;">
+ <?= sprintf('%.1f %%', $item['weighting']) ?>
+ </td>
+ <? endif ?>
+ </tr>
+ <? endif ?>
+ <? endforeach ?>
+ <? endforeach ?>
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colspan="4" style="padding: 5px;">
+ <?= _('Prozent, gesamt') ?>
+ </td>
+ <td style="padding: 5px; text-align: right;">
+ <?= sprintf('%.1f %%', $me['overall']['weighting']) ?>
+ </td>
+ <? if ($use_weighting) : ?>
+ <td></td>
+ <? endif ?>
+ </tr>
+
+ <tr style="font-weight: bold;">
+ <td colspan="<?= $use_weighting ? 6 : 5 ?>" style="text-align: center;">
+ <?= _('Note:') ?>
+ <?= htmlReady($me['grade']) ?>
+ <? if ($me['grade_comment'] != '') : ?>
+ (<?= htmlReady($me['grade_comment']) ?>)
+ <? endif ?>
+ </td>
+ </tr>
+ </tfoot>
+
+ <? endforeach ?>
+</table>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/app/views/vips/solutions/update_released_dialog.php b/app/views/vips/solutions/update_released_dialog.php
new file mode 100644
index 0000000..a8a88ad
--- /dev/null
+++ b/app/views/vips/solutions/update_released_dialog.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var int[] $assignment_ids
+ * @var int $default
+ */
+?>
+<form class="default" action="<?= $controller->update_released() ?>" method="POST">
+ <?= CSRFProtection::tokenTag() ?>
+ <? foreach ($assignment_ids as $assignment_id): ?>
+ <input type="hidden" name="assignment_ids[]" value="<?= $assignment_id ?>">
+ <? endforeach ?>
+
+ <label>
+ <input type="radio" name="released" value="0" <?= $default == VipsAssignment::RELEASE_STATUS_NONE ? 'checked' : '' ?>>
+ <?= _('Nichts') ?>
+ </label>
+
+ <label>
+ <input type="radio" name="released" value="1" <?= $default == VipsAssignment::RELEASE_STATUS_POINTS ? 'checked' : '' ?>>
+ <?= _('Vergebene Punkte') ?>
+ </label>
+
+ <label>
+ <input type="radio" name="released" value="2" <?= $default == VipsAssignment::RELEASE_STATUS_COMMENTS ? 'checked' : '' ?>>
+ <?= _('Punkte und Kommentare') ?>
+ </label>
+
+ <label>
+ <input type="radio" name="released" value="3" <?= $default == VipsAssignment::RELEASE_STATUS_CORRECTIONS ? 'checked' : '' ?>>
+ <?= _('… zusätzlich Aufgaben und Korrektur') ?>
+ </label>
+
+ <label>
+ <input type="radio" name="released" value="4" <?= $default == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS ? 'checked' : '' ?>>
+ <?= _('… zusätzlich Musterlösungen') ?>
+ </label>
+
+ <footer data-dialog-button>
+ <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
+ </footer>
+</form>
diff --git a/app/views/vips/solutions/view_solution.php b/app/views/vips/solutions/view_solution.php
new file mode 100644
index 0000000..bac4a89
--- /dev/null
+++ b/app/views/vips/solutions/view_solution.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * @var Vips_SolutionsController $controller
+ * @var VipsAssignment $assignment
+ * @var Exercise $exercise
+ * @var VipsSolution $solution
+ * @var float $max_points
+ */
+?>
+<? setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8') ?>
+
+<div class="breadcrumb width-1200">
+ <div style="display: inline-block; width: 20%;">
+ <? if (isset($prev_exercise_id)) : ?>
+ <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $prev_exercise_id]) ?>">
+ <?= Icon::create('arr_1left') ?>
+ <?= _('Vorige Aufgabe') ?>
+ </a>
+ <? endif ?>
+ </div><!--
+ --><div style="display: inline-block; text-align: center; width: 60%;">
+ <a href="<?= $controller->student_assignment_solutions(['assignment_id' => $assignment->id]) ?>">
+ &bull; <?= htmlReady($assignment->test->title) ?> &bull;
+ </a>
+ </div><!--
+ --><div style="display: inline-block; text-align: right; width: 20%;">
+ <? if (isset($next_exercise_id)) : ?>
+ <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $next_exercise_id]) ?>">
+ <?= _('Nächste Aufgabe') ?>
+ <?= Icon::create('arr_1right') ?>
+ </a>
+ <? endif ?>
+ </div>
+</div>
+
+<form class="default width-1200">
+ <?= $this->render_partial('vips/exercises/correct_exercise') ?>
+
+ <fieldset>
+ <legend>
+ <?= sprintf(_('Bewertung der Aufgabe &bdquo;%s&ldquo;'), htmlReady($exercise->title)) ?>
+ <div style="float: right;">
+ <? if ($solution->state): ?>
+ <?= _('Korrigiert') ?>
+ <? elseif ($solution->id): ?>
+ <?= _('Unkorrigiert') ?>
+ <? else: ?>
+ <?= _('Nicht abgegeben') ?>
+ <? endif ?>
+ </div>
+ </legend>
+
+ <? if ($solution->feedback != '') : ?>
+ <div class="label-text">
+ <?= _('Anmerkung des Korrektors') ?>
+
+ <? if (isset($solution->grader_id) && $assignment->type === 'practice') : ?>
+ <? $corrector_full_name = get_fullname($solution->grader_id); ?>
+ (<a href="<?= URLHelper::getLink('dispatch.php/messages/write', ['rec_uname' => get_username($solution->grader_id)]) ?>"
+ title="<?= htmlReady(sprintf(_('Nachricht an „%s“ schreiben'), $corrector_full_name)) ?>" data-dialog><?= htmlReady($corrector_full_name) ?></a>)
+ <? endif ?>
+ </div>
+
+ <div class="vips_output">
+ <?= formatReady($solution->feedback) ?>
+ </div>
+ <? endif ?>
+
+ <?= $this->render_partial('vips/solutions/feedback_files_table') ?>
+
+ <div class="description">
+ <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?>
+ </div>
+ </fieldset>
+</form>
+
+<? setlocale(LC_NUMERIC, 'C') ?>
diff --git a/composer.json b/composer.json
index 410e14d..ea95764 100644
--- a/composer.json
+++ b/composer.json
@@ -51,6 +51,7 @@
"lib/models/",
"lib/models/calendar/",
"lib/models/resources/",
+ "lib/models/vips/",
"lib/modules/",
"lib/navigation/",
"lib/plugins/core/",
@@ -100,6 +101,7 @@
"ext-mbstring": "*",
"ext-dom": "*",
"ext-iconv": "*",
+ "ext-simplexml": "*",
"opis/json-schema": "2.3.0",
"slim/slim": "4.13.0",
"php-di/php-di": "7.0.0",
diff --git a/db/migrations/6.0.40_add_vips_module.php b/db/migrations/6.0.40_add_vips_module.php
new file mode 100644
index 0000000..8fc50c8
--- /dev/null
+++ b/db/migrations/6.0.40_add_vips_module.php
@@ -0,0 +1,485 @@
+<?php
+
+class AddVipsModule extends Migration
+{
+ public function description()
+ {
+ return 'initial database setup for Vips';
+ }
+
+ public function up()
+ {
+ $db = DBManager::get();
+
+ // install as core plugin
+ $sql = "INSERT INTO plugins (pluginclassname, pluginname, plugintype, enabled, navigationpos)
+ VALUES ('VipsModule', 'Aufgaben', 'StudipModule,SystemPlugin,PrivacyPlugin,Courseware\\\\CoursewarePlugin', 'yes', 1)";
+ $db->exec($sql);
+ $id = $db->lastInsertId();
+
+ $sql = "INSERT INTO roles_plugins (roleid, pluginid)
+ SELECT roleid, ? FROM roles WHERE `system` = 'y'";
+ $db->execute($sql, [$id]);
+
+ // copy tool activations from Vips plugin
+ $sql = "INSERT INTO tools_activated
+ SELECT range_id, range_type, ?, position, metadata, mkdate, chdate FROM tools_activated
+ WHERE plugin_id = (SELECT pluginid FROM plugins WHERE pluginname = 'Vips')";
+ $db->execute($sql, [$id]);
+
+ // update etask tables
+ $sql = "ALTER TABLE etask_assignments
+ CHANGE type type varchar(64) COLLATE latin1_bin NOT NULL,
+ CHANGE active active tinyint UNSIGNED NOT NULL DEFAULT 1,
+ ADD weight float NOT NULL DEFAULT 0 AFTER active,
+ ADD block_id int DEFAULT NULL AFTER weight,
+ ADD KEY test_id (test_id),
+ ADD KEY range_id (range_id)";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_assignment_attempts
+ ADD ip_address varchar(39) COLLATE latin1_bin NOT NULL AFTER end,
+ CHANGE options options text DEFAULT NULL,
+ ADD UNIQUE KEY assignment_id (assignment_id,user_id)";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_responses
+ CHANGE response response mediumtext NOT NULL,
+ ADD student_comment text DEFAULT NULL AFTER response,
+ ADD ip_address varchar(39) COLLATE latin1_bin NOT NULL AFTER student_comment,
+ ADD commented_solution text DEFAULT NULL AFTER feedback,
+ ADD KEY assignment_id (assignment_id,task_id,user_id),
+ ADD KEY user_id (user_id),
+ ADD KEY task_id (task_id)";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_tasks
+ CHANGE type type varchar(64) COLLATE latin1_bin NOT NULL,
+ CHANGE description description mediumtext NOT NULL,
+ CHANGE task task mediumtext NOT NULL,
+ ADD KEY user_id (user_id)";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_tests
+ CHANGE description description mediumtext NOT NULL,
+ CHANGE options options text DEFAULT NULL,
+ ADD KEY user_id (user_id)";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_test_tasks
+ ADD part int NOT NULL DEFAULT 0 AFTER position,
+ ADD KEY task_id (task_id)";
+ $db->exec($sql);
+
+ // add new tables
+ $sql = "CREATE TABLE etask_blocks (
+ id int NOT NULL AUTO_INCREMENT,
+ name varchar(255) NOT NULL,
+ range_id char(32) COLLATE latin1_bin NOT NULL,
+ group_id char(32) COLLATE latin1_bin DEFAULT NULL,
+ visible tinyint NOT NULL DEFAULT 1,
+ weight float DEFAULT NULL,
+ PRIMARY KEY (id),
+ KEY range_id (range_id)
+ )";
+ $db->exec($sql);
+
+ $sql = "CREATE TABLE etask_group_members (
+ group_id char(32) COLLATE latin1_bin NOT NULL,
+ user_id char(32) COLLATE latin1_bin NOT NULL,
+ start int unsigned NOT NULL,
+ end int unsigned DEFAULT NULL,
+ PRIMARY KEY (group_id,user_id,start),
+ KEY user_id (user_id)
+ )";
+ $db->exec($sql);
+
+ // add settings (unless already present)
+ $sql = 'INSERT IGNORE INTO `config` (`field`, `value`, `type`, `range`, `mkdate`, `chdate`, `description`)
+ VALUES (:name, :value, :type, :range, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)';
+ $statement = DBManager::get()->prepare($sql);
+ $statement->execute([
+ ':name' => 'VIPS_COURSE_GRADES',
+ ':description' => 'Kursbezogenes Schema zur Notenverteilung in Vips',
+ ':range' => 'course',
+ ':type' => 'array',
+ ':value' => '[]'
+ ]);
+ $statement->execute([
+ ':name' => 'VIPS_EXAM_RESTRICTIONS',
+ ':description' => 'Sperrt während einer Klausur andere Bereiche von Stud.IP für die Teilnehmenden',
+ ':range' => 'global',
+ ':type' => 'boolean',
+ ':value' => '0'
+ ]);
+ $statement->execute([
+ ':name' => 'VIPS_EXAM_ROOMS',
+ ':description' => 'Zentral verwaltete IP-Adressen für PC-Räume',
+ ':range' => 'global',
+ ':type' => 'array',
+ ':value' => '[]'
+ ]);
+ $statement->execute([
+ ':name' => 'VIPS_EXAM_TERMS',
+ ':description' => 'Teilnahmebedingungen, die vor Beginn einer Klausur zu akzeptieren sind',
+ ':range' => 'global',
+ ':type' => 'string',
+ ':value' => ''
+ ]);
+
+ // copy data from Vips plugin
+ $result = $db->query("SHOW TABLES LIKE 'vips_assignment'");
+
+ if ($result->rowCount() > 0) {
+ $this->copyVipsData();
+ }
+ }
+
+ private function copyVipsData()
+ {
+ $db = DBManager::get();
+ $now = time();
+
+ $task_id = [];
+ $test_id = [];
+ $assignment_id = [];
+ $response_id = [];
+ $group_id = [];
+ $folder_id = [];
+
+ $task_mapping = [
+ 'sc_exercise' => 'SingleChoiceTask',
+ 'mc_exercise' => 'MultipleChoiceTask',
+ 'mco_exercise' => 'MatrixChoiceTask',
+ 'lt_exercise' => 'TextLineTask',
+ 'tb_exercise' => 'TextTask',
+ 'cloze_exercise' => 'ClozeTask',
+ 'rh_exercise' => 'MatchingTask',
+ 'seq_exercise' => 'SequenceTask'
+ ];
+
+ // etask_tasks
+ $sql = 'INSERT INTO etask_tasks (type, title, description, task, user_id, mkdate, chdate, options)
+ VALUES (:type, :title, :description, :task, :user_id, :mkdate, :chdate, :options)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_exercise');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ $values = [
+ 'type' => $task_mapping[$row['type']] ?? $row['type'],
+ 'title' => $row['title'],
+ 'description' => $row['description'],
+ 'task' => $row['task_json'],
+ 'user_id' => $row['user_id'],
+ 'mkdate' => strtotime($row['created']),
+ 'chdate' => $now,
+ 'options' => $row['options'] ?: '[]'
+ ];
+ $stmt->execute($values);
+ $task_id[$row['id']] = $db->lastInsertId();
+ }
+
+ // etask_tests
+ $sql = 'INSERT INTO etask_tests (title, description, user_id, mkdate, chdate, options)
+ VALUES (:title, :description, :user_id, :mkdate, :chdate, :options)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_test');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ $values = [
+ 'title' => $row['title'],
+ 'description' => $row['description'],
+ 'user_id' => $row['user_id'],
+ 'mkdate' => strtotime($row['created']),
+ 'chdate' => $now,
+ 'options' => null
+ ];
+ $stmt->execute($values);
+ $test_id[$row['id']] = $db->lastInsertId();
+ }
+
+ // etask_test_tasks
+ $sql = 'INSERT INTO etask_test_tasks (test_id, task_id, position, part, points, options, mkdate, chdate)
+ VALUES (:test_id, :task_id, :position, :part, :points, :options, :mkdate, :chdate)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_exercise_ref');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($test_id[$row['test_id']]) && isset($task_id[$row['exercise_id']])) {
+ $values = [
+ 'test_id' => $test_id[$row['test_id']],
+ 'task_id' => $task_id[$row['exercise_id']],
+ 'position' => $row['position'],
+ 'part' => $row['part'],
+ 'points' => $row['points'],
+ 'mkdate' => $now,
+ 'chdate' => $now,
+ 'options' => '',
+ ];
+ $stmt->execute($values);
+ }
+ }
+
+ // etask_assignments
+ $sql = 'INSERT INTO etask_assignments (test_id, range_type, range_id, type, start, end, active, weight, block_id, options, mkdate, chdate)
+ VALUES (:test_id, :range_type, :range_id, :type, :start, :end, :active, :weight, :block_id, :options, :mkdate, :chdate)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_assignment');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($test_id[$row['test_id']])) {
+ $options = json_decode($row['options'], true);
+ unset($options['shuffle_answers']);
+ unset($options['printable']);
+
+ $values = [
+ 'test_id' => $test_id[$row['test_id']],
+ 'range_type' => $row['context'],
+ 'range_id' => $row['course_id'],
+ 'type' => $row['type'],
+ 'start' => strtotime($row['start']),
+ 'end' => strtotime($row['end']),
+ 'active' => $row['active'],
+ 'weight' => $row['weight'],
+ 'block_id' => $row['block_id'],
+ 'options' => json_encode($options),
+ 'mkdate' => $now,
+ 'chdate' => $now
+ ];
+ $stmt->execute($values);
+ $assignment_id[$row['id']] = $db->lastInsertId();
+ }
+ }
+
+ // etask_assignment_attempts
+ $sql = 'INSERT INTO etask_assignment_attempts (assignment_id, user_id, start, end, ip_address, options, mkdate, chdate)
+ VALUES (:assignment_id, :user_id, :start, :end, :ip_address, :options, :mkdate, :chdate)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_assignment_attempt');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($assignment_id[$row['assignment_id']])) {
+ $values = [
+ 'assignment_id' => $assignment_id[$row['assignment_id']],
+ 'user_id' => $row['user_id'],
+ 'start' => strtotime($row['start']),
+ 'end' => $row['end'] ? strtotime($row['end']) : null,
+ 'ip_address' => $row['ip_address'],
+ 'options' => $row['options'],
+ 'mkdate' => $now,
+ 'chdate' => $now
+ ];
+ $stmt->execute($values);
+ }
+ }
+
+ // etask_responses
+ $sql = 'INSERT INTO etask_responses (assignment_id, task_id, user_id, response, student_comment, ip_address, state, points, feedback, commented_solution, grader_id, mkdate, chdate, options)
+ SELECT :assignment_id, :task_id, user_id, response, student_comment, ip_address, corrected, points, corrector_comment, commented_solution, corrector_id, UNIX_TIMESTAMP(time), UNIX_TIMESTAMP(correction_time), options
+ FROM :table WHERE id = :id';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT id, exercise_id, assignment_id, 0 as archive FROM vips_solution UNION SELECT id, exercise_id, assignment_id, 1 as archive FROM vips_solution_archive ORDER BY id');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($assignment_id[$row['assignment_id']]) && isset($task_id[$row['exercise_id']])) {
+ $stmt->bindValue(':assignment_id', $assignment_id[$row['assignment_id']]);
+ $stmt->bindValue(':task_id', $task_id[$row['exercise_id']]);
+ $stmt->bindValue(':table', $row['archive'] ? 'vips_solution_archive' : 'vips_solution', StudipPDO::PARAM_COLUMN);
+ $stmt->bindValue(':id', $row['id']);
+ $stmt->execute();
+ $response_id[$row['id']] = $db->lastInsertId();
+ }
+ }
+
+ // statusgruppen
+ $sql = 'INSERT INTO statusgruppen (statusgruppe_id, name, range_id, position, size, mkdate, chdate)
+ VALUES (:statusgruppe_id, :name, :range_id, :position, :size, :mkdate, :chdate)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_group');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ $id = md5($row['id'] . ':' . uniqid('statusgruppen', true));
+ $position = $db->fetchColumn('SELECT MAX(position) FROM statusgruppen WHERE range_id = ?', [$row['course_id']]);
+
+ $values = [
+ 'statusgruppe_id' => $id,
+ 'name' => $row['name'],
+ 'range_id' => $row['course_id'],
+ 'position' => $position + 1,
+ 'size' => $row['size'],
+ 'mkdate' => $now,
+ 'chdate' => $now
+ ];
+ $stmt->execute($values);
+ $group_id[$row['id']] = $id;
+ }
+
+ // etask_blocks
+ $sql = 'INSERT INTO etask_blocks (id, name, range_id, group_id, visible, weight)
+ SELECT id, name, course_id, group_id, visible, weight FROM vips_block';
+ $db->exec($sql);
+
+ // etask_group_members
+ $sql = 'INSERT INTO etask_group_members (group_id, user_id, start, end)
+ VALUES (:group_id, :user_id, :start, :end)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_group_member');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if (isset($group_id[$row['group_id']])) {
+ $values = [
+ 'group_id' => $group_id[$row['group_id']],
+ 'user_id' => $row['user_id'],
+ 'start' => strtotime($row['start']),
+ 'end' => strtotime($row['end'])
+ ];
+ $stmt->execute($values);
+ }
+ }
+
+ // files
+ $sql = 'INSERT INTO files (id, user_id, mime_type, name, size, mkdate, chdate)
+ VALUES (:id, :user_id, :mime_type, :name, :size, :mkdate, :chdate)';
+ $stmt = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_file');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ $values = [
+ 'id' => $row['id'],
+ 'user_id' => $row['user_id'],
+ 'mime_type' => $row['mime_type'],
+ 'name' => $row['name'],
+ 'size' => $row['size'],
+ 'mkdate' => strtotime($row['created']),
+ 'chdate' => $now
+ ];
+ $stmt->execute($values);
+ }
+
+ // folders and file_refs
+ $sql = 'INSERT INTO folders (id, user_id, parent_id, range_id, range_type, folder_type, name, data_content, description, mkdate, chdate)
+ VALUES (:id, :user_id, :parent_id, :range_id, :range_type, :folder_type, :name, :data_content, :description, :mkdate, :chdate)';
+ $stmt_folder = $db->prepare($sql);
+ $sql = "INSERT INTO file_refs (id, file_id, folder_id, description, content_terms_of_use_id, user_id, name, mkdate, chdate)
+ VALUES (:id, :file_id, :folder_id, :description, 'UNDEF_LICENSE', :user_id, :name, :mkdate, :chdate)";
+ $stmt_file_ref = $db->prepare($sql);
+ $data = $db->query('SELECT * FROM vips_file_ref JOIN vips_file ON vips_file_ref.file_id = vips_file.id');
+
+ while ($row = $data->fetch(PDO::FETCH_ASSOC)) {
+ if ($row['type'] === 'exercise') {
+ $range_id = $task_id[$row['object_id']] ?? null;
+ $range_type = 'task';
+ $folder_type = 'ExerciseFolder';
+ } else {
+ $range_id = $response_id[$row['object_id']] ?? null;
+ $range_type = 'response';
+ $folder_type = $row['type'] === 'solution' ? 'ResponseFolder' : 'FeedbackFolder';
+ }
+
+ if (isset($range_id)) {
+ if (!isset($folder_id[$row['object_id'] . ':' . $row['type']])) {
+ $new_folder_id = md5($row['object_id'] . ':' . uniqid('folders', true));
+ $values = [
+ 'id' => $new_folder_id,
+ 'user_id' => $row['user_id'],
+ 'parent_id' => '',
+ 'range_id' => $range_id,
+ 'range_type' => $range_type,
+ 'folder_type' => $folder_type,
+ 'name' => '',
+ 'data_content' => '',
+ 'description' => '',
+ 'mkdate' => strtotime($row['created']),
+ 'chdate' => $now
+ ];
+ $stmt_folder->execute($values);
+ $folder_id[$row['object_id'] . ':' . $row['type']] = $new_folder_id;
+ }
+
+ $file_ref_id = md5($row['file_id'] . ':' . $row['object_id'] . ':' . uniqid('file_refs' , true));
+ $values = [
+ 'id' => $file_ref_id,
+ 'file_id' => $row['file_id'],
+ 'folder_id' => $folder_id[$row['object_id'] . ':' . $row['type']],
+ 'description' => '',
+ 'user_id' => $row['user_id'],
+ 'name' => $row['name'],
+ 'mkdate' => strtotime($row['created']),
+ 'chdate' => $now
+ ];
+ $stmt_file_ref->execute($values);
+ }
+ }
+ }
+
+ public function down()
+ {
+ $db = DBManager::get();
+
+ // unregister core plugin
+ $sql = "DELETE plugins, roles_plugins, tools_activated FROM plugins
+ LEFT JOIN roles_plugins USING (pluginid)
+ LEFT JOIN tools_activated ON plugin_id = pluginid
+ WHERE pluginclassname = 'VipsModule'";
+ $db->exec($sql);
+
+ // update etask tables
+ $sql = "ALTER TABLE etask_assignments
+ CHANGE type type varchar(64) NOT NULL,
+ CHANGE active active tinyint UNSIGNED NOT NULL,
+ DROP weight,
+ DROP block_id,
+ DROP KEY test_id,
+ DROP KEY range_id";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_assignment_attempts
+ DROP ip_address,
+ CHANGE options options text NOT NULL,
+ DROP KEY assignment_id";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_responses
+ CHANGE response response text NOT NULL,
+ DROP student_comment,
+ DROP ip_address,
+ DROP commented_solution,
+ DROP KEY assignment_id,
+ DROP KEY user_id,
+ DROP KEY task_id";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_tasks
+ CHANGE type type varchar(64) NOT NULL,
+ CHANGE description description text NOT NULL,
+ CHANGE task task text NOT NULL,
+ DROP KEY user_id";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_tests
+ CHANGE description description text NOT NULL,
+ CHANGE options options text NOT NULL,
+ DROP KEY user_id";
+ $db->exec($sql);
+
+ $sql = "ALTER TABLE etask_test_tasks
+ DROP part,
+ DROP KEY task_id";
+ $db->exec($sql);
+
+ // drop new tables
+ $db->exec('DROP TABLE etask_blocks, etask_group_members');
+
+ // remove config entries
+ $sql = "DELETE config, config_values
+ FROM config
+ LEFT JOIN config_values USING (field)
+ WHERE field IN (
+ 'VIPS_COURSE_GRADES',
+ 'VIPS_EXAM_RESTRICTIONS',
+ 'VIPS_EXAM_ROOMS',
+ 'VIPS_EXAM_TERMS'
+ )";
+ $db->exec($sql);
+ }
+}
diff --git a/lib/classes/SimpleORMap.php b/lib/classes/SimpleORMap.php
index d8cdb8e..bea9595 100644
--- a/lib/classes/SimpleORMap.php
+++ b/lib/classes/SimpleORMap.php
@@ -536,7 +536,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate
/**
* build object with given data
*
- * @param array $data assoc array of record
+ * @param iterable $data assoc array of record
* @param ?bool $is_new set object to new state
* @return static
*/
@@ -551,7 +551,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate
/**
* build object with given data and mark it as existing
*
- * @param array $data assoc array of record
+ * @param iterable $data assoc array of record
* @return static
*/
public static function buildExisting($data)
diff --git a/lib/classes/sidebar/VipsSearchWidget.php b/lib/classes/sidebar/VipsSearchWidget.php
new file mode 100644
index 0000000..dbfdea6
--- /dev/null
+++ b/lib/classes/sidebar/VipsSearchWidget.php
@@ -0,0 +1,42 @@
+<?php
+/*
+ * VipsSearchWidget.php - Sidebar SearchWidget for Vips
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class VipsSearchWidget extends SearchWidget
+{
+ /**
+ * Renders the widget.
+ *
+ * @param Array $variables Unused variables parameter
+ * @return String containing the html output of the widget
+ */
+ public function render($variables = [])
+ {
+ $needles = [];
+
+ foreach ($this->needles as $needle) {
+ if ($needle['quick_search']) {
+ $quick_search = QuickSearch::get($needle['name'], $needle['quick_search']);
+ $quick_search->noSelectbox();
+ if (isset($needle['value'])) {
+ $quick_search->defaultValue(null, $needle['value']);
+ }
+ if (isset($needle['js_func'])) {
+ $quick_search->fireJSFunctionOnSelect($needle['js_func']);
+ }
+
+ $needle['quick_search'] = $quick_search;
+ $needles[] = $needle;
+ }
+ }
+
+ return parent::render($variables + compact('needles'));
+ }
+}
diff --git a/lib/filesystem/ExerciseFolder.php b/lib/filesystem/ExerciseFolder.php
new file mode 100644
index 0000000..e400bbc
--- /dev/null
+++ b/lib/filesystem/ExerciseFolder.php
@@ -0,0 +1,111 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips exercise folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class ExerciseFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $exercise = Exercise::find($this->range_id);
+
+ foreach ($exercise->tests as $test) {
+ foreach ($test->assignments as $assignment) {
+ if ($assignment->checkEditPermission($user_id) ||
+ $assignment->checkViewPermission($user_id) &&
+ ($assignment->checkAccess($user_id) || $assignment->releaseStatus($user_id) >= 3)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $exercise = Exercise::find($this->range_id);
+
+ foreach ($exercise->tests as $test) {
+ foreach ($test->assignments as $assignment) {
+ if ($assignment->checkEditPermission($user_id)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/filesystem/FeedbackFolder.php b/lib/filesystem/FeedbackFolder.php
new file mode 100644
index 0000000..17511b8
--- /dev/null
+++ b/lib/filesystem/FeedbackFolder.php
@@ -0,0 +1,96 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips feedback folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class FeedbackFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission() ||
+ $assignment->checkViewPermission() && $assignment->releaseStatus($user_id) >= 2;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission();
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/filesystem/ResponseFolder.php b/lib/filesystem/ResponseFolder.php
new file mode 100644
index 0000000..598bf28
--- /dev/null
+++ b/lib/filesystem/ResponseFolder.php
@@ -0,0 +1,107 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips response folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class ResponseFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ if (!$assignment->checkViewPermission()) {
+ return false;
+ }
+
+ if ($assignment->checkEditPermission() || $solution->user_id === $user_id) {
+ return true;
+ }
+
+ $group = $assignment->getUserGroup($solution->user_id);
+ $group2 = $assignment->getUserGroup($user_id);
+
+ return isset($group, $group2)
+ && $group->id === $group2->id;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission();
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/models/Courseware/BlockTypes/TestBlock.php b/lib/models/Courseware/BlockTypes/TestBlock.php
new file mode 100644
index 0000000..181fff6
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/TestBlock.php
@@ -0,0 +1,125 @@
+<?php
+/*
+ * TestBlock.php - Courseware Vips test block
+ * Copyright (c) 2022 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+namespace Courseware\BlockTypes;
+
+use VipsAssignment;
+use VipsModule;
+
+class TestBlock extends BlockType
+{
+ /**
+ * Get a short string describing this type of block.
+ */
+ public static function getType(): string
+ {
+ return 'test';
+ }
+
+ /**
+ * Get the title of this type of block.
+ */
+ public static function getTitle(): string
+ {
+ return _('Aufgabenblatt');
+ }
+
+ /**
+ * Get the description of this type of block.
+ */
+ public static function getDescription(): string
+ {
+ return _('Stellt ein vorhandenes Aufgabenblatt bereit.');
+ }
+
+ /**
+ * Get the initial payload of every instance of this block.
+ */
+ public function initialPayload(): array
+ {
+ return ['assignment' => ''];
+ }
+
+ /**
+ * Get the JSON schema for the payload of this block type.
+ */
+ public static function getJsonSchema(): string
+ {
+ $schema = [
+ 'type' => 'object',
+ 'properties' => [
+ 'assignment' => [
+ 'type' => 'string'
+ ]
+ ]
+ ];
+
+ return json_encode($schema);
+ }
+
+ /**
+ * Get the list of categories for this block type.
+ */
+ public static function getCategories(): array
+ {
+ return ['interaction'];
+ }
+
+ /**
+ * Get the list of content types for this block type.
+ */
+ public static function getContentTypes(): array
+ {
+ return ['rich'];
+ }
+
+ /**
+ * Get the list of file types for this block type.
+ */
+ public static function getFileTypes(): array
+ {
+ return [];
+ }
+
+ /**
+ * Copy the payload of this block into the given range id.
+ */
+ public function copyPayload(string $rangeId = ''): array
+ {
+ static $assignments = [];
+
+ $context = $rangeId === $GLOBALS['user']->id ? 'user' : 'course';
+ $payload = $this->getPayload();
+
+ if ($payload['assignment']) {
+ $assignment = VipsAssignment::find($payload['assignment']);
+ }
+
+ if (!$assignment || !$assignment->checkEditPermission()) {
+ return $this->initialPayload();
+ }
+
+ if ($context === 'course' && !VipsModule::hasStatus('tutor', $rangeId)) {
+ return $this->initialPayload();
+ }
+
+ if ($assignment->range_id !== $rangeId) {
+ if (!isset($assignments[$assignment->id])) {
+ $copy = $assignment->copyIntoCourse($rangeId, $context);
+ $assignments[$assignment->id] = $copy->id;
+ }
+
+ $payload['assignment'] = $assignments[$assignment->id];
+ }
+
+ return $payload;
+ }
+}
diff --git a/lib/models/FileRef.php b/lib/models/FileRef.php
index 2a7f485..4196367 100644
--- a/lib/models/FileRef.php
+++ b/lib/models/FileRef.php
@@ -299,7 +299,6 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
return mb_strpos($this->mime_type, 'audio/') === 0;
}
-
/**
* Determines if the FileRef references a video file.
*
@@ -311,6 +310,22 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
}
/**
+ * Get the preferred content disposition of this file.
+ */
+ public function getContentDisposition(): string
+ {
+ if ($this->isImage() || $this->isAudio() || $this->isVideo()) {
+ return 'inline';
+ }
+
+ if (in_array($this->mime_type, ['application/pdf', 'text/plain'])) {
+ return 'inline';
+ }
+
+ return 'attachment';
+ }
+
+ /**
* Export available data of a given user into a storage object
* (an instance of the StoredUserData class) for that user.
*
diff --git a/lib/models/Folder.php b/lib/models/Folder.php
index 1c7a13e..111decf 100644
--- a/lib/models/Folder.php
+++ b/lib/models/Folder.php
@@ -274,6 +274,17 @@ class Folder extends SimpleORMap implements FeedbackRange
}
/**
+ * Retrieves folders by range id and folder type.
+ *
+ * @param string $range_id range id of the folder
+ * @param string $folder_type folder type name
+ */
+ public static function findByRangeIdAndFolderType(?string $range_id, string $folder_type)
+ {
+ return self::findBySQL('range_id = ? AND folder_type = ?', [$range_id, $folder_type]);
+ }
+
+ /**
* This callback is called before storing a Folder object.
* In case the name field is changed this callback assures that the
* name of the Folder object is unique inside the parent folder.
@@ -381,11 +392,15 @@ class Folder extends SimpleORMap implements FeedbackRange
*
* @param string range_id The ID of the Stud.IP object whose top folder shall be found.
* @param string folder_type The expected folder type related to the Stud.IP object (defaults to RootFolder, use MessageFolder for the top folder of a message)
+ * @param string range_type The expected range type of the Stud.IP object (defaults to auto detect)
*
* @returns Folder|null Folder object on success or null, if no folder can be created.
**/
- public static function findTopFolder($range_id, $folder_type = 'RootFolder')
- {
+ public static function findTopFolder(
+ string $range_id,
+ string $folder_type = 'RootFolder',
+ ?string $range_type = null
+ ) {
$top_folder = self::findOneBySQL(
"range_id = ? AND folder_type = ? AND parent_id=''",
[$range_id, $folder_type]
@@ -395,10 +410,12 @@ class Folder extends SimpleORMap implements FeedbackRange
if (!$top_folder) {
//top_folder doest not exist: create it
//determine range type:
- $range_type = self::findRangeTypeById($range_id);
if (!$range_type) {
- //no range type means we can't create a folder!
- return null;
+ $range_type = self::findRangeTypeById($range_id);
+ if (!$range_type) {
+ //no range type means we can't create a folder!
+ return null;
+ }
}
$top_folder = self::createTopFolder($range_id, $range_type, $folder_type);
diff --git a/lib/models/vips/ClozeTask.php b/lib/models/vips/ClozeTask.php
new file mode 100644
index 0000000..b5c8069
--- /dev/null
+++ b/lib/models/vips/ClozeTask.php
@@ -0,0 +1,505 @@
+<?php
+/*
+ * ClozeTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class ClozeTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('log', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Lückentext mit Eingabe oder Auswahl');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['text'] = '';
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->parseClozeText(trim($request['cloze_text']));
+ $this->task['compare'] = $request['compare'];
+
+ if ($this->task['compare'] === 'numeric') {
+ $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100;
+ }
+
+ if (isset($request['input_width'])) {
+ $this->task['input_width'] = (int) $request['input_width'];
+ }
+
+ if ($request['layout']) {
+ $this->task['layout'] = $request['layout'];
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ["L'text", 'Eingabehilfe', 'Abgleich'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ $this->parseClozeText($this->description);
+ $this->description = '';
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Kleinbuchstaben') {
+ $this->task['compare'] = 'ignorecase';
+ }
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+ $this->task['text'] = '';
+ $select = null;
+
+ foreach ($exercise->items->item->description->children() as $name => $elem) {
+ if ($name == 'text') {
+ $this->task['text'] .= (string) $elem;
+ } else if ($name == 'answers') {
+ $answers = [];
+
+ foreach ($elem->answer as $answer) {
+ $answers[] = [
+ 'text' => (string) $answer,
+ 'score' => (string) $answer['score']
+ ];
+ }
+
+ if ($elem['select'] == 'true') {
+ $select[] = $this->itemCount();
+ }
+
+ $this->task['answers'][] = $answers;
+ $this->task['text'] .= '[[]]';
+ }
+ }
+
+ $this->task['text'] = Studip\Markup::purifyHtml($this->task['text']);
+
+ switch ($exercise->items->item['type']) {
+ case 'cloze-input':
+ $this->task['select'] = $select;
+ break;
+ case 'cloze-select':
+ $this->task['layout'] = 'select';
+ break;
+ case 'cloze-drag':
+ $this->task['layout'] = 'drag';
+ }
+
+ if ($exercise->items->item->{'submission-hints'}) {
+ if ($exercise->items->item->{'submission-hints'}->input['width']) {
+ $this->task['input_width'] = (int) $exercise->items->item->{'submission-hints'}->input['width'];
+ }
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'ignorecase':
+ $this->task['compare'] = 'ignorecase';
+ break;
+ case 'numeric':
+ $this->task['compare'] = 'numeric';
+ $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'};
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing a cloze exercise. NOTE: As a cloze
+ * exercise has no special fields (it consists only of the question),
+ * normally, an empty template will be returned. The only elements it can
+ * contain are message boxes alerting that for the same cloze an answer
+ * alternative has been set repeatedly.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $duplicate_alternatives = $this->findDuplicateAlternatives();
+
+ foreach ($duplicate_alternatives as $alternative) {
+ $message = sprintf(_('Achtung: Sie haben bei der %d. Lücke die Antwort &bdquo;%s&ldquo; mehrfach eingetragen.'),
+ $alternative['index'] + 1, htmlReady($alternative['text']));
+ PageLayout::postWarning($message);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate($view, $solution, $assignment, $user_id): \Flexi\Template
+ {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Return the interaction type of this task (input, select or drag).
+ */
+ public function interactionType(): string
+ {
+ return $this->task['layout'] ?? 'input';
+ }
+
+ /**
+ * Check if selection should be offered for the given item.
+ */
+ public function isSelect(string $item, bool $use_default = true): bool
+ {
+ if ($use_default && $this->interactionType() === 'select') {
+ return true;
+ }
+
+ if (isset($this->task['select'])) {
+ return in_array($item, $this->task['select']);
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns all currently unassigned answers for the given solution.
+ */
+ public function availableAnswers(?VipsSolution $solution): array
+ {
+ $answers = [];
+ $response = $solution->response ?? [];
+
+ foreach ($this->task['answers'] as $answer) {
+ foreach ($answer as $option) {
+ $i = array_search($option['text'], $response);
+
+ if ($i !== false) {
+ unset($response[$i]);
+ } else if ($option['text'] !== '') {
+ $answers[] = $option['text'];
+ }
+ }
+ }
+
+ sort($answers, SORT_LOCALE_STRING);
+ return $answers;
+ }
+
+ /**
+ * Returns all the correct answers for an item in an array.
+ */
+ public function correctAnswers($item): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'][$item] as $answer) {
+ if ($answer['score'] == 1) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+
+ /**
+ * Calculate the optimal input field size for text exercises.
+ *
+ * @param int $item item number
+ * @return int length of input field in characters
+ */
+ public function getInputWidth($item): int
+ {
+ if (isset($this->task['input_width'])) {
+ return 5 << $this->task['input_width'];
+ }
+
+ $max = 0;
+
+ foreach ($this->task['answers'][$item] as $option) {
+ $length = mb_strlen($option['text']);
+
+ if ($length > $max) {
+ $max = $length;
+ }
+ }
+
+ $length = $max ? min(max($max, 6), 48) : 12;
+
+ // possible sizes: 5, 10, 20, 40
+ return 5 << ceil(log($length / 6) / log(2));
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $ignorecase = isset($this->task['compare']) && $this->task['compare'] === 'ignorecase';
+ $numeric = isset($this->task['compare']) && $this->task['compare'] === 'numeric';
+
+ foreach ($this->task['answers'] as $blank => $answer) {
+ $student_answer = $this->normalizeText($response[$blank] ?? '', $ignorecase);
+ $options = ['' => 0];
+ $points = 0;
+ $safe = $this->interactionType() !== 'input';
+
+ foreach ($answer as $option) { // different answer options
+ if ($numeric && $student_answer !== '') {
+ $correct_unit = $student_unit = null;
+ $correct = $this->normalizeFloat($option['text'], $correct_unit);
+ $student = $this->normalizeFloat($response[$blank], $student_unit);
+
+ if ($correct_unit === $student_unit) {
+ if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) {
+ $options[$student_answer] = max($option['score'], $options[$student_answer]);
+ } else {
+ $safe = true;
+ }
+ }
+ } else {
+ $content = $this->normalizeText($option['text'], $ignorecase);
+ $options[$content] = $option['score'];
+ }
+ }
+
+ if (isset($options[$student_answer])) {
+ $points = $options[$student_answer];
+ $safe = true;
+ }
+
+ $result[] = ['points' => $points, 'safe' => $safe];
+ }
+
+ return $result;
+ }
+
+
+
+ #######################################
+ # #
+ # h e l p e r f u n c t i o n s #
+ # #
+ #######################################
+
+
+
+ /**
+ * Returns the exercise for the lecturer. Clozes are represented by square brackets.
+ */
+ public function getClozeText(): string
+ {
+ $is_html = Studip\Markup::isHtml($this->task['text']);
+ $result = '';
+
+ foreach (explode('[[]]', $this->task['text']) as $blank => $text) {
+ $result .= $text;
+
+ if (isset($this->task['answers'][$blank])) { // blank
+ $answers = [];
+ $select = $this->isSelect($blank, false) ? ':' : '';
+
+ foreach ($this->task['answers'][$blank] as $answer) {
+ $answer_text = $answer['text'];
+
+ if (preg_match('/^$|^[":*~ ]|\||\]\]|[] ]$/', $answer_text)) {
+ $answer_text = '"' . $answer_text . '"';
+ }
+
+ if ($answer['score'] == 0) {
+ $answers[] = '*' . $answer_text;
+ } else if ($answer['score'] == 0.5) {
+ $answers[] = '~' . $answer_text;
+ } else {
+ $answers[] = $answer_text;
+ }
+ }
+
+ $blank = '[[' . $select . implode('|', $answers) . ']]';
+
+ if ($is_html) {
+ $blank = htmlReady($blank);
+ }
+
+ $result .= $blank;
+ }
+ }
+
+ return $result;
+ }
+
+
+
+ /**
+ * Converts plain text ("foo bar [blank] text...") to array.
+ */
+ public function parseClozeText(string $question): void
+ {
+ $is_html = Studip\Markup::isHtml($question);
+ $question = Studip\Markup::purifyHtml($question);
+ $this->task['text'] = '';
+
+ // $question_array contains text elements and blanks (surrounded by [[ and ]]).
+ $parts = preg_split('/(\[\[(?:".*?"|.)*?\]\])/s', $question, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $select = null;
+
+ foreach ($parts as $part) {
+ if (preg_match('/^\[\[(.*)\]\]$/s', $part, $matches)) {
+ $part = preg_replace("/[\t\n\r\xA0]/", ' ', $matches[1]);
+ $answers = [];
+
+ if ($is_html) {
+ $part = Studip\Markup::markAsHtml($part);
+ $part = Studip\Markup::removeHtml($part);
+ }
+
+ if ($part[0] === ':') {
+ $select[] = $this->itemCount();
+ $part = substr($part, 1);
+ }
+
+ if ($part !== '') {
+ preg_match_all('/((?:".*?"|[^|])*)\|/', $part . '|', $matches);
+
+ foreach ($matches[1] as $answer) {
+ $answer = trim($answer);
+ $points = 1;
+
+ if ($answer !== '') {
+ if ($answer[0] === '*') {
+ $points = 0;
+ $answer = substr($answer, 1);
+ } else if ($answer[0] === '~') {
+ $points = 0.5;
+ $answer = substr($answer, 1);
+ }
+ }
+
+ if (preg_match('/^"(.*)"$/', $answer, $matches)) {
+ $answer = $matches[1];
+ }
+
+ $answers[] = ['text' => $answer, 'score' => $points];
+ }
+ }
+
+ $this->task['answers'][] = $answers;
+ $this->task['text'] .= '[[]]';
+ } else {
+ $this->task['text'] .= $part;
+ }
+ }
+
+ $this->task['select'] = $select;
+ }
+
+ /**
+ * Searches in each cloze if an answer alternative is given repatedly.
+ *
+ * @return array Either an empty array or an array of arrays, each containing the
+ * elements 'index' (index of the cloze where the duplicate
+ * entry was found) and 'text' (text of the duplicate entry).
+ */
+ private function findDuplicateAlternatives(): array
+ {
+ $duplicate_alternatives = [];
+
+ foreach ($this->task['answers'] as $index => $answers) {
+ $alternatives = [];
+
+ foreach ($answers as $answer) {
+ if (in_array($answer['text'], $alternatives, true)) {
+ $duplicate_alternatives[] = [
+ 'index' => $index,
+ 'text' => $answer['text']
+ ];
+ }
+
+ $alternatives[] = $answer['text'];
+ }
+ }
+
+ return $duplicate_alternatives;
+ }
+}
diff --git a/lib/models/vips/DummyExercise.php b/lib/models/vips/DummyExercise.php
new file mode 100644
index 0000000..daa9dc5
--- /dev/null
+++ b/lib/models/vips/DummyExercise.php
@@ -0,0 +1,83 @@
+<?php
+/*
+ * DummyExercise.php - Vips plugin for Stud.IP
+ * Copyright (c) 2021 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class DummyExercise extends Exercise
+{
+ /**
+ * Get the name of this exercise type.
+ */
+ public function getTypeName(): string
+ {
+ return _('Unbekannter Aufgabentyp');
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ */
+ public function evaluateItems($solution): array
+ {
+ return [];
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers (defaults to 1).
+ */
+ public function itemCount(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Create a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $template = $GLOBALS['template_factory']->open('shared/string');
+ $template->content = '';
+
+ return $template;
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = $GLOBALS['template_factory']->open('shared/string');
+ $template->content = '';
+
+ return $template;
+ }
+}
diff --git a/lib/models/vips/Exercise.php b/lib/models/vips/Exercise.php
new file mode 100644
index 0000000..a4ef00a
--- /dev/null
+++ b/lib/models/vips/Exercise.php
@@ -0,0 +1,855 @@
+<?php
+/*
+ * Exercise.php - base class for all exercise types
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+abstract class Exercise extends SimpleORMap
+{
+ /**
+ * The unpacked value from the "task" column in the SORM instance.
+ * This is an array, but type hinting does not work due to SORM
+ * writing the JSON string into this property on restore().
+ */
+ public $task = [];
+
+ /**
+ * @var array<class-string<static>, array>
+ */
+ private static array $exercise_types = [];
+
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_tasks';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['has_and_belongs_to_many']['tests'] = [
+ 'class_name' => VipsTest::class,
+ 'thru_table' => 'etask_test_tasks',
+ 'thru_key' => 'task_id',
+ 'thru_assoc_key' => 'test_id'
+ ];
+
+ $config['has_many']['exercise_refs'] = [
+ 'class_name' => VipsExerciseRef::class,
+ 'assoc_foreign_key' => 'task_id'
+ ];
+ $config['has_many']['solutions'] = [
+ 'class_name' => VipsSolution::class,
+ 'assoc_foreign_key' => 'task_id',
+ 'on_delete' => 'delete'
+ ];
+
+ $config['has_one']['folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->id, 'ExerciseFolder'],
+ 'on_delete' => 'delete'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->type = get_class($this);
+ $this->task = ['answers' => []];
+ }
+
+ if (is_null($this->options)) {
+ $this->options = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ $this->title = trim($request['exercise_name']);
+ $this->description = trim($request['exercise_question']);
+ $this->description = Studip\Markup::purifyHtml($this->description);
+ $exercise_hint = trim($request['exercise_hint']);
+ $exercise_hint = Studip\Markup::purifyHtml($exercise_hint);
+ $feedback = trim($request['feedback']);
+ $feedback = Studip\Markup::purifyHtml($feedback);
+ $this->task = ['answers' => []];
+ $this->options = [];
+
+ if ($this->title === '') {
+ $this->title = _('Aufgabe');
+ }
+
+ if ($exercise_hint !== '') {
+ $this->options['hint'] = $exercise_hint;
+ }
+
+ if ($feedback !== '') {
+ $this->options['feedback'] = $feedback;
+ }
+
+ if ($request['exercise_comment']) {
+ $this->options['comment'] = 1;
+ }
+
+ if ($request['file_ids'] && !$request['files_visible']) {
+ $this->options['files_hidden'] = 1;
+ }
+ }
+
+ /**
+ * Filter input from flexible input with HTMLPurifier (if required).
+ */
+ public static function purifyFlexibleInput(string $html): string
+ {
+ if (Studip\Markup::isHtml($html)) {
+ $text = Studip\Markup::removeHtml($html);
+
+ if (substr_count($html, '<') > 1 || kill_format($text) !== $text) {
+ $html = Studip\Markup::purifyHtml($html);
+ } else {
+ $html = $text;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * Load a specific exercise from the database.
+ */
+ public static function find($id)
+ {
+ $db = DBManager::get();
+
+ $stmt = $db->prepare('SELECT * FROM etask_tasks WHERE id = ?');
+ $stmt->execute([$id]);
+ $data = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($data) {
+ return self::buildExisting($data);
+ }
+
+ return null;
+ }
+
+ /**
+ * Load an array of exercises filtered by given sql from the database.
+ *
+ * @param string $sql clause to use on the right side of WHERE
+ * @param array $params for query
+ */
+ public static function findBySQL($sql, $params = [])
+ {
+ $db = DBManager::get();
+
+ $has_join = stripos($sql, 'JOIN ');
+ if ($has_join === false || $has_join > 10) {
+ $sql = 'WHERE ' . $sql;
+ }
+ $stmt = $db->prepare('SELECT etask_tasks.* FROM etask_tasks ' . $sql);
+ $stmt->execute($params);
+ $stmt->setFetchMode(PDO::FETCH_ASSOC);
+ $result = [];
+
+ while ($data = $stmt->fetch()) {
+ $result[] = self::buildExisting($data);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Find related records for an n:m relation (has_and_belongs_to_many)
+ * using a combination table holding the keys.
+ *
+ * @param string $foreign_key_value value of foreign key to find related records
+ * @param array $options relation options from other side of relation
+ */
+ public static function findThru($foreign_key_value, $options)
+ {
+ $thru_table = $options['thru_table'];
+ $thru_key = $options['thru_key'];
+ $thru_assoc_key = $options['thru_assoc_key'];
+
+ $sql = "JOIN `$thru_table` ON `$thru_table`.`$thru_assoc_key` = etask_tasks.id
+ WHERE `$thru_table`.`$thru_key` = ? " . $options['order_by'];
+
+ return self::findBySQL($sql, [$foreign_key_value]);
+ }
+
+ /**
+ * Create a new exercise object from a data array.
+ */
+ public static function create($data)
+ {
+ $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class;
+
+ if (static::class === self::class) {
+ return $class::create($data);
+ } else {
+ return parent::create($data);
+ }
+ }
+
+ /**
+ * Build an exercise object from a data array.
+ */
+ public static function buildExisting($data)
+ {
+ $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class;
+
+ return $class::build($data, false);
+ }
+
+ /**
+ * Initialize task structure from JSON string.
+ */
+ public function setTask(mixed $value): void
+ {
+ if (is_string($value)) {
+ $this->content['task'] = $value;
+ $value = json_decode($value, true) ?: [];
+ }
+
+ $this->task = $value;
+ }
+
+ /**
+ * Restore this exercise from the database.
+ */
+ public function restore()
+ {
+ $result = parent::restore();
+ $this->setTask($this->task);
+
+ return $result;
+ }
+
+ /**
+ * Store this exercise into the database.
+ */
+ public function store()
+ {
+ $this->content['task'] = json_encode($this->task);
+
+ return parent::store();
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers (defaults to 1).
+ */
+ public function itemCount(): int
+ {
+ return 1;
+ }
+
+ /**
+ * Overwrite this function for each exercise type where shuffling answer
+ * alternatives makes sense.
+ *
+ * @param string $user_id A value for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param VipsSolution $solution The solution object returned by getSolutionFromRequest().
+ */
+ public abstract function evaluateItems(VipsSolution $solution): array;
+
+ /**
+ * Evaluates a student's solution.
+ *
+ * @param VipsSolution $solution The solution object returned by getSolutionFromRequest().
+ */
+ public function evaluate(VipsSolution $solution): array
+ {
+ $results = $this->evaluateItems($solution);
+ $mc_mode = $solution->assignment->options['evaluation_mode'];
+ $malus = 0;
+ $points = 0;
+ $safe = true;
+
+ foreach ($results as $item) {
+ if ($item['points'] === 0) {
+ ++$malus;
+ } else if ($item['points'] !== null) {
+ $points += $item['points'];
+ }
+
+ if ($item['safe'] === null) {
+ $safe = null;
+ } else if ($safe !== null) {
+ // only true if all items are marked as 'safe'
+ $safe &= $item['safe'];
+ }
+ }
+
+ if ($this->isMultipleChoice()) {
+ if ($mc_mode == 1) {
+ $points = max($points - $malus, 0);
+ } else if ($mc_mode == 2 && $malus > 0) {
+ $points = 0;
+ }
+ }
+
+ $percent = $points / max(count($results), 1);
+
+ return ['percent' => $percent, 'safe' => $safe];
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return array_fill(0, $this->itemCount(), '');
+ }
+
+ /**
+ * Return the response of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ for ($i = 0; $i < $this->itemCount(); ++$i) {
+ $result[] = trim($request['answer'][$i] ?? '');
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_values($response);
+ }
+
+ /**
+ * Export this exercise to Vips XML format.
+ */
+ public function getXMLTemplate(VipsAssignment $assignment): Flexi\Template
+ {
+ return $this->getViewTemplate('xml', null, $assignment, null);
+ }
+
+ /**
+ * Exercise handler to be called when a solution is corrected.
+ */
+ public function correctSolutionAction(Trails\Controller$controller, VipsSolution $solution): void
+ {
+ }
+
+ /**
+ * Return a URL to a specified route in this exercise class.
+ * $params can contain optional additional parameters.
+ */
+ public function url_for($path, $params = []): string
+ {
+ $params['exercise_id'] = $this->id;
+
+ return URLHelper::getURL('dispatch.php/vips/sheets/relay/' . $path, $params);
+ }
+
+ /**
+ * Return an encoded URL to a specified route in this exercise class.
+ * $params can contain optional additional parameters.
+ */
+ public function link_for($path, $params = []): string
+ {
+ return htmlReady($this->url_for($path, $params));
+ }
+
+ /**
+ * Create a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/edit');
+ $template->exercise = $this;
+
+ return $template;
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ if ($assignment->isShuffled() && $user_id) {
+ $this->shuffleAnswers($user_id);
+ }
+
+ $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/' . $view);
+ $template->exercise = $this;
+ $template->solution = $solution;
+ $template->response = $solution ? $solution->response : null;
+ $template->evaluation_mode = $assignment->options['evaluation_mode'];
+
+ return $template;
+ }
+
+ /**
+ * Return a template for solving an exercise.
+ */
+ public function getSolveTemplate(
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ return $this->getViewTemplate('solve', $solution, $assignment, $user_id);
+ }
+
+ /**
+ * Return a template for correcting an exercise.
+ */
+ public function getCorrectionTemplate(VipsSolution $solution): Flexi\Template
+ {
+ return $this->getViewTemplate('correct', $solution, $solution->assignment, $solution->user_id);
+ }
+
+ /**
+ * Return a template for printing an exercise.
+ */
+ public function getPrintTemplate(VipsSolution $solution, VipsAssignment $assignment, ?string $user_id)
+ {
+ return $this->getViewTemplate('print', $solution, $assignment, $user_id);
+ }
+
+ /**
+ * Get the name of this exercise type.
+ */
+ public function getTypeName(): string
+ {
+ return self::$exercise_types[$this->type]['name'];
+ }
+
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('question-circle', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return '';
+ }
+
+ /**
+ * Get the list of supported exercise types.
+ */
+ public static function getExerciseTypes(): array
+ {
+ return self::$exercise_types;
+ }
+
+ /**
+ * Register a new exercise type and class.
+ *
+ * @param class-string<static> $class
+ */
+ public static function addExerciseType(string $name, string $class, mixed $type = null): void
+ {
+ self::$exercise_types[$class] = compact('name', 'type');
+ }
+
+ /**
+ * Return the list of keywords used for legacy text export. The first
+ * keyword in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return [];
+ }
+
+ /**
+ * Import a new exercise from text data array.
+ */
+ public static function importText(string $segment): static
+ {
+ $all_keywords = ['Tipp'];
+
+ $types = [];
+ foreach (self::$exercise_types as $key => $value) {
+ $keywords = $key::getTextKeywords();
+
+ if ($keywords) {
+ $all_keywords = array_merge($all_keywords, $keywords);
+ $types[$key] = array_shift($keywords);
+ }
+ }
+
+ $type = '';
+ $pattern = implode('|', array_unique($all_keywords));
+ $parts = preg_split("/\n($pattern):/", $segment, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $title = array_shift($parts);
+
+ $exercise = [['Name' => trim($title)]];
+
+ if ($parts) {
+ $type = array_shift($parts);
+ $text = array_shift($parts);
+ $text = preg_replace('/\\\\' . $type . '$/', '', trim($text));
+
+ $exercise[] = ['Type' => trim($type)];
+ $exercise[] = ['Text' => trim($text)];
+ }
+
+ while ($parts) {
+ $tag = array_shift($parts);
+ $val = array_shift($parts);
+ $val = preg_replace('/\\\\' . $tag . '$/', '', trim($val));
+
+ $exercise[] = [$tag => trim($val)];
+ }
+
+ foreach ($types as $key => $value) {
+ if (preg_match('/^' . $value . '$/', $type)) {
+ $exercise_type = $key;
+ }
+ }
+
+ if (!isset($exercise_type)) {
+ throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type);
+ }
+
+ /** @var class-string<static> $exercise_type */
+ $result = new $exercise_type();
+ $result->initText($exercise);
+ return $result;
+ }
+
+ /**
+ * Import a new exercise from Vips XML format.
+ */
+ public static function importXML($exercise): static
+ {
+ $type = (string) $exercise->items->item[0]['type'];
+
+ foreach (self::$exercise_types as $key => $value) {
+ if ($type === $value['type'] || is_array($value['type']) && in_array($type, $value['type'])) {
+ $exercise_type = $key;
+ }
+ }
+
+ if (!isset($exercise_type)) {
+ throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type);
+ }
+
+ if (
+ $exercise_type === MultipleChoiceTask::class
+ && $exercise->items->item[0]->choices
+ ) {
+ $exercise_type = MatrixChoiceTask::class;
+ }
+
+ /** @var class-string<static> $exercise_type */
+ $result = new $exercise_type();
+ $result->initXML($exercise);
+ return $result;
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Name') {
+ $this->title = current($tag) ?: _('Aufgabe');
+ }
+
+ if (key($tag) === 'Text') {
+ $this->description = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Tipp') {
+ $this->options['hint'] = Studip\Markup::purifyHtml(current($tag));
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ $this->title = trim($exercise->title);
+
+ if ($this->title === '') {
+ $this->title = _('Aufgabe');
+ }
+
+ if ($exercise->description) {
+ $this->description = Studip\Markup::purifyHtml(trim($exercise->description));
+ }
+
+ if ($exercise->hint) {
+ $this->options['hint'] = Studip\Markup::purifyHtml(trim($exercise->hint));
+ }
+
+ if ($exercise['feedback'] == 'true') {
+ $this->options['comment'] = 1;
+ }
+
+ if ($exercise->{'file-refs'}['hidden'] == 'true') {
+ $this->options['files_hidden'] = 1;
+ }
+
+ if ($exercise->items->item[0]->feedback) {
+ $this->options['feedback'] = Studip\Markup::purifyHtml(trim($exercise->items->item[0]->feedback));
+ }
+ }
+
+ /**
+ * Construct a new solution object from the request post data.
+ */
+ public function getSolutionFromRequest($request, ?array $files = null): VipsSolution
+ {
+ $solution = new VipsSolution();
+ $solution->exercise = $this;
+ $solution->user_id = $GLOBALS['user']->id;
+ $solution->response = $this->responseFromRequest($request);
+ $solution->student_comment = trim($request['student_comment']);
+
+ return $solution;
+ }
+
+ /**
+ * Include files referenced by URL into the exercise attachments and
+ * rewrite all corresponding URLs in the exercise text.
+ */
+ public function includeFilesForExport(): void
+ {
+ if (!$this->folder || count($this->folder->file_refs) === 0) {
+ $this->options['files_hidden'] = 1;
+ }
+
+ $this->description = $this->rewriteLinksForExport($this->description);
+ $this->options['hint'] = $this->rewriteLinksForExport($this->options['hint']);
+ $this->task = $this->rewriteLinksForExport($this->task);
+ }
+
+ /**
+ * Return a normalized version of a string
+ *
+ * @param string $string string to be normalized
+ * @param boolean $lowercase make string lower case
+ * @return string The normalized string
+ */
+ protected function normalizeText(string $string, bool $lowercase = true): string
+ {
+ // remove leading/trailing spaces
+ $string = trim($string);
+
+ // compress white space
+ $string = preg_replace('/\s+/u', ' ', $string);
+
+ // delete blanks before and after [](){}:;,.!?"=<>^*/+-
+ $string = preg_replace('/ *([][(){}:;,.!?"=<>^*\/+-]) */', '$1', $string);
+
+ // convert to lower case if requested
+ return $lowercase ? mb_strtolower($string) : $string;
+ }
+
+ /**
+ * Return a normalized version of a float (and optionally a unit)
+ *
+ * @param string $string string to be normalized
+ * @param string $unit will contain the unit text
+ * @return float The normalized value
+ */
+ protected function normalizeFloat(string $string, string &$unit): float
+ {
+ static $si_scale = [
+ 'T' => 12,
+ 'G' => 9,
+ 'M' => 6,
+ 'k' => 3,
+ 'h' => 2,
+ 'd' => -1,
+ 'c' => -2,
+ 'm' => -3,
+ 'µ' => -6,
+ 'μ' => -6,
+ 'n' => -9,
+ 'p' => -12
+ ];
+
+ // normalize representation
+ $string = $this->normalizeText($string, false);
+ $string = str_replace('*10^', 'e', $string);
+ $string = preg_replace_callback('/(\d+)\/(\d+)/', function($m) { return $m[1] / $m[2]; }, $string);
+ $string = strtr($string, ',', '.');
+
+ // split into value and unit
+ preg_match('/^([-+0-9.e]*)(.*)/', $string, $matches);
+ $value = (float) $matches[1];
+ $unit = trim($matches[2]);
+
+ if ($unit) {
+ $prefix = mb_substr($unit, 0, 1);
+ $letter = mb_substr($unit, 1, 1);
+
+ if (ctype_alpha($letter) && isset($si_scale[$prefix])) {
+ $value *= pow(10, $si_scale[$prefix]);
+ $unit = mb_substr($unit, 1);
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * UTF-8 compatible version of standard PHP levenshtein function.
+ */
+ protected function levenshtein(string $string1, string $string2): int
+ {
+ $mb_str1 = preg_split('//u', $string1, null, PREG_SPLIT_NO_EMPTY);
+ $mb_str2 = preg_split('//u', $string2, null, PREG_SPLIT_NO_EMPTY);
+
+ $mb_len1 = count($mb_str1);
+ $mb_len2 = count($mb_str2);
+
+ $dist = [];
+ for ($i = 0; $i <= $mb_len1; ++$i) {
+ $dist[$i][0] = $i;
+ }
+ for ($j = 0; $j <= $mb_len2; ++$j) {
+ $dist[0][$j] = $j;
+ }
+
+ for ($i = 1; $i <= $mb_len1; $i++) {
+ for ($j = 1; $j <= $mb_len2; $j++) {
+ $dist[$i][$j] = min(
+ $dist[$i-1][$j] + 1,
+ $dist[$i][$j-1] + 1,
+ $dist[$i-1][$j-1] + ($mb_str1[$i-1] !== $mb_str2[$j-1] ? 1 : 0)
+ );
+ }
+ }
+
+ return $dist[$mb_len1][$mb_len2];
+ }
+
+ /**
+ * Scan the given string or array (recursively) for referenced file URLs
+ * and rewrite those links into URNs suitable for XML export.
+ */
+ protected function rewriteLinksForExport(mixed $data): mixed
+ {
+ if (is_array($data)) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $this->rewriteLinksForExport($value);
+ }
+ } else if (is_string($data) && Studip\Markup::isHtml($data)) {
+ $data = preg_replace_callback('/"\Khttps?:[^"]*/', function($match) {
+ $url = html_entity_decode($match[0]);
+ $url = preg_replace(
+ '%/download/(?:normal|force_download)/\d/(\w+)/.+%',
+ '/sendfile.php?file_id=$1',
+ $url
+ );
+ [$url, $query] = explode('?', $url);
+
+ if (is_internal_url($url) && basename($url) === 'sendfile.php') {
+ parse_str($query, $query_params);
+ $file_id = $query_params['file_id'];
+ $file_ref = FileRef::find($file_id);
+
+ if ($file_ref && $this->folder->file_refs->find($file_id)) {
+ return 'urn:vips:file-ref:file-' . $file_ref->file_id;
+ }
+
+ if ($file_ref) {
+ $folder = $file_ref->folder->getTypedFolder();
+
+ if ($folder->isFileDownloadable($file_ref, $GLOBALS['user']->id)) {
+ if (!$this->folder->file_refs->find($file_id)) {
+ $file = $file_ref->file;
+ // $this->files->append($file);
+ }
+
+ return 'urn:vips:file-ref:file-' . $file_id->file_id;
+ }
+ }
+ }
+
+ return $match[0];
+ }, $data);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Calculate the size parameter for a flexible input element.
+ *
+ * @param string $text contents of the input
+ */
+ public function flexibleInputSize(?string $text): string
+ {
+ return str_contains($text, "\n") || Studip\Markup::isHtml($text) ? 'large' : 'small';
+ }
+
+ /**
+ * Calculate the optimal textarea height for text exercises.
+ *
+ * @param string $text contents of textarea
+ * @return int height of textarea in lines
+ */
+ public function textareaSize(?string $text): int
+ {
+ return max(substr_count($text, "\n") + 3, 5);
+ }
+}
diff --git a/lib/models/vips/MatchingTask.php b/lib/models/vips/MatchingTask.php
new file mode 100644
index 0000000..bb559e2
--- /dev/null
+++ b/lib/models/vips/MatchingTask.php
@@ -0,0 +1,341 @@
+<?php
+/*
+ * MatchingTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MatchingTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('view-list', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Zuordnung von Elementen zu Kategorien');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['groups'] = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $id = $request['id'];
+ $_id = $request['_id'];
+
+ $this->task['groups'] = [];
+ $this->task['select'] = $request['multiple'] ? 'multiple' : 'single';
+
+ foreach ($request['default'] as $i => $group) {
+ $group = self::purifyFlexibleInput($group);
+ $answers = (array) $request['answer'][$i];
+
+ if (trim($group) != '') {
+ foreach ($answers as $j => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $id[$i][$j],
+ 'text' => trim($answer),
+ 'group' => count($this->task['groups'])
+ ];
+ }
+ }
+
+ $this->task['groups'][] = trim($group);
+ }
+ }
+
+ // list of answers that must remain unassigned
+ foreach ($request['_answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $_id[$i],
+ 'text' => trim($answer),
+ 'group' => -1
+ ];
+ }
+ }
+
+ $this->createIds();
+ }
+
+ /**
+ * Genereate new IDs for all answers that do not yet have one.
+ */
+ public function createIds(): void
+ {
+ $ids = [0 => true];
+
+ foreach ($this->task['answers'] as $i => &$answer) {
+ if (empty($answer['id'])) {
+ do {
+ $answer['id'] = rand();
+ } while (isset($ids[$answer['id']]));
+ }
+
+ $ids[$answer['id']] = true;
+ }
+ }
+
+ /**
+ * Check if multiple assignment mode is enabled for this exercise.
+ */
+ public function isMultiSelect(): bool
+ {
+ return isset($this->task['select']) && $this->task['select'] === 'multiple';
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']) - count($this->correctAnswers(-1));
+ }
+
+ /**
+ * Sort the list of answers by their ids.
+ */
+ public function sortAnswersById(): void
+ {
+ usort(
+ $this->task['answers'],
+ fn($a, $b) => $a['id'] <=> $b['id']
+ );
+ }
+
+ /**
+ * Returns all the correct answers for the given group.
+ */
+ public function correctAnswers(string $group): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['group'] == $group) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+
+ /**
+ * Check if this answer is a correct assignment to the given group.
+ */
+ public function isCorrectAnswer(array $answer, string $group): bool
+ {
+ if ($answer['group'] == $group) {
+ return true;
+ }
+
+ foreach ($this->task['answers'] as $_answer) {
+ if ($_answer['group'] == $group) {
+ if ($answer['text'] === $_answer['text']) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $item_count = $this->itemCount();
+
+ foreach ($this->task['answers'] as $answer) {
+ $group = $response[$answer['id']] ?? -1;
+
+ if ($group != -1) {
+ $points = $this->isCorrectAnswer($answer, $group) ? 1 : 0;
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+ }
+
+ // assign no points for missing answers
+ while (count($result) < $item_count) {
+ $result[] = ['points' => 0, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['ZU-Frage', 'Vorgabe', 'Antwort', 'Distraktor'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Vorgabe') {
+ $group = count($this->task['groups']);
+ $this->task['groups'][] = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Antwort' && isset($group)) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'group' => $group
+ ];
+ unset($group);
+ }
+
+ if (key($tag) === 'Distraktor') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'group' => -1
+ ];
+ }
+ }
+
+ $this->createIds();
+ }
+
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ $this->task['select'] = $exercise->items->item['type'] == 'matching-multiple' ? 'multiple' : 'single';
+
+ foreach ($exercise->items->item->choices->choice as $choice) {
+ $this->task['groups'][] = Studip\Markup::purifyHtml(trim($choice));
+ }
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'group' => (int) $answer['correct']
+ ];
+ }
+
+ $this->createIds();
+ }
+
+
+
+ /**
+ * Creates a template for editing a MatchingTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): \Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ foreach (range(0, 4) as $i) {
+ $this->task['answers'][] = ['id' => '', 'text' => '', 'group' => count($this->task['groups'])];
+ $this->task['groups'][] = '';
+ }
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ // get the group the user has added this answer to
+ $result[$answer['id']] = (int) $request['answer'][$answer['id']];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ $result = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['group'] != -1) {
+ if (isset($response[$answer['id']]) && $response[$answer['id']] != -1) {
+ $result[] = $this->task['groups'][$response[$answer['id']]];
+ } else {
+ $result[] = '';
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/models/vips/MatrixChoiceTask.php b/lib/models/vips/MatrixChoiceTask.php
new file mode 100644
index 0000000..1ba2d90
--- /dev/null
+++ b/lib/models/vips/MatrixChoiceTask.php
@@ -0,0 +1,268 @@
+<?php
+/*
+ * MatrixChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MatrixChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('timetable', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Einfachauswahl pro Zeile in einer Tabelle');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['choices'] = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task['choices'] = [];
+ $choice_index = [];
+
+ foreach ($request['choice'] as $i => $choice) {
+ if (trim($choice) != '') {
+ $this->task['choices'][] = trim($choice);
+ $choice_index[$i] = count($choice_index);
+ }
+ }
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'choice' => $choice_index[$request['correct'][$i]]
+ ];
+ }
+ }
+
+ if ($request['optional']) {
+ $this->options['optional'] = 1;
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ $random_order = range(0, $this->itemCount() - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task['answers'][$index];
+ }
+ $this->task['answers'] = $answer_temp;
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task['answers'] as $i => $answer) {
+ if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) {
+ $points = null;
+ } else {
+ $points = $response[$i] == $answer['choice'] ? 1 : 0;
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['MCO-Frage', 'Auswahl', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'choice' => 0
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'choice' => 1
+ ];
+ }
+ }
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Auswahl') {
+ [$label_yes, $label_no] = explode('/', current($tag));
+ $this->task['choices'] = [trim($label_yes), trim($label_no)];
+ }
+ }
+
+ $this->options['optional'] = 1;
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ if (isset($answer['correct'])) {
+ $choice = (int) $answer['correct'];
+ } else {
+ $choice = (int) $answer['score'] ? 0 : 1;
+ }
+
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'choice' => $choice
+ ];
+ }
+
+ foreach ($exercise->items->item->choices->choice as $choice) {
+ if ($choice['type'] == 'none') {
+ $this->options['optional'] = 1;
+ } else {
+ $this->task['choices'][] = trim($choice);
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['choices']) {
+ $this->task['choices'] = [_('Ja'), _('Nein')];
+ }
+
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'choice' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate($view, $solution, $assignment, $user_id): Flexi\Template
+ {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if (isset($this->options['optional']) && $this->options['optional']) {
+ $template->optional_choice = [-1 => _('keine Antwort')];
+ } else {
+ $template->optional_choice = [];
+ }
+
+ return $template;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_map(
+ fn($a) => $a == -1 ? '' : $a,
+ $response
+ );
+ }
+}
diff --git a/lib/models/vips/MultipleChoiceTask.php b/lib/models/vips/MultipleChoiceTask.php
new file mode 100644
index 0000000..68470ef
--- /dev/null
+++ b/lib/models/vips/MultipleChoiceTask.php
@@ -0,0 +1,196 @@
+<?php
+/*
+ * MultipleChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MultipleChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('assessment-mc', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Mehrfachauswahl aus einer Liste');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (int) $request['correct'][$i]
+ ];
+ }
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return [];
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ $random_order = range(0, $this->itemCount() - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task['answers'][$index];
+ }
+ $this->task['answers'] = $answer_temp;
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task['answers'] as $i => $answer) {
+ if (!isset($response[$i])) {
+ $points = null;
+ } else {
+ $points = (int) $response[$i] == $answer['score'] ? 1 : 0;
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['MC-Frage', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 1
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 0
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => (int) $answer['score']
+ ];
+ }
+ }
+
+ /**
+ * Creates a template for editing a MultipleChoiceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+}
diff --git a/lib/models/vips/SequenceTask.php b/lib/models/vips/SequenceTask.php
new file mode 100644
index 0000000..696fe6a
--- /dev/null
+++ b/lib/models/vips/SequenceTask.php
@@ -0,0 +1,255 @@
+<?php
+/*
+ * SequenceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2022 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class SequenceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('hamburger', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Anordnung von Elementen in einer Reihe');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $request['id'][$i],
+ 'text' => trim($answer)
+ ];
+ }
+ }
+
+ $this->task['compare'] = $request['compare'];
+
+ $this->createIds();
+ }
+
+ /**
+ * Genereate new IDs for all answers that do not yet have one.
+ */
+ public function createIds(): void
+ {
+ $ids = [0 => true];
+
+ foreach ($this->task['answers'] as $i => &$answer) {
+ if (empty($answer['id'])) {
+ do {
+ $answer['id'] = rand();
+ } while (isset($ids[$answer['id']]));
+ }
+
+ $ids[$answer['id']] = true;
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ if ($this->task['compare'] === 'sequence') {
+ return max(count($this->task['answers']) - 1, 0);
+ }
+
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the list of answers as ordered by the student (if applicable).
+ */
+ public function orderedAnswers($response)
+ {
+ $answers = $this->task['answers'];
+ $pos = isset($response) ? array_flip($response) : [];
+
+ usort($answers, function($a, $b) use ($pos) {
+ if (isset($pos[$a['id']]) && isset($pos[$b['id']])) {
+ return $pos[$a['id']] <=> $pos[$b['id']];
+ } else if (isset($pos[$a['id']])) {
+ return -1;
+ } else if (isset($pos[$b['id']])) {
+ return 1;
+ } else {
+ return $a['id'] <=> $b['id'];
+ }
+ });
+
+ return $answers;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution object returned by getSolutionFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $item_count = $this->itemCount();
+ $answers = $this->task['answers'];
+ $pos = array_flip($response);
+
+ for ($i = 0; $i < $item_count; ++$i) {
+ if ($this->task['compare'] === 'sequence') {
+ if ($pos[$answers[$i]['id']] + 1 == $pos[$answers[$i + 1]['id']]) {
+ $points = 1;
+ } else {
+ $points = 0;
+ }
+ } else {
+ if ($pos[$answers[$i]['id']] == $i) {
+ $points = 1;
+ } else {
+ $points = 0;
+ }
+ }
+
+ if (!$this->task['compare'] && count($result)) {
+ $result[0]['points'] &= $points;
+ } else {
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer))
+ ];
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'position':
+ case 'sequence':
+ $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type'];
+ }
+ }
+
+ $this->createIds();
+ }
+
+
+
+ /**
+ * Creates a template for editing a SequenceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['id' => '', 'text' => '']);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ foreach ($request['answer'] as $id) {
+ $result[] = (int) $id;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ $result = [];
+
+ foreach ($response as $id) {
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['id'] === $id) {
+ $result[] = $answer['text'];
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/models/vips/SingleChoiceTask.php b/lib/models/vips/SingleChoiceTask.php
new file mode 100644
index 0000000..4029a65
--- /dev/null
+++ b/lib/models/vips/SingleChoiceTask.php
@@ -0,0 +1,279 @@
+<?php
+/*
+ * SingleChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class SingleChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('assessment', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Einfachauswahl aus einer Liste');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task = [];
+
+ foreach ($request['answer'] as $group => $answergroup) {
+ $task = [];
+ $description = trim($request['description'][$group]);
+ $description = Studip\Markup::purifyHtml($description);
+
+ if ($this->task && $description != '') {
+ $task['description'] = $description;
+ }
+
+ foreach ($answergroup as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => $request['correct'][$group] == $i ? 1 : 0
+ ];
+ }
+ }
+
+ if ($task) {
+ $this->task[] = $task;
+ }
+ }
+
+ if ($request['optional']) {
+ $this->options['optional'] = 1;
+ }
+ }
+
+ /**
+ * Computes the default maximum points which can be reached in this
+ * exercise, dependent on the number of groups.
+ *
+ * @return int maximum points
+ */
+ public function itemCount(): int
+ {
+ return count($this->task);
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ for ($block = 0; $block < count($this->task); $block++) {
+ $random_order = range(0, count($this->task[$block]['answers']) - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task[$block]['answers'][$index];
+ }
+ $this->task[$block]['answers'] = $answer_temp;
+ }
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task as $i => $task) {
+ if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) {
+ $points = null;
+ } else {
+ $points = $task['answers'][$response[$i]]['score'];
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['SCO?-Frage|JN-Frage', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ $block = 0;
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Type' && current($tag) === 'SCO-Frage') {
+ $this->options['optional'] = 1;
+ }
+
+ if (key($tag) === '+Antwort' || key($tag) === 'Antwort') {
+ if (preg_match('/\n--$/', current($tag))) {
+ $text = trim(substr(current($tag), 0, -3));
+ $incr = 1;
+ } else {
+ $text = current($tag);
+ $incr = 0;
+ }
+
+ $score = key($tag) === '+Antwort' ? 1 : 0;
+
+ $this->task[$block]['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml($text),
+ 'score' => $score
+ ];
+
+ $block += $incr;
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item as $item) {
+ $task = [];
+
+ if ($item->description) {
+ $task['description'] = Studip\Markup::purifyHtml(trim($item->description->text));
+ }
+
+ foreach ($item->answers->answer as $answer) {
+ if ($answer['default'] == 'true') {
+ $this->options['optional'] = 1;
+ } else {
+ $task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => (int) $answer['score']
+ ];
+ }
+ }
+
+ $this->task[] = $task;
+ }
+ }
+
+ /**
+ * Creates a template for editing a SingleChoiceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task) {
+ $this->task[0]['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if (isset($this->options['optional']) && $this->options['optional']) {
+ $template->optional_answer = [-1 => ['text' => _('keine Antwort'), 'score' => 0]];
+ } else {
+ $template->optional_answer = [];
+ }
+
+ return $template;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_map(function($a) { return $a == -1 ? '' : $a; }, $response);
+ }
+}
diff --git a/lib/models/vips/TextLineTask.php b/lib/models/vips/TextLineTask.php
new file mode 100644
index 0000000..4a2e7d2
--- /dev/null
+++ b/lib/models/vips/TextLineTask.php
@@ -0,0 +1,271 @@
+<?php
+/*
+ * TextLineTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2011 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class TextLineTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('edit-line', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Kurze einzeilige Textantwort');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (float) $request['correct'][$i]
+ ];
+ }
+ }
+
+ $this->task['compare'] = $request['compare'];
+
+ if ($this->task['compare'] === 'numeric') {
+ $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100;
+ }
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $studentSolution = $response[0];
+
+ $similarity = 0;
+ $safe = false;
+ $studentSolution = $this->normalizeText($studentSolution, true);
+
+ if ($studentSolution === '') {
+ $result[] = ['points' => 0, 'safe' => true];
+ return $result;
+ }
+
+ foreach ($this->task['answers'] as $answer) {
+ $musterLoesung = $this->normalizeText($answer['text'], true);
+ $similarity_temp = 0;
+
+ if ($musterLoesung === $studentSolution) {
+ $similarity_temp = 1;
+ } else if ($this->task['compare'] === 'levenshtein') { // Levenshtein-Distanz
+ $string1 = mb_substr($studentSolution, 0, 255);
+ $string2 = mb_substr($musterLoesung, 0, 255);
+ $divisor = max(mb_strlen($string1), mb_strlen($string2));
+
+ $levenshtein = $this->levenshtein($string1, $string2) / $divisor;
+ $similarity_temp = 1 - $levenshtein;
+ } else if ($this->task['compare'] === 'soundex') { // Soundex-Aussprache
+ $levenshtein = levenshtein(soundex($musterLoesung), soundex($studentSolution));
+
+ if ($levenshtein == 0) {
+ $similarity_temp = 0.8;
+ } else if ($levenshtein == 1) {
+ $similarity_temp = 0.6;
+ } else if ($levenshtein == 2) {
+ $similarity_temp = 0.4;
+ } else if ($levenshtein == 3) {
+ $similarity_temp = 0.2;
+ } else {// $levenshtein == 4
+ $similarity_temp = 0;
+ }
+ } else if ($this->task['compare'] === 'numeric') {
+ $correct = $this->normalizeFloat($answer['text'], $correct_unit);
+ $student = $this->normalizeFloat($response[0], $student_unit);
+
+ if ($correct_unit === $student_unit) {
+ if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) {
+ $similarity_temp = 1;
+ } else {
+ $safe = true;
+ }
+ }
+ }
+
+ if ($answer['score'] == 1) { // correct
+ if ($similarity_temp > $similarity) {
+ $similarity = $similarity_temp;
+ $safe = $similarity_temp == 1;
+ }
+ } else if ($answer['score'] == 0.5) { // half correct
+ if ($similarity_temp > $similarity) {
+ $similarity = $similarity_temp * 0.5;
+ $safe = $similarity_temp == 1;
+ }
+ } else if ($similarity_temp == 1) { // false
+ $similarity = 0;
+ $safe = true;
+ break;
+ }
+ }
+
+ $result[] = ['points' => $similarity, 'safe' => $safe];
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['Frage', 'Eingabehilfe', 'Abgleich', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Levenshtein') {
+ $this->task['compare'] = 'levenshtein';
+ } else if (current($tag) === 'Soundex') {
+ $this->task['compare'] = 'soundex';
+ }
+ }
+
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 1
+ ];
+ } else if (key($tag) === '~Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 0.5
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 0
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (float) $answer['score']
+ ];
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'levenshtein':
+ case 'soundex':
+ $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type'];
+ break;
+ case 'numeric':
+ $this->task['compare'] = 'numeric';
+ $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'};
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing a TextLineTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Returns all the correct answers in an array.
+ */
+ public function correctAnswers(): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['score'] == 1) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+}
diff --git a/lib/models/vips/TextTask.php b/lib/models/vips/TextTask.php
new file mode 100644
index 0000000..5684195
--- /dev/null
+++ b/lib/models/vips/TextTask.php
@@ -0,0 +1,279 @@
+<?php
+/*
+ * TextTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class TextTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('edit', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Mehrzeilige Textantwort oder Dateiabgabe');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(trim($request['answer_0'])),
+ 'score' => 1
+ ];
+
+ $this->task['template'] = trim($request['answer_default']);
+ $this->task['compare'] = $request['compare'];
+
+ if ($request['layout']) {
+ $this->task['layout'] = $request['layout'];
+ }
+
+ if ($request['layout'] === 'markup') {
+ $this->task['template'] = Studip\Markup::purifyHtml($this->task['template']);
+ }
+
+ if ($request['file_upload'] || $request['layout'] === 'none') {
+ $this->options['file_upload'] = 1;
+ }
+ }
+
+ /**
+ * Exercise handler to be called when a solution is corrected.
+ */
+ public function correctSolutionAction(Trails\Controller $controller, VipsSolution $solution): void
+ {
+ $commented_solution = Request::get('commented_solution');
+
+ if (isset($commented_solution)) {
+ $solution->commented_solution = Studip\Markup::purifyHtml(trim($commented_solution));
+ } else {
+ $solution->commented_solution = null;
+ }
+
+ if (Request::submitted('delete_commented_solution')) {
+ $solution->commented_solution = null;
+ $solution->store();
+
+ PageLayout::postSuccess(_('Die kommentierte Lösung wurde gelöscht.'));
+ }
+ }
+
+ /**
+ * Return the layout of this task (text, markup, code or none).
+ */
+ public function getLayout(): string
+ {
+ return $this->task['layout'] ?? 'text';
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $answerDefault = Studip\Markup::removeHtml($this->task['template']);
+ $musterLoesung = Studip\Markup::removeHtml($this->task['answers'][0]['text']);
+ $studentSolution = Studip\Markup::removeHtml($solution->response[0]);
+
+ $answerDefault = $this->normalizeText($answerDefault, true);
+ $studentSolution = $this->normalizeText($studentSolution, true);
+ $musterLoesung = $this->normalizeText($musterLoesung, true);
+
+ if ($studentSolution == '' || $studentSolution == $answerDefault) {
+ $has_files = $solution->folder && count($solution->folder->file_refs);
+ $result[] = ['points' => 0, 'safe' => !$has_files ? true : null];
+ } else if ($musterLoesung == $studentSolution) {
+ $result[] = ['points' => 1, 'safe' => true];
+ } else if ($this->task['compare'] === 'levenshtein') {
+ $string1 = mb_substr($studentSolution, 0, 500);
+ $string2 = mb_substr($musterLoesung, 0, 500);
+ $string3 = mb_substr($answerDefault, 0, 500);
+ $divisor = $this->levenshtein($string3, $string2) ?: 1;
+
+ $levenshtein = $this->levenshtein($string1, $string2) / $divisor;
+ $similarity = max(1 - $levenshtein, 0);
+ $result[] = ['points' => $similarity, 'safe' => false];
+ } else {
+ $result[] = ['points' => 0, 'safe' => null];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return [$this->task['template']];
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = parent::responseFromRequest($request);
+
+ if ($this->getLayout() === 'markup') {
+ $result = array_map('Studip\Markup::purifyHtml', $result);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Construct a new solution object from the request post data.
+ */
+ public function getSolutionFromRequest($request, ?array $files = null): VipsSolution
+ {
+ $solution = parent::getSolutionFromRequest($request, $files);
+ $upload = $files['upload'] ?: ['name' => []];
+ $solution_files = [];
+
+ if ($this->options['file_upload']) {
+ if ($files['upload']) {
+ $solution->options['upload'] = $files['upload'];
+ }
+
+ $solution->store();
+ $folder = Folder::findTopFolder($solution->id, 'ResponseFolder', 'response');
+
+ if (is_array($request['file_ids'])) {
+ foreach ($request['file_ids'] as $file_id) {
+ $file_ref = FileRef::find($file_id);
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent());
+ }
+ }
+
+ FileManager::handleFileUpload($upload, $folder->getTypedFolder());
+ }
+
+ return $solution;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['Offene Frage', 'Eingabehilfe', 'Abgleich', 'Vorgabe', 'Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Levenshtein') {
+ $this->task['compare'] = 'levenshtein';
+ }
+ }
+
+ if (key($tag) === 'Vorgabe') {
+ $this->task['template'] = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Antwort') {
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 1
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ if ($answer['score'] == '1') {
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => 1
+ ];
+ } else if ($answer['default'] == 'true') {
+ $this->task['template'] = Studip\Markup::purifyHtml(trim($answer));
+ }
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'levenshtein':
+ $this->task['compare'] = 'levenshtein';
+ }
+ }
+
+ if ($exercise->items->item->{'submission-hints'}->input) {
+ switch ($exercise->items->item->{'submission-hints'}->input['type']) {
+ case 'markup':
+ $this->task['layout'] = 'markup';
+ break;
+ case 'code':
+ $this->task['layout'] = 'code';
+ break;
+ case 'none':
+ $this->task['layout'] = 'none';
+ }
+ }
+
+ if ($exercise->items->item->{'submission-hints'}->attachments) {
+ if ($exercise->items->item->{'submission-hints'}->attachments['upload'] == 'true') {
+ $this->options['file_upload'] = 1;
+ }
+ }
+ }
+}
diff --git a/lib/models/vips/VipsAssignment.php b/lib/models/vips/VipsAssignment.php
new file mode 100644
index 0000000..d73d62a
--- /dev/null
+++ b/lib/models/vips/VipsAssignment.php
@@ -0,0 +1,1308 @@
+<?php
+/*
+ * VipsAssignment.php - Vips test class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property int $test_id database column
+ * @property string|null $range_type database column
+ * @property string|null $range_id database column
+ * @property string $type database column
+ * @property int|null $start database column
+ * @property int|null $end database column
+ * @property int $active database column
+ * @property float $weight database column
+ * @property int|null $block_id database column
+ * @property JSONArrayObject $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property SimpleORMapCollection|VipsAssignmentAttempt[] $assignment_attempts has_many VipsAssignmentAttempt
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property Course|null $course belongs_to Course
+ * @property VipsBlock|null $block belongs_to VipsBlock
+ * @property VipsTest $test belongs_to VipsTest
+ */
+class VipsAssignment extends SimpleORMap
+{
+ public const RELEASE_STATUS_NONE = 0;
+ public const RELEASE_STATUS_POINTS = 1;
+ public const RELEASE_STATUS_COMMENTS = 2;
+ public const RELEASE_STATUS_CORRECTIONS = 3;
+ public const RELEASE_STATUS_SAMPLE_SOLUTIONS = 4;
+
+ public const SCORING_DEFAULT = 0;
+ public const SCORING_NEGATIVE_POINTS = 1;
+ public const SCORING_ALL_OR_NOTHING = 2;
+
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_assignments';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['has_many']['assignment_attempts'] = [
+ 'class_name' => VipsAssignmentAttempt::class,
+ 'assoc_foreign_key' => 'assignment_id'
+ ];
+ $config['has_many']['solutions'] = [
+ 'class_name' => VipsSolution::class,
+ 'assoc_foreign_key' => 'assignment_id'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+ $config['belongs_to']['block'] = [
+ 'class_name' => VipsBlock::class,
+ 'foreign_key' => 'block_id'
+ ];
+ $config['belongs_to']['test'] = [
+ 'class_name' => VipsTest::class,
+ 'foreign_key' => 'test_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (is_null($this->options)) {
+ $this->options = [];
+ }
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ Grading\Definition::deleteBySQL('id = ?', [$gradebook_id]);
+ }
+
+ VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$this->id]);
+
+ $ref_count = self::countBySql('test_id = ?', [$this->test_id]);
+
+ if ($ref_count === 1) {
+ $this->test->delete();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Find all assignments for a given range_id.
+ *
+ * @return VipsAssignment[]
+ */
+ public static function findByRangeId($range_id)
+ {
+ return VipsAssignment::findBySQL(
+ 'range_id = ? AND type IN (?) ORDER BY start',
+ [$range_id, ['exam', 'practice', 'selftest']]
+ );
+ }
+
+ public static function importText(
+ string $title,
+ string $string,
+ string $user_id,
+ string $course_id
+ ): VipsAssignment {
+ $duration = 7 * 24 * 60 * 60; // one week
+
+ $data_test = [
+ 'title' => $title !== '' ? $title : _('Aufgabenblatt'),
+ 'description' => '',
+ 'user_id' => $user_id
+ ];
+ $data = [
+ 'type' => 'practice',
+ 'range_id' => $course_id ?: $user_id,
+ 'range_type' => $course_id ? 'course' : 'user',
+ 'start' => strtotime(date('Y-m-d H:00:00')),
+ 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration))
+ ];
+
+ // remove comments
+ $string = preg_replace('/^#.*/m', '', $string);
+
+ // split into exercises
+ $segments = preg_split('/^Name:/m', $string);
+ array_shift($segments);
+
+ $test_obj = VipsTest::create($data_test);
+
+ $result = self::build($data);
+ $result->test = $test_obj;
+ $result->store();
+
+ foreach ($segments as $segment) {
+ try {
+ $new_exercise = Exercise::importText($segment);
+ $new_exercise->user_id = $user_id;
+ $new_exercise->store();
+ $test_obj->addExercise($new_exercise);
+ } catch (Exception $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (isset($errors)) {
+ PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
+ }
+
+ return $result;
+ }
+
+ public static function importXML(
+ string $string,
+ string $user_id,
+ string $course_id
+ ): VipsAssignment {
+ // default options
+ $options = [
+ 'evaluation_mode' => 0,
+ 'released' => 0
+ ];
+
+ $duration = 7 * 24 * 60 * 60; // one week
+
+ $data_test = [
+ 'title' => _('Aufgabenblatt'),
+ 'description' => '',
+ 'user_id' => $user_id
+ ];
+ $data = [
+ 'type' => 'practice',
+ 'range_id' => $course_id ?: $user_id,
+ 'range_type' => $course_id ? 'course' : 'user',
+ 'start' => strtotime(date('Y-m-d H:00:00')),
+ 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration)),
+ 'options' => $options
+ ];
+
+ $test = new SimpleXMLElement($string, LIBXML_COMPACT | LIBXML_NOCDATA);
+ $data['type'] = (string) $test['type'];
+
+ if (trim($test->title) !== '') {
+ $data_test['title'] = trim($test->title);
+ }
+ if ($test->description) {
+ $data_test['description'] = Studip\Markup::purifyHtml(trim($test->description));
+ }
+ if ($test->notes) {
+ $data['options']['notes'] = trim($test->notes);
+ }
+
+ if ($test->limit['access-code']) {
+ $data['options']['access_code'] = (string) $test->limit['access-code'];
+ }
+ if ($test->limit['ip-ranges']) {
+ $data['options']['ip_range'] = (string) $test->limit['ip-ranges'];
+ }
+ if ($test->limit['resets']) {
+ $data['options']['resets'] = (int) $test->limit['resets'];
+ }
+ if ($test->limit['tries']) {
+ $data['options']['max_tries'] = (int) $test->limit['tries'];
+ }
+
+ if ($test->option['scoring-mode'] == 'negative_points') {
+ $data['options']['evaluation_mode'] = self::SCORING_NEGATIVE_POINTS;
+ } else if ($test->option['scoring-mode'] == 'all_or_nothing') {
+ $data['options']['evaluation_mode'] = self::SCORING_ALL_OR_NOTHING;
+ }
+ if ($test->option['shuffle-answers'] == 'true') {
+ $data['options']['shuffle_answers'] = 1;
+ }
+ if ($test->option['shuffle-exercises'] == 'true') {
+ $data['options']['shuffle_exercises'] = 1;
+ }
+
+ if ($test['start']) {
+ $data['start'] = strtotime($test['start']);
+ }
+ if ($test['end']) {
+ $data['end'] = strtotime($test['end']);
+ } else if ($data['type'] === 'selftest') {
+ $data['end'] = null;
+ }
+ if ($test['duration']) {
+ $data['options']['duration'] = (int) $test['duration'];
+ }
+ if ($test['block'] && $course_id) {
+ $block = VipsBlock::findOneBySQL('name = ? AND range_id = ?', [$test['block'], $course_id]);
+
+ if (!$block) {
+ $block = VipsBlock::create(['name' => $test['block'], 'range_id' => $course_id]);
+ }
+
+ $data['block_id'] = $block->id;
+ }
+
+ if ($test->{'feedback-items'}) {
+ foreach ($test->{'feedback-items'}->feedback as $feedback) {
+ $threshold = (int) ($feedback['score'] * 100);
+ $data['options']['feedback'][$threshold] = Studip\Markup::purifyHtml(trim($feedback));
+ }
+
+ krsort($data['options']['feedback']);
+ }
+
+ $test_obj = VipsTest::create($data_test);
+
+ $result = self::build($data);
+ $result->test = $test_obj;
+ $result->store();
+
+ if ($test->files) {
+ foreach ($test->files->file as $file) {
+ $file_id = (string) $file['id'];
+ $content = base64_decode((string) $file);
+
+ $test->registerXPathNamespace('vips', 'urn:vips:test:v1.0');
+ $file_refs = $test->xpath('vips:exercises/*/vips:file-refs/*[@ref="' . $file_id . '"]');
+
+ if ($file_refs && $content !== false) {
+ if (strlen($file_id) > 5 && str_starts_with($file_id, 'file-')) {
+ $vips_file = File::find(substr($file_id, 5));
+
+ // try to avoid reupload of identical files
+ if ($vips_file && sha1_file($vips_file->getPath()) === sha1($content)) {
+ $files[$file_id] = $vips_file;
+ continue;
+ }
+ }
+
+ $file = File::create([
+ 'user_id' => $user_id,
+ 'mime_type' => get_mime_type($file['name']),
+ 'name' => basename($file['name']),
+ 'size' => strlen($content)
+ ]);
+
+ file_put_contents($file->getPath(), $content);
+ }
+ }
+
+ if (isset($files)) {
+ $mapped = preg_replace_callback(
+ '/\burn:vips:file-ref:([A-Za-z_][\w.-]*)/',
+ function($match) use ($files) {
+ $file = $files[$match[1]];
+
+ if ($file) {
+ return htmlReady($file->getDownloadURL());
+ } else {
+ return $match[0];
+ }
+ }, $string
+ );
+ $test = new SimpleXMLElement($mapped, LIBXML_COMPACT | LIBXML_NOCDATA);
+ }
+ }
+
+ foreach ($test->exercises->exercise as $exercise) {
+ try {
+ $new_exercise = Exercise::importXML($exercise);
+ $new_exercise->user_id = $user_id;
+ $new_exercise->store();
+ $exercise_ref = $test_obj->addExercise($new_exercise);
+
+ if ($exercise['points']) {
+ $exercise_ref->points = (float) $exercise['points'];
+ $exercise_ref->store();
+ }
+
+ if ($exercise->{'file-refs'}) {
+ $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task');
+
+ foreach ($exercise->{'file-refs'}->{'file-ref'} as $file_ref) {
+ $file = $files[(string) $file_ref['ref']];
+
+ if ($file) {
+ FileRef::create([
+ 'file_id' => $file->id,
+ 'folder_id' => $folder->id,
+ 'object_id' => $new_exercise->id,
+ 'user_id' => $user_id,
+ 'name' => $file->name
+ ]);
+ }
+ }
+ }
+ } catch (Exception $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (isset($errors)) {
+ PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the name of this assignment type.
+ */
+ public function getTypeName(): string
+ {
+ $assignment_types = self::getAssignmentTypes();
+
+ return $assignment_types[$this->type]['name'];
+ }
+
+ /**
+ * Get the icon of this assignment type.
+ */
+ public function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ $assignment_types = self::getAssignmentTypes();
+
+ return Icon::create(
+ $assignment_types[$this->type]['icon'],
+ $role,
+ ['aria-hidden' => 'true', 'title' => $assignment_types[$this->type]['name']]
+ );
+ }
+
+ /**
+ * Get the list of supported assignment types.
+ */
+ public static function getAssignmentTypes(): array
+ {
+ return [
+ 'practice' => ['name' => _('Übung'), 'icon' => 'file'],
+ 'selftest' => ['name' => _('Selbsttest'), 'icon' => 'check-circle'],
+ 'exam' => ['name' => _('Klausur'), 'icon' => 'doctoral_cap']
+ ];
+ }
+
+ /**
+ * Check if this assignment is locked for editing.
+ */
+ public function isLocked(): bool
+ {
+ return $this->type === 'exam' && $this->countAssignmentAttempts() > 0;
+ }
+
+ /**
+ * Check if this assignment is visible to this user.
+ */
+ public function isVisible(string $user_id): bool
+ {
+ return $this->block_id ? $this->block->isVisible($user_id) : true;
+ }
+
+ /**
+ * Check if this assignment has been started.
+ */
+ public function isStarted(): bool
+ {
+ $now = time();
+
+ return $now >= $this->start;
+ }
+
+ /**
+ * Check if this assignment is currently running.
+ *
+ * @param string|null $user_id check end time for this user id (optional)
+ */
+ public function isRunning(?string $user_id = null): bool
+ {
+ $now = time();
+ $end = $user_id ? $this->getUserEndTime($user_id) : $this->end;
+
+ return $now >= $this->start && ($end === null || $now <= $end);
+ }
+
+ /**
+ * Check if this assignment is already finished.
+ *
+ * @param string|null $user_id check end time for this user id (optional)
+ */
+ public function isFinished(?string $user_id = null): bool
+ {
+ $now = time();
+ $end = $user_id ? $this->getUserEndTime($user_id) : $this->end;
+
+ return $end && $now > $end;
+ }
+
+ /**
+ * Check if this assignment has no end date.
+ */
+ public function isUnlimited(): bool
+ {
+ return $this->type === 'selftest' && $this->end === null;
+ }
+
+ /**
+ * Check if this assignment may use self assessment features.
+ */
+ public function isSelfAssessment(): bool
+ {
+ return $this->type === 'selftest' || $this->options['self_assessment'];
+ }
+
+ /**
+ * Check if a user may reset and restart this assignment.
+ */
+ public function isResetAllowed(): bool
+ {
+ return $this->isSelfAssessment() && $this->options['resets'] !== 0;
+ }
+
+ /**
+ * Check if this assignment presents shuffled exercises.
+ */
+ public function isExerciseShuffled(): bool
+ {
+ return $this->type === 'exam' && $this->options['shuffle_exercises'];
+ }
+
+ /**
+ * Check if this assignment presents shuffled answers.
+ */
+ public function isShuffled(): bool
+ {
+ return $this->type === 'exam' && $this->options['shuffle_answers'] !== 0;
+ }
+
+ /**
+ * Check if this assignment is using group solutions.
+ */
+ public function hasGroupSolutions(): bool
+ {
+ return $this->type === 'practice' && $this->options['use_groups'] !== 0;
+ }
+
+ /**
+ * Get the number of tries allowed for exercises on this assignment.
+ */
+ public function getMaxTries(): int
+ {
+ if ($this->type === 'selftest') {
+ return $this->options['max_tries'] ?? 3;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Check whether the given exercise is part of this assignment.
+ *
+ * @param int $exercise_id exercise id
+ */
+ public function hasExercise(int $exercise_id): bool
+ {
+ return VipsExerciseRef::exists([$this->test_id, $exercise_id]);
+ }
+
+ /**
+ * Return array of exercise refs in the test of this assignment.
+ */
+ public function getExerciseRefs(?string $user_id): array
+ {
+ $result = $this->test->exercise_refs->getArrayCopy();
+
+ if ($this->isExerciseShuffled() && $user_id) {
+ srand(crc32($this->id . ':' . $user_id));
+ shuffle($result);
+ srand();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export this assignment to XML format. Returns the XML string.
+ */
+ public function exportXML(): string
+ {
+ $files = [];
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $exercise = $exercise_ref->exercise;
+ $exercise->includeFilesForExport();
+
+ if ($exercise->folder) {
+ foreach ($exercise->folder->file_refs as $file_ref) {
+ $files[$file_ref->file_id] = $file_ref->file;
+ }
+ }
+ }
+
+ $template = VipsModule::$template_factory->open('sheets/export_assignment');
+ $template->assignment = $this;
+ $template->files = $files;
+
+ // delete all characters outside the valid character range for XML
+ // documents (#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]).
+ return preg_replace("/[^\t\n\r -\xFF]/", '', $template->render());
+ }
+
+ /**
+ * Check whether this assignment is editable by the given user.
+ *
+ * @param string|null $user_id user to check (defaults to current user)
+ */
+ public function checkEditPermission(?string $user_id = null): bool
+ {
+ if ($this->range_type === 'user') {
+ return $this->range_id === ($user_id ?: $GLOBALS['user']->id);
+ }
+
+ return $GLOBALS['perm']->have_studip_perm('tutor', $this->range_id, $user_id);
+ }
+
+ /**
+ * Check whether this assignment is viewable by the given user.
+ *
+ * @param string|null $user_id user to check (defaults to current user)
+ */
+ public function checkViewPermission(?string $user_id = null): bool
+ {
+ if ($this->range_type === 'user') {
+ return $this->range_id === ($user_id ?: $GLOBALS['user']->id);
+ }
+
+ return $GLOBALS['perm']->have_studip_perm('autor', $this->range_id, $user_id);
+ }
+
+ /**
+ * Check whether this assignment is accessible to a student. This is just
+ * a shortcut for checking: running, active, ip address and access code.
+ *
+ * @param string $user_id check end time for this user id (optional)
+ */
+ public function checkAccess($user_id = null): bool
+ {
+ return $this->isRunning($user_id)
+ && $this->active && $this->checkAccessCode()
+ && $this->checkIPAccess($_SERVER['REMOTE_ADDR']);
+ }
+
+ /**
+ * Check whether the access code provided for this assignment is valid.
+ * If $access_code is null, the code stored in the user session is used.
+ *
+ * @param string|null $access_code access code (optional)
+ */
+ public function checkAccessCode(?string $access_code = null): bool
+ {
+ if (isset($access_code)) {
+ $_SESSION['vips_access_' . $this->id] = $access_code;
+ } else if (isset($_SESSION['vips_access_' . $this->id])) {
+ $access_code = $_SESSION['vips_access_' . $this->id];
+ } else {
+ $access_code = null;
+ }
+
+ return in_array($this->options['access_code'], [null, $access_code], true);
+ }
+
+ /**
+ * Check whether the given IP address listed among the IP addresses given
+ * by the lecturer for this exam (if applicable).
+ *
+ * @param string $ip_addr IPv4 or IPv6 address
+ */
+ public function checkIPAccess(string $ip_addr): bool
+ {
+ // not an exam: user has access.
+ if ($this->type !== 'exam') {
+ return true;
+ }
+
+ $ip_addr = inet_pton($ip_addr);
+ $ip_ranges = $this->options['ip_range'];
+ $exam_rooms = Config::get()->VIPS_EXAM_ROOMS;
+
+ // expand exam room names
+ if ($exam_rooms) {
+ $ip_ranges = preg_replace_callback('/#([^ ,]+)/',
+ function($match) use ($exam_rooms) {
+ return $exam_rooms[$match[1]];
+ }, $ip_ranges);
+ }
+
+ // Explode space separated list into an array and check the resulting single IPs
+ $ip_ranges = preg_split('/[ ,]+/', $ip_ranges, -1, PREG_SPLIT_NO_EMPTY);
+
+ // No IP given: user has access.
+ if (count($ip_ranges) == 0) {
+ return true;
+ }
+
+ // One or more IPs are given and user IP matches at least one: user has access.
+ foreach ($ip_ranges as $ip_range) {
+ if (str_contains($ip_range, '/')) {
+ [$ip_range, $bits] = explode('/', $ip_range);
+ $ip_range = inet_pton($ip_range) ?: '';
+ $mask = str_repeat(chr(0), strlen($ip_range));
+
+ for ($i = 0; $i < strlen($mask); ++$i) {
+ if ($bits >= 8) {
+ $bits -= 8;
+ } else {
+ $mask[$i] = chr((1 << 8 - $bits) - 1);
+ $bits = 0;
+ }
+ }
+
+ $ip_start = $ip_range & ~$mask;
+ $ip_end = $ip_range | $mask;
+ } else {
+ if (str_contains($ip_range, '-')) {
+ [$ip_start, $ip_end] = explode('-', $ip_range);
+ } else {
+ $ip_start = $ip_end = $ip_range;
+ }
+
+ if (!str_contains($ip_range, ':')) {
+ $ip_start = implode('.', array_pad(explode('.', $ip_start), 4, 0));
+ $ip_end = implode('.', array_pad(explode('.', $ip_end), 4, 255));
+ }
+
+ $ip_start = inet_pton($ip_start);
+ $ip_end = inet_pton($ip_end);
+ }
+
+ if (strcmp($ip_start, $ip_addr) <= 0 && strcmp($ip_addr, $ip_end) <= 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the release status of this assignment for the given user.
+ *
+ * Valid values are:
+ * - 0 = not released
+ * - 1 = points
+ * - 2 = comments
+ * - 3 = corrections
+ * - 4 = sample solutions
+ *
+ * See the according constants of this class.
+ */
+ public function releaseStatus(string $user_id): int
+ {
+ if ($this->isFinished() || $this->isSelfAssessment() && $this->isFinished($user_id)) {
+ if ($this->type === 'exam') {
+ if ($this->getAssignmentAttempt($user_id)) {
+ return $this->options['released'] ?? self::RELEASE_STATUS_NONE;
+ }
+ } else {
+ if ($this->options['released'] > 0) {
+ return $this->options['released'];
+ }
+ }
+ }
+
+ return self::RELEASE_STATUS_NONE;
+ }
+
+ /**
+ * Count the number of assignment attempts for this assignment.
+ */
+ public function countAssignmentAttempts(): int
+ {
+ return VipsAssignmentAttempt::countBySql('assignment_id = ?', [$this->id]);
+ }
+
+ /**
+ * Get the assignment attempt of the given user for this assignment.
+ * Returns null if there is no assignment attempt for this user.
+ *
+ * @param string $user_id user id
+ */
+ public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ return VipsAssignmentAttempt::findOneBySQL('assignment_id = ? AND user_id = ?', [$this->id, $user_id]);
+ }
+
+ /**
+ * Record an assignment attempt for the given user for this assignment.
+ */
+ public function recordAssignmentAttempt(string $user_id): void
+ {
+ if (!$this->getAssignmentAttempt($user_id)) {
+ if ($this->type === 'exam') {
+ $end = time() + $this->options['duration'] * 60;
+ $ip_address = $_SERVER['REMOTE_ADDR'];
+ $options = ['session_id' => session_id()];
+ } else {
+ $end = null;
+ $ip_address = '';
+ $options = null;
+ }
+
+ VipsAssignmentAttempt::create([
+ 'assignment_id' => $this->id,
+ 'user_id' => $user_id,
+ 'start' => time(),
+ 'end' => $end,
+ 'ip_address' => $ip_address,
+ 'options' => $options
+ ]);
+ }
+ }
+
+ /**
+ * Finish an assignment attempt for the given user for this assignment.
+ */
+ public function finishAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ $assignment_attempt = $this->getAssignmentAttempt($user_id);
+ $now = time();
+
+ if ($assignment_attempt) {
+ if ($assignment_attempt->end === null || $assignment_attempt->end > $now) {
+ $assignment_attempt->end = $now;
+ $assignment_attempt->store();
+ }
+ }
+
+ return $assignment_attempt;
+ }
+
+ /**
+ * Get the individual end time of the given user for this assignment.
+ */
+ public function getUserEndTime(string $user_id): ?int
+ {
+ if ($this->type === 'practice') {
+ return $this->end;
+ }
+
+ $assignment_attempt = $this->getAssignmentAttempt($user_id);
+
+ if ($assignment_attempt) {
+ $start = $assignment_attempt->start;
+ } else {
+ $start = time();
+ }
+
+ if ($assignment_attempt && $assignment_attempt->end) {
+ return min($assignment_attempt->end, $this->end ?: $assignment_attempt->end);
+ } else if ($this->type === 'exam') {
+ return min($start + $this->options['duration'] * 60, $this->end);
+ } else {
+ return $this->end;
+ }
+ }
+
+ /**
+ * Get all members that were assigned to a particular group for
+ * this assignment.
+ *
+ * @param VipsGroup $group The group object
+ * @return VipsGroupMember[]
+ */
+ public function getGroupMembers($group): array
+ {
+ return VipsGroupMember::findBySQL(
+ 'group_id = ? AND start < ? AND (end > ? OR end IS NULL)',
+ [$group->id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Get the group the user was assigned to for this assignment.
+ * Returns null if there is no group assignment for this user.
+ */
+ public function getUserGroup(string $user_id): ?VipsGroup
+ {
+ if (!$this->hasGroupSolutions()) {
+ return null;
+ }
+
+ return VipsGroup::findOneBySQL(
+ 'JOIN etask_group_members ON group_id = statusgruppe_id
+ WHERE range_id = ?
+ AND user_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)',
+ [$this->range_id, $user_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Store a solution related to this assignment into the database.
+ *
+ * @param VipsSolution $solution The solution object
+ */
+ public function storeSolution(VipsSolution $solution): bool|int
+ {
+ $solution->assignment = $this;
+
+ // store some client info for exams
+ if ($this->type === 'exam') {
+ $solution->ip_address = $_SERVER['REMOTE_ADDR'];
+ $solution->options['session_id'] = session_id();
+ }
+
+ // in selftests, autocorrect solution
+ if ($this->isSelfAssessment()) {
+ $this->correctSolution($solution);
+ }
+
+ // insert new solution into etask_responses
+ return $solution->store();
+ }
+
+ /**
+ * Correct a solution and store the points for the solution in the object.
+ *
+ * @param VipsSolution $solution The solution object
+ * @param bool $corrected mark solution as corrected
+ */
+ public function correctSolution(VipsSolution $solution, bool $corrected = false): void
+ {
+ $exercise = $solution->exercise;
+ $exercise_ref = $this->test->getExerciseRef($exercise->id);
+ $max_points = (float) $exercise_ref->points;
+
+ // always set corrected to true for selftest exercises
+ $selftest = $this->type === 'selftest';
+ $evaluation = $exercise->evaluate($solution);
+ $eval_safe = $selftest ? $evaluation['safe'] !== null : $evaluation['safe'];
+
+ $reached_points = round($evaluation['percent'] * $max_points * 2) / 2;
+ $corrected = (int) ($corrected || $eval_safe);
+
+ // insert solution points
+ $solution->state = $corrected;
+ $solution->points = $reached_points;
+ $solution->chdate = time();
+
+ if ($selftest && $evaluation['percent'] != 1 && isset($exercise->options['feedback'])) {
+ $solution->feedback = $exercise->options['feedback'];
+ }
+ }
+
+ /**
+ * Restores an archived solution as the current solution.
+ *
+ * @param VipsSolution $solution The solution object
+ */
+ public function restoreSolution(VipsSolution $solution): void
+ {
+ if ($solution->isArchived() && $solution->assignment_id == $this->id) {
+ $new_solution = VipsSolution::build($solution);
+ $new_solution->id = 0;
+
+ if ($solution->folder) {
+ $new_solution->store();
+ $folder = Folder::findTopFolder($new_solution->id, 'ResponseFolder', 'response');
+
+ foreach ($solution->folder->file_refs as $file_ref) {
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), $file_ref->user);
+ }
+ }
+
+ $this->storeSolution($new_solution);
+ }
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedGroupSolutions(string $group_id, int $exercise_id): array
+ {
+ return VipsSolution::findBySQL(
+ 'JOIN etask_group_members USING(user_id)
+ WHERE task_id = ?
+ AND assignment_id = ?
+ AND group_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)
+ ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $group_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * NOTE: This method will NOT check the group solutions, if applicable.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedUserSolutions(string $user_id, int $exercise_id): array
+ {
+ return VipsSolution::findBySQL(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $user_id]
+ );
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedSolutions(string $user_id, int $exercise_id): array
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ return $this->getArchivedGroupSolutions($group->id, $exercise_id);
+ }
+
+ return $this->getArchivedUserSolutions($user_id, $exercise_id);
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getGroupSolution(string $group_id, int $exercise_id): ?VipsSolution
+ {
+ return VipsSolution::findOneBySQL(
+ 'JOIN etask_group_members USING(user_id)
+ WHERE task_id = ?
+ AND assignment_id = ?
+ AND group_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)
+ ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $group_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * NOTE: This method will NOT check the group solution, if applicable.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getUserSolution(string $user_id, int $exercise_id): ?VipsSolution
+ {
+ return VipsSolution::findOneBySQL(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $user_id]
+ );
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getSolution(string $user_id, int $exercise_id): ?VipsSolution
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ return $this->getGroupSolution($group->id, $exercise_id);
+ }
+
+ return $this->getUserSolution($user_id, $exercise_id);
+ }
+
+ /**
+ * Delete all solutions of the given user for a single exercise of
+ * this test from the DB.
+ */
+ public function deleteSolution(string $user_id, int $exercise_id): void
+ {
+ $sql = 'task_id = ? AND assignment_id = ? AND user_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$exercise_id, $this->id, $user_id]);
+ }
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries($user_id);
+ }
+
+ /**
+ * Delete all solutions of the given user for this test from the DB.
+ */
+ public function deleteSolutions(string $user_id): void
+ {
+ $sql = 'assignment_id = ? AND user_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$this->id, $user_id]);
+ }
+
+ // delete start times
+ VipsAssignmentAttempt::deleteBySQL($sql, [$this->id, $user_id]);
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries($user_id);
+ }
+
+ /**
+ * Delete all solutions of all users for this test from the DB.
+ */
+ public function deleteAllSolutions(): void
+ {
+ $sql = 'assignment_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$this->id]);
+ }
+
+ // delete start times
+ VipsAssignmentAttempt::deleteBySQL($sql, [$this->id]);
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries();
+ }
+
+ /**
+ * Count the number of solutions of the given user for this test.
+ */
+ public function countSolutions(string $user_id): int
+ {
+ $solutions = 0;
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ if ($this->getSolution($user_id, $exercise_ref->task_id)) {
+ ++$solutions;
+ }
+ }
+
+ return $solutions;
+ }
+
+ /**
+ * Return the points a user has reached in all exercises in this assignment.
+ */
+ public function getUserPoints(string $user_id): float|int
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ $user_ids = array_column($this->getGroupMembers($group), 'user_id');
+ } else {
+ $user_ids = [$user_id];
+ }
+
+ $solutions = $this->solutions->findBy('user_id', $user_ids)->orderBy('mkdate');
+ $points = [];
+
+ foreach ($solutions as $solution) {
+ $points[$solution->task_id] = (float) $solution->points;
+ }
+
+ return max(array_sum($points), 0);
+ }
+
+ /**
+ * Return the progress a user has achieved on this assignment (range 0..1).
+ */
+ public function getUserProgress(string $user_id): float|int
+ {
+ $group = $this->getUserGroup($user_id);
+ $max_points = 0;
+ $progress = 0;
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $max_points += $exercise_ref->points;
+
+ if ($group) {
+ $solution = $this->getGroupSolution($group->id, $exercise_ref->task_id);
+ } else {
+ $solution = $this->getUserSolution($user_id, $exercise_ref->task_id);
+ }
+
+ if ($solution) {
+ $progress += $exercise_ref->points;
+ }
+ }
+
+ return $max_points ? $progress / $max_points : 0;
+ }
+
+ /**
+ * Return the individual feedback text for the given user in this assignment.
+ */
+ public function getUserFeedback(string $user_id): ?string
+ {
+ if (isset($this->options['feedback'])) {
+ $user_points = $this->getUserPoints($user_id);
+ $max_points = $this->test->getTotalPoints();
+ $percent = $user_points / $max_points * 100;
+
+ foreach ($this->options['feedback'] as $threshold => $feedback) {
+ if ($percent >= $threshold) {
+ return $feedback;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Copy this assignment into the given course. Returns the new assignment.
+ */
+ public function copyIntoCourse(string $course_id, string $range_type = 'course'): ?VipsAssignment
+ {
+ // determine title of new assignment
+ if ($this->range_id === $course_id) {
+ $title = sprintf(_('Kopie von %s'), $this->test->title);
+ } else {
+ $title = $this->test->title;
+ }
+
+ // reset released option for new assignment
+ $options = $this->options;
+ unset($options['released']);
+ unset($options['stopdate']);
+ unset($options['gradebook_id']);
+
+ $new_test = VipsTest::create([
+ 'title' => $title,
+ 'description' => $this->test->description,
+ 'user_id' => $GLOBALS['user']->id
+ ]);
+
+ $new_assignment = VipsAssignment::create([
+ 'test_id' => $new_test->id,
+ 'range_id' => $course_id,
+ 'range_type' => $range_type,
+ 'type' => $this->type,
+ 'start' => $this->start,
+ 'end' => $this->end,
+ 'options' => $options
+ ]);
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $exercise_ref->copyIntoTest($new_test->id, $exercise_ref->position);
+ }
+
+ return $new_assignment;
+ }
+
+ /**
+ * Move this assignment into the given course.
+ */
+ public function moveIntoCourse(string $course_id, string $range_type = 'course'): void
+ {
+ if ($this->range_id !== $course_id) {
+ $this->range_id = $course_id;
+ $this->range_type = $range_type;
+ $this->block_id = null;
+ $this->removeFromGradebook();
+ $this->store();
+ }
+ }
+
+ /**
+ * Insert this assignment into the gradebook of its course.
+ *
+ * @param string $title gradebook title
+ * @param float $weight gradebook weight
+ */
+ public function insertIntoGradebook(string $title, float $weight = 1): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if (!$gradebook_id) {
+ $definition = Grading\Definition::create([
+ 'course_id' => $this->range_id,
+ 'item' => $this->id,
+ 'name' => $title,
+ 'tool' => _('Aufgaben'),
+ 'category' => $this->getTypeName(),
+ 'position' => $this->start,
+ 'weight' => $weight
+ ]);
+
+ $this->options['gradebook_id'] = $definition->id;
+ $this->store();
+ }
+ }
+
+ /**
+ * Remove this assignment from the gradebook of its course.
+ */
+ public function removeFromGradebook(): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ Grading\Definition::find($gradebook_id)->delete();
+
+ unset($this->options['gradebook_id']);
+ $this->store();
+ }
+ }
+
+ /**
+ * Update some or all gradebook entries of this assignment. If the
+ * user_id is specified, only update entries related to this user.
+ *
+ * @param string|null $user_id user id
+ */
+ public function updateGradebookEntries(?string $user_id = null): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ $max_points = $this->test->getTotalPoints() ?: 1;
+
+ if ($user_id) {
+ $group = $this->getUserGroup($user_id);
+ }
+
+ if ($group) {
+ $members = $this->getGroupMembers($group);
+ } else if ($user_id) {
+ $members = [(object) compact('user_id')];
+ } else {
+ $members = $this->course->members->findBy('status', 'autor');
+ }
+
+ foreach ($members as $member) {
+ $reached_points = $this->getUserPoints($member->user_id);
+ $entry = new Grading\Instance([$gradebook_id, $member->user_id]);
+
+ if ($reached_points) {
+ $entry->rawgrade = $reached_points / $max_points;
+ $entry->store();
+ } else {
+ $entry->delete();
+ }
+ }
+ }
+ }
+}
diff --git a/lib/models/vips/VipsAssignmentAttempt.php b/lib/models/vips/VipsAssignmentAttempt.php
new file mode 100644
index 0000000..9eba371
--- /dev/null
+++ b/lib/models/vips/VipsAssignmentAttempt.php
@@ -0,0 +1,99 @@
+<?php
+/*
+ * VipsAssignmentAttempt.php - Vips test attempt class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property int $assignment_id database column
+ * @property string $user_id database column
+ * @property int|null $start database column
+ * @property int|null $end database column
+ * @property string $ip_address database column
+ * @property JSONArrayObject|null $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property VipsAssignment $assignment belongs_to VipsAssignment
+ * @property User $user belongs_to User
+ */
+class VipsAssignmentAttempt extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_assignment_attempts';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['belongs_to']['assignment'] = [
+ 'class_name' => VipsAssignment::class,
+ 'foreign_key' => 'assignment_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Return a student's event log for the assignment as a data array.
+ */
+ public function getLogEntries(): array
+ {
+ $assignment = $this->assignment;
+ $user_id = $this->user_id;
+ $end_time = min($this->end, $assignment->end);
+
+ $solutions = VipsSolution::findBySQL('assignment_id = ? AND user_id = ?', [$assignment->id, $user_id]);
+
+ foreach ($assignment->test->exercise_refs as $exercise_ref) {
+ $position[$exercise_ref->task_id] = $exercise_ref->position;
+ }
+
+ $logs[] = [
+ 'label' => _('Beginn der Klausur'),
+ 'time' => $this->start,
+ 'ip_address' => $this->ip_address,
+ 'session_id' => $this->options['session_id'],
+ 'archived' => false
+ ];
+
+ foreach ($solutions as $solution) {
+ if ($solution->isSubmitted()) {
+ $logs[] = [
+ 'label' => sprintf(_('Abgabe Aufgabe %d'), $position[$solution->task_id]),
+ 'time' => $solution->mkdate,
+ 'ip_address' => $solution->ip_address,
+ 'session_id' => $solution->options['session_id'],
+ 'archived' => $solution->isArchived(),
+ ];
+ }
+ }
+
+ if ($end_time && $end_time < date('Y-m-d H:i:s')) {
+ $logs[] = [
+ 'label' => _('Ende der Klausur'),
+ 'time' => $end_time,
+ 'ip_address' => '',
+ 'session_id' => '',
+ 'archived' => false
+ ];
+ }
+
+ usort($logs, fn($a, $b) => $a['time'] <=> $b['time']);
+
+ return $logs;
+ }
+}
diff --git a/lib/models/vips/VipsBlock.php b/lib/models/vips/VipsBlock.php
new file mode 100644
index 0000000..2179254
--- /dev/null
+++ b/lib/models/vips/VipsBlock.php
@@ -0,0 +1,92 @@
+<?php
+/*
+ * VipsBlock.php - Vips block class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $name database column
+ * @property string $range_id database column
+ * @property string|null $group_id database column
+ * @property int $visible database column
+ * @property float|null $weight database column
+ * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment
+ * @property Course $course belongs_to Course
+ * @property Statusgruppen|null $group belongs_to Statusgruppen
+ */
+class VipsBlock extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_blocks';
+
+ $config['has_many']['assignments'] = [
+ 'class_name' => VipsAssignment::class,
+ 'assoc_foreign_key' => 'block_id'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+ $config['belongs_to']['group'] = [
+ 'class_name' => Statusgruppen::class,
+ 'foreign_key' => 'group_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ foreach ($this->assignments as $assignment) {
+ $assignment->block_id = null;
+ $assignment->store();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Check if this block is visible to this user.
+ */
+ public function isVisible(string $user_id): bool
+ {
+ $visible = $this->visible;
+
+ if ($visible && $this->group_id) {
+ $visible = StatusgruppeUser::exists([$this->group_id, $user_id]);
+ }
+
+ return $visible;
+ }
+
+ /**
+ * Get the first assignment attempt of the given user for this block.
+ * Returns null if there is no assignment attempt for this user.
+ *
+ * @param string $user_id user id
+ */
+ public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ $assignment_ids = $this->assignments->pluck('id');
+
+ return VipsAssignmentAttempt::findOneBySQL(
+ 'assignment_id IN (?) AND user_id = ? ORDER BY start', [$assignment_ids, $user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsExerciseRef.php b/lib/models/vips/VipsExerciseRef.php
new file mode 100644
index 0000000..3255e27
--- /dev/null
+++ b/lib/models/vips/VipsExerciseRef.php
@@ -0,0 +1,137 @@
+<?php
+/*
+ * VipsExerciseRef.php - Vips exercise reference class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property array $id alias for pk
+ * @property int $test_id database column
+ * @property int $task_id database column
+ * @property int $position database column
+ * @property int $part database column
+ * @property float|null $points database column
+ * @property string $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property Exercise $exercise belongs_to Exercise
+ * @property VipsTest $test belongs_to VipsTest
+ */
+class VipsExerciseRef extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_test_tasks';
+
+ $config['belongs_to']['exercise'] = [
+ 'class_name' => Exercise::class,
+ 'foreign_key' => 'task_id'
+ ];
+ $config['belongs_to']['test'] = [
+ 'class_name' => VipsTest::class,
+ 'foreign_key' => 'test_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Set value for the "exercise" relation (to avoid SORM errors).
+ */
+ public function setExercise(Exercise $exercise): void
+ {
+ $this->task_id = $exercise->id;
+ $this->relations['exercise'] = $exercise;
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ $ref_count = self::countBySql('task_id = ?', [$this->task_id]);
+
+ if ($ref_count == 1) {
+ $this->exercise->delete();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Copy the referenced exercise into the given test at the specified
+ * position (or at the end). Returns the new exercise reference.
+ *
+ * @param string $test_id test id
+ * @param int $position exercise position (optional)
+ */
+ public function copyIntoTest(string $test_id, ?int $position = null): VipsExerciseRef
+ {
+ $db = DBManager::get();
+
+ if ($position === null) {
+ $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?');
+ $stmt->execute([$test_id]);
+ $position = $stmt->fetchColumn() + 1;
+ }
+
+ $new_exercise = Exercise::create([
+ 'type' => $this->exercise->type,
+ 'title' => $this->exercise->title,
+ 'description' => $this->exercise->description,
+ 'task' => $this->exercise->task,
+ 'options' => $this->exercise->options,
+ 'user_id' => $GLOBALS['user']->id
+ ]);
+
+ if ($this->exercise->folder) {
+ $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task');
+
+ foreach ($this->exercise->folder->file_refs as $file_ref) {
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent());
+ }
+ }
+
+ return VipsExerciseRef::create([
+ 'task_id' => $new_exercise->id,
+ 'test_id' => $test_id,
+ 'points' => $this->points,
+ 'position' => $position
+ ]);
+ }
+
+ /**
+ * Move the referenced exercise into the given test (at the end).
+ *
+ * @param string $test_id test id
+ */
+ public function moveIntoTest(string $test_id): void
+ {
+ $db = DBManager::get();
+ $old_test_id = $this->test_id;
+ $old_position = $this->position;
+
+ if ($old_test_id != $test_id) {
+ $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?');
+ $stmt->execute([$test_id]);
+ $this->position = $stmt->fetchColumn() + 1;
+ $this->test_id = $test_id;
+ $this->store();
+
+ // renumber following exercises
+ $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?';
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$old_test_id, $old_position]);
+ }
+ }
+}
diff --git a/lib/models/vips/VipsGroup.php b/lib/models/vips/VipsGroup.php
new file mode 100644
index 0000000..8b43c2e
--- /dev/null
+++ b/lib/models/vips/VipsGroup.php
@@ -0,0 +1,79 @@
+<?php
+/*
+ * VipsGroup.php - Vips group class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property string $id alias column for statusgruppe_id
+ * @property string $statusgruppe_id database column
+ * @property string $name database column
+ * @property string|null $description database column
+ * @property string $range_id database column
+ * @property int $position database column
+ * @property int $size database column
+ * @property int $selfassign database column
+ * @property int $selfassign_start database column
+ * @property int $selfassign_end database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property int $calendar_group database column
+ * @property string|null $name_w database column
+ * @property string|null $name_m database column
+ * @property SimpleORMapCollection|VipsGroupMember[] $members has_many VipsGroupMember
+ * @property SimpleORMapCollection|VipsGroupMember[] $current_members has_many VipsGroupMember
+ * @property Course $course belongs_to Course
+ */
+class VipsGroup extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'statusgruppen';
+
+ $config['has_many']['members'] = [
+ 'class_name' => VipsGroupMember::class,
+ 'assoc_foreign_key' => 'group_id',
+ 'on_delete' => 'delete'
+ ];
+ $config['has_many']['current_members'] = [
+ 'class_name' => VipsGroupMember::class,
+ 'assoc_foreign_key' => 'group_id',
+ 'order_by' => 'AND end IS NULL'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Get the group the user is currently assigned to in a course.
+ * Returns null if there is no group assignment for this user.
+ *
+ * @param string $user_id user id
+ * @param string $course_id course id
+ */
+ public static function getUserGroup(string $user_id, string $course_id): ?VipsGroup
+ {
+ return self::findOneBySQL(
+ 'JOIN etask_group_members ON group_id = statusgruppe_id
+ WHERE range_id = ?
+ AND user_id = ?
+ AND end IS NULL',
+ [$course_id, $user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsGroupMember.php b/lib/models/vips/VipsGroupMember.php
new file mode 100644
index 0000000..c6be629
--- /dev/null
+++ b/lib/models/vips/VipsGroupMember.php
@@ -0,0 +1,50 @@
+<?php
+/*
+ * VipsGroupMember.php - Vips group member class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property array $id alias for pk
+ * @property string $group_id database column
+ * @property string $user_id database column
+ * @property int $start database column
+ * @property int|null $end database column
+ * @property VipsGroup $group belongs_to VipsGroup
+ * @property User $user belongs_to User
+ * @property mixed $vorname additional field
+ * @property mixed $nachname additional field
+ * @property mixed $username additional field
+ */
+class VipsGroupMember extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_group_members';
+
+ $config['additional_fields']['vorname'] = ['user', 'vorname'];
+ $config['additional_fields']['nachname'] = ['user', 'nachname'];
+ $config['additional_fields']['username'] = ['user', 'username'];
+
+ $config['belongs_to']['group'] = [
+ 'class_name' => VipsGroup::class,
+ 'foreign_key' => 'group_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+}
diff --git a/lib/models/vips/VipsSolution.php b/lib/models/vips/VipsSolution.php
new file mode 100644
index 0000000..14b9826
--- /dev/null
+++ b/lib/models/vips/VipsSolution.php
@@ -0,0 +1,160 @@
+<?php
+/*
+ * VipsSolution.php - Vips solution class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ *
+ * @property int $id database column
+ * @property int $assignment_id database column
+ * @property int $task_id database column
+ * @property string $user_id database column
+ * @property JSONArrayObject $response database column
+ * @property string|null $student_comment database column
+ * @property string $ip_address database column
+ * @property int|null $state database column
+ * @property float|null $points database column
+ * @property string|null $feedback database column
+ * @property string|null $commented_solution database column
+ * @property string|null $grader_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property Exercise $exercise belongs_to Exercise
+ * @property VipsAssignment $assignment belongs_to VipsAssignment
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property Folder $feedback_folder has_one Folder
+ */
+class VipsSolution extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_responses';
+
+ $config['serialized_fields']['response'] = JSONArrayObject::class;
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['registered_callbacks']['after_store'][] = 'after_store';
+
+ $config['has_one']['folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->getId(), 'ResponseFolder'],
+ 'on_delete' => 'delete'
+ ];
+ $config['has_one']['feedback_folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->getId(), 'FeedbackFolder'],
+ 'on_delete' => 'delete'
+ ];
+
+ $config['belongs_to']['exercise'] = [
+ 'class_name' => Exercise::class,
+ 'foreign_key' => 'task_id'
+ ];
+ $config['belongs_to']['assignment'] = [
+ 'class_name' => VipsAssignment::class,
+ 'foreign_key' => 'assignment_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Update the gradebook entry.
+ */
+ public function after_store(): void
+ {
+ $this->assignment->updateGradebookEntries($this->user_id);
+ }
+
+ /**
+ * Set value for the "exercise" relation (to avoid SORM errors).
+ */
+ public function setExercise(Exercise $exercise): void
+ {
+ $this->task_id = $exercise->id;
+ $this->relations['exercise'] = $exercise;
+ }
+
+ /**
+ * Get array of submitted answers for this solution (PHP array).
+ */
+ public function getResponse(): array
+ {
+ return $this->content['response']->getArrayCopy();
+ }
+
+ /**
+ * Check if this solution is archived.
+ */
+ public function isArchived(): bool
+ {
+ $solution = VipsSolution::findOneBySql(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY id DESC',
+ [$this->task_id, $this->assignment_id, $this->user_id]
+ );
+
+ return $solution && $this->id != $solution->id;
+ }
+
+ /**
+ * Check if this solution is empty (default response and no files).
+ */
+ public function isEmpty(): bool
+ {
+ return $this->response == $this->exercise->defaultResponse()
+ && $this->student_comment == ''
+ && (!$this->folder || count($this->folder->file_refs) === 0);
+ }
+
+ /**
+ * Check if this solution has been submitted (is not a dummy solution).
+ */
+ public function isSubmitted(): bool
+ {
+ return $this->id && !$this->mkdate;
+ }
+
+ /**
+ * Check if this solution has any corrector feedback (text or files).
+ */
+ public function hasFeedback()
+ {
+ return $this->feedback
+ || ($this->feedback_folder && count($this->feedback_folder->file_refs) > 0);
+ }
+
+ /**
+ * Return the total number of solutions (including archived ones)
+ * submitted by the same user for this exercise.
+ */
+ public function countTries(): int
+ {
+ if ($this->isNew()) {
+ return 0;
+ }
+
+ return VipsSolution::countBySql(
+ 'task_id = ? AND assignment_id = ? AND user_id = ?',
+ [$this->task_id, $this->assignment_id, $this->user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsTest.php b/lib/models/vips/VipsTest.php
new file mode 100644
index 0000000..178b352
--- /dev/null
+++ b/lib/models/vips/VipsTest.php
@@ -0,0 +1,121 @@
+<?php
+/*
+ * VipsTest.php - Vips test class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property string|null $options database column
+ * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property User $user belongs_to User
+ * @property SimpleORMapCollection|Exercise[] $exercises has_and_belongs_to_many Exercise
+ */
+class VipsTest extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_tests';
+
+ // $config['serialized_fields']['options'] = 'JSONArrayObject';
+
+ $config['has_and_belongs_to_many']['exercises'] = [
+ 'class_name' => Exercise::class,
+ 'assoc_foreign_key' => 'id',
+ 'thru_table' => 'etask_test_tasks',
+ 'thru_key' => 'test_id',
+ 'thru_assoc_key' => 'task_id',
+ 'order_by' => 'ORDER BY position'
+ ];
+
+ $config['has_many']['assignments'] = [
+ 'class_name' => VipsAssignment::class,
+ 'assoc_foreign_key' => 'test_id'
+ ];
+ $config['has_many']['exercise_refs'] = [
+ 'class_name' => VipsExerciseRef::class,
+ 'assoc_foreign_key' => 'test_id',
+ 'on_delete' => 'delete',
+ 'order_by' => 'ORDER BY position'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ public function addExercise(Exercise $exercise): VipsExerciseRef
+ {
+ $attributes = [
+ 'task_id' => $exercise->id,
+ 'test_id' => $this->id,
+ 'position' => count($this->exercise_refs) + 1,
+ 'points' => $exercise->itemCount()
+ ];
+
+ $exercise_ref = VipsExerciseRef::create($attributes);
+
+ $this->resetRelation('exercises');
+ $this->resetRelation('exercise_refs');
+
+ return $exercise_ref;
+ }
+
+ public function removeExercise(int $exercise_id): void
+ {
+ $db = DBManager::get();
+
+ $exercise_ref = VipsExerciseRef::find([$this->id, $exercise_id]);
+ $position = $exercise_ref->position;
+
+ if ($exercise_ref->delete()) {
+ // renumber following exercises
+ $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?';
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$this->id, $position]);
+ }
+
+ $this->resetRelation('exercises');
+ $this->resetRelation('exercise_refs');
+ }
+
+ public function getExerciseRef(int $exercise_id): ?VipsExerciseRef
+ {
+ return $this->exercise_refs->findOneBy('task_id', $exercise_id);
+ }
+
+ /**
+ * Return the maximum number of points a person can get on this test.
+ *
+ * @return integer number of maximum points
+ */
+ public function getTotalPoints(): int
+ {
+ $points = 0;
+
+ foreach ($this->exercise_refs as $exercise_ref) {
+ $points += $exercise_ref->points;
+ }
+
+ return $points;
+ }
+}
diff --git a/lib/modules/VipsModule.php b/lib/modules/VipsModule.php
new file mode 100644
index 0000000..9c37c3a
--- /dev/null
+++ b/lib/modules/VipsModule.php
@@ -0,0 +1,471 @@
+<?php
+/*
+ * VipsModule.php - Vips plugin class for Stud.IP
+ * Copyright (c) 2007-2021 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+use Courseware\CoursewarePlugin;
+
+/**
+ * Vips plugin class for Stud.IP
+ */
+class VipsModule extends CorePlugin implements StudipModule, SystemPlugin, PrivacyPlugin, CoursewarePlugin
+{
+ public static ?bool $exam_mode = null;
+ public static ?VipsModule $instance = null;
+ public static ?Flexi\Factory $template_factory = null;
+
+ public function __construct()
+ {
+ global $perm, $user;
+
+ parent::__construct();
+
+ self::$instance = $this;
+ self::$template_factory = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/app/views/vips');
+
+ NotificationCenter::addObserver($this, 'userDidDelete', 'UserDidDelete');
+ NotificationCenter::addObserver($this, 'courseDidDelete', 'CourseDidDelete');
+ NotificationCenter::addObserver($this, 'userDidLeaveCourse', 'UserDidLeaveCourse');
+ NotificationCenter::addObserver($this, 'userDidMigrate', 'UserDidMigrate');
+ NotificationCenter::addObserver($this, 'statusgruppeUserDidCreate', 'StatusgruppeUserDidCreate');
+ NotificationCenter::addObserver($this, 'statusgruppeUserDidDelete', 'StatusgruppeUserDidDelete');
+
+ Exercise::addExerciseType(_('Single Choice'), SingleChoiceTask::class, ['choice-single', '']);
+ Exercise::addExerciseType(_('Multiple Choice'), MultipleChoiceTask::class, 'choice-multiple');
+ Exercise::addExerciseType(_('Multiple Choice Matrix'), MatrixChoiceTask::class, 'choice-matrix');
+ Exercise::addExerciseType(_('Freie Antwort'), TextLineTask::class, 'text-line');
+ Exercise::addExerciseType(_('Textaufgabe'), TextTask::class, 'text-area');
+ Exercise::addExerciseType(_('Lückentext'), ClozeTask::class, ['cloze-input', 'cloze-select', 'cloze-drag']);
+ Exercise::addExerciseType(_('Zuordnung'), MatchingTask::class, ['matching', 'matching-multiple']);
+ Exercise::addExerciseType(_('Reihenfolge'), SequenceTask::class, 'sequence');
+
+ if ($perm->have_perm('root')) {
+ $nav_item = new Navigation(_('Klausuren'), 'dispatch.php/vips/config');
+ Navigation::addItem('/admin/config/vips', $nav_item);
+ }
+
+ if (Navigation::hasItem('/contents')) {
+ $nav_item = new Navigation(_('Aufgaben'));
+ $nav_item->setImage(Icon::create('vips'));
+ $nav_item->setDescription(_('Erstellen und Verwalten von Aufgabenblättern'));
+ Navigation::addItem('/contents/vips', $nav_item);
+
+ $sub_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/pool/assignments');
+ $nav_item->addSubNavigation('assignments', $sub_item);
+
+ $sub_item = new Navigation(_('Aufgaben'), 'dispatch.php/vips/pool/exercises');
+ $nav_item->addSubNavigation('exercises', $sub_item);
+ }
+
+ // check for running exams
+ if (Config::get()->VIPS_EXAM_RESTRICTIONS && !isset(self::$exam_mode)) {
+ $courses = self::getCoursesWithRunningExams($user->id);
+ self::$exam_mode = count($courses) > 0;
+
+ if (self::$exam_mode) {
+ $page = basename($_SERVER['PHP_SELF']);
+ $path_info = Request::pathInfo();
+ $course_id = Context::getId();
+
+ // redirect page calls if necessary
+ if (match_route('dispatch.php/jsupdater/get')) {
+ // always allow jsupdater calls
+ UpdateInformation::setInformation('vips', ['exam_mode' => true]);
+ } else if (isset($course_id, $courses[$course_id])) {
+ // course with running exam is selected, allow all exam actions
+ if (!match_route('dispatch.php/vips/sheets')) {
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets'));
+ sess()->save();
+ die();
+ }
+ } else if (count($courses) === 1) {
+ // only one course with running exam, redirect there
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets', ['cid' => key($courses)]));
+ sess()->save();
+
+ die();
+ } else if (!match_route('dispatch.php/vips/exam_mode')) {
+ // forward to overview of all running courses with exams
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/exam_mode'));
+ sess()->save();
+ die();
+ }
+ } else {
+ PageLayout::addHeadElement(
+ 'script',
+ [],
+ 'STUDIP.JSUpdater.register("vips", () => location.reload());'
+ );
+ }
+ }
+ }
+
+ /**
+ * Return whether or not the current user has the given status in a course.
+ *
+ * @param string $status status name: 'autor', 'tutor' or 'dozent'
+ * @param string $course_id course to check
+ */
+ public static function hasStatus(string $status, string $course_id): bool
+ {
+ return $course_id && $GLOBALS['perm']->have_studip_perm($status, $course_id);
+ }
+
+ /**
+ * Check whether or not the current user has the required status in a course.
+ *
+ * @param string $status required status: 'autor', 'tutor' or 'dozent'
+ * @param string $course_id course to check
+ * @throws AccessDeniedException if the requirement is not met, an exception is thrown
+ */
+ public static function requireStatus(string $status, string $course_id): void
+ {
+ if (!VipsModule::hasStatus($status, $course_id)) {
+ throw new AccessDeniedException(_('Sie verfügen nicht über die notwendigen Rechte für diese Aktion.'));
+ }
+ }
+
+ /**
+ * Checks whether or not the current user may view an assignment.
+ *
+ * @param VipsAssignment|null $assignment assignment to check
+ * @param int|null $exercise_id check that this exercise is on the assignment (optional)
+ * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown
+ */
+ public static function requireViewPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void
+ {
+ if (!$assignment || !$assignment->checkViewPermission()) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!'));
+ }
+
+ if ($exercise_id && !$assignment->hasExercise($exercise_id)) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!'));
+ }
+ }
+
+ /**
+ * Checks whether or not the current user may edit an assignment.
+ *
+ * @param VipsAssignment|null $assignment assignment to check
+ * @param int|null $exercise_id check that this exercise is on the assignment (optional)
+ * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown
+ */
+ public static function requireEditPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void
+ {
+ if (!$assignment || !$assignment->checkEditPermission()) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!'));
+ }
+
+ if ($exercise_id && !$assignment->hasExercise($exercise_id)) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!'));
+ }
+ }
+
+ /**
+ * Get all courses where the user is at least tutor and Vips is activated.
+ *
+ * @return array with all course ids, null if no courses
+ */
+ public static function getActiveCourses(string $user_id): array
+ {
+ $plugin_manager = PluginManager::getInstance();
+ $vips_plugin_id = VipsModule::$instance->getPluginId();
+
+ $sql = "JOIN seminar_user USING(Seminar_id)
+ WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor')
+ ORDER BY (SELECT MIN(beginn) FROM semester_data
+ JOIN semester_courses USING(semester_id)
+ WHERE course_id = Seminar_id) DESC, Name";
+ $courses = Course::findBySQL($sql, [$user_id]);
+
+ // remove courses where Vips is not active
+ foreach ($courses as $key => $course) {
+ if (!$plugin_manager->isPluginActivated($vips_plugin_id, $course->id)) {
+ unset($courses[$key]);
+ }
+ }
+
+ return $courses;
+ }
+
+ /**
+ * Get all courses with currently running exams for the given user.
+ *
+ * @param string $user_id The user id
+ *
+ * @return array associative array of course ids and course names
+ */
+ public static function getCoursesWithRunningExams(string $user_id): array
+ {
+ $db = DBManager::get();
+
+ $courses = [];
+
+ $sql = "SELECT DISTINCT seminare.Seminar_id, seminare.Name, etask_assignments.id
+ FROM etask_assignments
+ JOIN seminar_user ON seminar_user.Seminar_id = etask_assignments.range_id
+ JOIN seminare USING(Seminar_id)
+ WHERE etask_assignments.type = 'exam'
+ AND etask_assignments.start <= UNIX_TIMESTAMP()
+ AND etask_assignments.end > UNIX_TIMESTAMP()
+ AND seminar_user.user_id = ?
+ AND seminar_user.status = 'autor'
+ ORDER BY seminare.Name";
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$user_id]);
+
+ foreach ($stmt as $row) {
+ $assignment = VipsAssignment::find($row['id']);
+ $ip_range = $assignment->options['ip_range'];
+
+ if ($assignment->isVisible($user_id)) {
+ if (strlen($ip_range) > 0 && $assignment->checkIPAccess($_SERVER['REMOTE_ADDR'])) {
+ $courses[$row['Seminar_id']] = $row['Name'];
+ }
+ }
+ }
+
+ return $courses;
+ }
+
+ public function setupExamNavigation()
+ {
+ $navigation = new Navigation('');
+
+ $start = Navigation::getItem('/start');
+ $start->setURL('dispatch.php/vips/exam_mode');
+ $navigation->addSubNavigation('start', $start);
+
+ $course = new Navigation(_('Veranstaltung'));
+ $navigation->addSubNavigation('course', $course);
+
+ $vips = new Navigation($this->getPluginName());
+ $vips->setImage(Icon::create('vips'));
+ $course->addSubNavigation('vips', $vips);
+
+ $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets');
+ $vips->addSubNavigation('sheets', $nav_item);
+
+ $links = new Navigation('Links');
+ $links->addSubNavigation('logout', new Navigation(_('Logout'), 'logout.php'));
+ $navigation->addSubNavigation('links', $links);
+
+ Config::get()->PERSONAL_NOTIFICATIONS_ACTIVATED = 0;
+ PageLayout::addStyle('#navigation-level-1, #navigation-level-2, #context-title { display: none; }');
+ PageLayout::addCustomQuicksearch('<div style="width: 64px;"></div>');
+ Navigation::setRootNavigation($navigation);
+ }
+
+ public function getIconNavigation($course_id, $last_visit, $user_id)
+ {
+ if (VipsModule::hasStatus('tutor', $course_id)) {
+ // find all uncorrected exercises in finished assignments in this course
+ // Added JOIN with seminar_user to filter out lecturer/tutor solutions.
+ $new_items = VipsSolution::countBySql(
+ "JOIN etask_assignments ON etask_responses.assignment_id = etask_assignments.id
+ LEFT JOIN seminar_user
+ ON seminar_user.Seminar_id = etask_assignments.range_id
+ AND seminar_user.user_id = etask_responses.user_id
+ WHERE etask_assignments.range_id = ?
+ AND etask_assignments.type IN ('exam', 'practice', 'selftest')
+ AND etask_assignments.end <= UNIX_TIMESTAMP()
+ AND etask_responses.state = 0
+ AND IFNULL(seminar_user.status, 'autor') = 'autor'",
+ [$course_id]
+ );
+
+ $message = ngettext('%d unkorrigierte Lösung', '%d unkorrigierte Lösungen', $new_items);
+ } else {
+ // find all active assignments not yet seen by the student
+ $assignments = VipsAssignment::findBySQL(
+ "LEFT JOIN etask_assignment_attempts
+ ON etask_assignment_attempts.assignment_id = etask_assignments.id
+ AND etask_assignment_attempts.user_id = ?
+ WHERE etask_assignments.range_id = ?
+ AND etask_assignments.type IN ('exam', 'practice', 'selftest')
+ AND etask_assignments.start <= UNIX_TIMESTAMP()
+ AND (etask_assignments.end IS NULL OR etask_assignments.end > UNIX_TIMESTAMP())
+ AND etask_assignment_attempts.user_id IS NULL",
+ [$user_id, $course_id]
+ );
+
+ $new_items = 0;
+
+ foreach ($assignments as $assignment) {
+ if ($assignment->isVisible($user_id)) {
+ ++$new_items;
+ }
+ }
+
+ $message = ngettext('%d neues Aufgabenblatt', '%d neue Aufgabenblätter', $new_items);
+ }
+
+ $overview_message = $this->getPluginName();
+ $icon = Icon::create('vips');
+
+ if ($new_items > 0) {
+ $overview_message = sprintf($message, $new_items);
+ $icon = Icon::create('vips', Icon::ROLE_NEW);
+ }
+
+ $icon_navigation = new Navigation($this->getPluginName(), 'dispatch.php/vips/sheets');
+ $icon_navigation->setImage($icon->copyWithAttributes(['title' => $overview_message]));
+
+ return $icon_navigation;
+ }
+
+ public function getInfoTemplate($course_id)
+ {
+ return null;
+ }
+
+ public function getTabNavigation($course_id)
+ {
+ $navigation = new Navigation($this->getPluginName());
+ $navigation->setImage(Icon::create('vips'));
+
+ $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets');
+ $navigation->addSubNavigation('sheets', $nav_item);
+
+ $nav_item = new Navigation(_('Ergebnisse'), 'dispatch.php/vips/solutions');
+ $navigation->addSubNavigation('solutions', $nav_item);
+
+ return ['vips' => $navigation];
+ }
+
+ public function getMetadata()
+ {
+ $metadata['category'] = _('Inhalte und Aufgabenstellungen');
+ $metadata['displayname'] = _('Aufgaben und Prüfungen');
+ $metadata['summary'] =
+ _('Erstellung und Durchführung von Übungen, Tests und Klausuren');
+ $metadata['description'] =
+ _('Mit diesem Werkzeug können Übungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' .
+ 'Die Lehrenden erhalten eine Übersicht darüber, welche Teilnehmenden eine Übung oder einen ' .
+ 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Übungszetteln ' .
+ 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' .
+ 'erhalten sofort einen Überblick darüber, was noch zu korrigieren ist. Neben allgemein ' .
+ 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' .
+ 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.');
+ $metadata['keywords'] =
+ _('Einsatz bei Hausaufgaben und Präsenzprüfungen; Reduzierter Arbeitsaufwand bei der Auswertung; ' .
+ 'Sortierte Übersicht der eingereichten Ergebnisse; Single-, Multiple-Choice- und Textaufgaben, ' .
+ 'Lückentexte und Zuordnungen; Notwendige Korrekturen und erzielte Punktzahlen auf einen Blick');
+ $metadata['icon'] = Icon::create('vips');
+
+ return $metadata;
+ }
+
+ public function userDidDelete($event, $user)
+ {
+ // delete all personal assignments
+ VipsAssignment::deleteBySQL('range_id = ?', [$user->id]);
+
+ // delete in etask_responses
+ VipsSolution::deleteBySQL('user_id = ?', [$user->id]);
+
+ // delete start times and group memberships
+ VipsAssignmentAttempt::deleteBySQL('user_id = ?', [$user->id]);
+ VipsGroupMember::deleteBySQL('user_id = ?', [$user->id]);
+ }
+
+ public function courseDidDelete($event, $course)
+ {
+ // delete all assignments in course
+ VipsAssignment::deleteBySQL('range_id = ?', [$course->id]);
+
+ // delete other course related info
+ VipsBlock::deleteBySQL('range_id = ?', [$course->id]);
+ }
+
+ public function userDidLeaveCourse($event, $course_id, $user_id)
+ {
+ // terminate group membership when leaving a course
+ $group_member = VipsGroupMember::findOneBySQL(
+ 'JOIN statusgruppen ON statusgruppe_id = group_id WHERE range_id = ? AND user_id = ? AND end IS NULL',
+ [$course_id, $user_id]
+ );
+
+ if ($group_member) {
+ $group_member->end = time();
+ $group_member->store();
+ }
+ }
+
+ public function userDidMigrate($event, $user_id, $new_id)
+ {
+ $db = DBManager::get();
+
+ $db->execute('UPDATE IGNORE etask_assignment_attempts SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ $db->execute('UPDATE etask_tasks SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+
+ $db->execute('UPDATE IGNORE etask_responses SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ $db->execute('UPDATE etask_tests SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ }
+
+ public function statusgruppeUserDidCreate($event, $statusgruppe_user)
+ {
+ VipsGroupMember::create([
+ 'group_id' => $statusgruppe_user->statusgruppe_id,
+ 'user_id' => $statusgruppe_user->user_id,
+ 'start' => time()
+ ]);
+ }
+
+ public function statusgruppeUserDidDelete($event, $statusgruppe_user)
+ {
+ $member = VipsGroupMember::findOneBySQL(
+ 'group_id = ? AND user_id = ? AND end IS NULL',
+ [$statusgruppe_user->statusgruppe_id, $statusgruppe_user->user_id]
+ );
+
+ if ($member) {
+ $member->end = time();
+ $member->store();
+ }
+ }
+
+ /**
+ * Export available data of a given user into a storage object
+ * (an instance of the StoredUserData class) for that user.
+ *
+ * @param StoredUserData $store object to store data into
+ */
+ public function exportUserData(StoredUserData $store)
+ {
+ $db = DBManager::get();
+
+ $data = $db->fetchAll('SELECT * FROM etask_group_members WHERE user_id = ?', [$store->user_id]);
+ $store->addTabularData(_('Aufgaben-Gruppenzuordnung'), 'etask_group_members', $data);
+ }
+
+ /**
+ * Implement this method to register more block types.
+ *
+ * You get the current list of block types and return an updated list
+ * containing your own block types.
+ */
+ public function registerBlockTypes(array $otherBlockTypes): array
+ {
+ $otherBlockTypes[] = Courseware\BlockTypes\TestBlock::class;
+
+ return $otherBlockTypes;
+ }
+
+ /**
+ * Implement this method to register more container types.
+ *
+ * You get the current list of container types and return an updated list
+ * containing your own container types.
+ */
+ public function registerContainerTypes(array $otherContainerTypes): array
+ {
+ return $otherContainerTypes;
+ }
+}
diff --git a/public/assets/images/choice_checked.svg b/public/assets/images/choice_checked.svg
new file mode 100644
index 0000000..ba483b5
--- /dev/null
+++ b/public/assets/images/choice_checked.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.986 1.337a6.676 6.676 0 1 0 0 13.352 6.676 6.676 0 1 0 0-13.352m0 11.894a5.219 5.219 0 1 1 0-10.437 5.219 5.219 0 0 1 0 10.437"/><path fill="#28497C" d="m15.985 13.943-5.93-5.93 5.93-5.93L13.917.014l-5.931 5.93L2.054.013l-2.069 2.07 5.932 5.93-5.932 5.931 2.069 2.07 5.932-5.932 5.932 5.93z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/choice_unchecked.svg b/public/assets/images/choice_unchecked.svg
new file mode 100644
index 0000000..4fa3b2a
--- /dev/null
+++ b/public/assets/images/choice_unchecked.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M7.999 1.337a6.676 6.676 0 0 0 0 13.352 6.676 6.676 0 1 0 0-13.352m0 11.894a5.218 5.218 0 1 1 .002-10.436A5.218 5.218 0 0 1 8 13.23"/></svg> \ No newline at end of file
diff --git a/public/assets/images/collapse.svg b/public/assets/images/collapse.svg
new file mode 100644
index 0000000..9aba4d5
--- /dev/null
+++ b/public/assets/images/collapse.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16"><path fill="#6C737B" d="m4.884 10.877 3.115-3.116 3.119 3.116 1.32-1.318L8.001 5.12h-.002L3.563 9.559z"/><path fill="#6C737B" d="M0-.001V16h16V-.001zm14.434 14.435H1.567V1.565h12.866z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/expand.svg b/public/assets/images/expand.svg
new file mode 100644
index 0000000..12c5fa1
--- /dev/null
+++ b/public/assets/images/expand.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16"><path fill="#6C737B" d="M11.118 5.571 8 8.687 4.882 5.571 3.563 6.892l4.436 4.438h.003l4.436-4.438z"/><path fill="#6C737B" d="M0-.001V16h15.999V-.001zm14.435 14.435H1.566V1.565h12.868z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/vips.svg b/public/assets/images/icons/black/vips.svg
new file mode 100644
index 0000000..fdd66a7
--- /dev/null
+++ b/public/assets/images/icons/black/vips.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16h-9.83a1.917 1.917 0 0 1-1.923-1.907V4.329c0-1.053.864-1.908 1.923-1.908h9.83c.476 0 .911.171 1.245.458l-.982.983a.54.54 0 0 0-.263-.067h-9.83a.536.536 0 0 0-.539.535v9.764c0 .296.24.534.539.534h9.83c.297 0 .54-.238.54-.534V8.69z"/><path d="m8.342 11.406 7.663-7.664-1.32-1.32-7.665 7.665L3.902 6.97l-1.32 1.32 4.437 4.438h.001l1.066-1.066z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/assessment-mc.svg b/public/assets/images/icons/blue/assessment-mc.svg
new file mode 100644
index 0000000..cb919d8
--- /dev/null
+++ b/public/assets/images/icons/blue/assessment-mc.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="#28497c"><path d="M4.622 5.865H1.017V2.262h3.604zM1.55 5.331h2.536V2.795H1.55zm3.072 4.472H1.017V6.199h3.604zM1.55 9.268h2.536V6.73H1.55zm3.072 4.47H1.017v-3.604h3.604zm-3.072-.534h2.536V10.67H1.55zM6.041 3.051h8.941v2.137H6.041zm0 3.892h8.941v2.135H6.041zm0 3.912h8.941v2.136H6.041z"/><path d="m5.248 6.887-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425z"/><path d="m5.248 6.887-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425zm0 3.876-.627-.629-1.523 1.521-.797-.795-.626.625 1.424 1.425Z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/edit-line.svg b/public/assets/images/icons/blue/edit-line.svg
new file mode 100644
index 0000000..b8e2f4a
--- /dev/null
+++ b/public/assets/images/icons/blue/edit-line.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 54 54"><g fill="#28497c"><path d="M47.91 4.8A6.3 6.3 0 0 0 39.47 7l-1 1.72-1.53 2.55 10.7 5.95 1.53-2.55 1-1.72a5.83 5.83 0 0 0-2.26-8.15m-1.44 8.36-5.35-3 1-1.71a3.15 3.15 0 0 1 4.22-1.09 2.92 2.92 0 0 1 1.13 4.06Zm-3.08 5.13-5.34-2.98-.9-.49-1.78-1L23 34.47 23.13 46l10.57-5.58 12.37-20.64-1.79-.99z"/><path d="M32 9v38H6V9zm3-3H3v44h32z"/><path d="M9 11.97h20v4H9z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/vips.svg b/public/assets/images/icons/blue/vips.svg
new file mode 100644
index 0000000..35dae00
--- /dev/null
+++ b/public/assets/images/icons/blue/vips.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#24437C" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#24437C" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/vips.svg b/public/assets/images/icons/red/vips.svg
new file mode 100644
index 0000000..49c4cae
--- /dev/null
+++ b/public/assets/images/icons/red/vips.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#D60000" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#D60000" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/vips.svg b/public/assets/images/icons/white/vips.svg
new file mode 100644
index 0000000..516901d
--- /dev/null
+++ b/public/assets/images/icons/white/vips.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#FFF" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#FFF" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png
new file mode 100644
index 0000000..0673245
--- /dev/null
+++ b/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png
Binary files differ
diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png
new file mode 100644
index 0000000..54322a1
--- /dev/null
+++ b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png
Binary files differ
diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png
new file mode 100644
index 0000000..7b1c2b2
--- /dev/null
+++ b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png
Binary files differ
diff --git a/resources/assets/javascripts/bootstrap/vips.js b/resources/assets/javascripts/bootstrap/vips.js
new file mode 100644
index 0000000..69eb34c
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/vips.js
@@ -0,0 +1,336 @@
+import { $gettext } from "../lib/gettext";
+
+$(function() {
+ if ($('#exam_timer').length > 0) {
+ const exam_timer = $('#exam_timer');
+ const user_end_time = exam_timer.data('time') + Math.floor(Date.now() / 1000);
+ const timer_id = setInterval(() => {
+ const remaining_time = user_end_time - Math.floor(Date.now() / 1000);
+
+ // update timer
+ exam_timer.children('.time').text(Math.round(remaining_time / 60));
+
+ if (remaining_time < 180 && !exam_timer.hasClass('alert')) {
+ exam_timer.addClass('alert');
+ }
+
+ if (remaining_time < 0) {
+ if (document.jsfrm) {
+ clearInterval(timer_id);
+ document.jsfrm.removeAttribute('data-secure');
+ document.jsfrm.forced.value = 1;
+ document.jsfrm.submit();
+ } else {
+ location.reload();
+ }
+ }
+ }, 1000);
+
+ exam_timer.draggable();
+ }
+
+ if ($('#list').length > 0) {
+ const assignment = $('#list').data('assignment');
+
+ $('#list').sortable({
+ axis: 'y',
+ containment: 'parent',
+ handle: '.drag-handle',
+ helper(event, element) {
+ element.children().width((index, width) => width);
+
+ return element;
+ },
+ tolerance: 'pointer',
+ update() {
+ $.post(
+ STUDIP.URLHelper.getURL('dispatch.php/vips/sheets/move_exercise', { assignment_id: assignment }),
+ $('#list').sortable('serialize')
+ );
+ }
+ });
+
+ $('#list > tr').on('keydown', function (event) {
+ if (event.key === 'ArrowUp' && event.target === this) {
+ $(this).prev().before(this);
+ } else if (event.key === 'ArrowDown' && event.target === this) {
+ $(this).next().after(this);
+ } else {
+ return;
+ }
+
+ $(this).focus();
+ $('#list').sortable('option').update();
+ event.preventDefault();
+ });
+ }
+
+ $(document).on('click', '.add_ip_range', function (event) {
+ const input = $(this).closest('fieldset').find('input[name=ip_range]');
+
+ input.val(input.val() + ' ' + $(this).attr('data-value'));
+ event.preventDefault();
+ });
+
+ $(document).on('input', '.validate_ip_range', function () {
+ const ip_ranges = $(this).val().split(/[ ,]+/);
+ let message = '';
+
+ for (const ip_range of ip_ranges) {
+ if (
+ ip_range.length > 0
+ && ip_range.charAt(0) !== '#'
+ && !ip_range.match(/^[\d.]+(\/\d+|-[\d.]+)?$/)
+ && !ip_range.match(/^[\da-fA-F:]+(\/\d+|-[\da-fA-F:]+)?$/)
+ ) {
+ message = $gettext('Der IP-Zugriffsbereich ist ungültig.');
+ }
+ }
+
+ this.setCustomValidity(message);
+ });
+
+ $(document).on('click', '.vips_file_upload', function (event) {
+ $(this).closest('form').find('.file_upload').click();
+ event.preventDefault();
+ });
+
+ $(document).on('change', '.file_upload.attach', function () {
+ const button = $(this).closest('form').find('.vips_file_upload');
+
+ if (this.files && this.files.length > 1) {
+ button.text(button.data('label').replace('%d', this.files.length));
+ button.next('.file_upload_hint').show();
+ } else if (this.files) {
+ button.text(this.files[0].name);
+ button.next('.file_upload_hint').show();
+ }
+ });
+
+ $(document).on('change', '.file_upload.inline', function (event) {
+ const textarea = $(this).closest('form').find('.download');
+ const reader = new FileReader();
+
+ if (this.files && this.files.length > 0) {
+ reader.onload = function () {
+ textarea.val(reader.result);
+ };
+ reader.onerror = function () {
+ STUDIP.Dialog.show(reader.error.message, {
+ title: $gettext('Fehler beim Hochladen'),
+ size: 'fit',
+ wikilink: false,
+ dialogClass: 'studip-confirmation'
+ });
+ }
+ reader.readAsText(this.files[0]);
+ }
+ event.preventDefault();
+ });
+
+ $(document).on('click', '.vips_file_download', function (event) {
+ const text = $(this).closest('form').find('.download').val();
+ const link = $(this).closest('form').find('a[download]');
+ const blob = new Blob([text], {type: 'text/plain; charset=UTF-8'});
+
+ link.attr('href', URL.createObjectURL(blob));
+ link[0].click();
+ event.preventDefault();
+ });
+
+ $('.sortable_list').sortable({
+ axis: 'y',
+ containment: 'parent',
+ items: '> .sortable_item',
+ tolerance: 'pointer'
+ });
+
+ $(document).on('keydown', '.sortable_item', function (event) {
+ if (event.key === 'ArrowUp' && event.target === this) {
+ $(this).prev('.sortable_item:visible').before(this);
+ } else if (event.key === 'ArrowDown' && event.target === this) {
+ $(this).next('.sortable_item:visible').after(this);
+ } else {
+ return;
+ }
+
+ $(this).focus();
+ event.preventDefault();
+ });
+
+ $(document).on('click', '.textarea_toggle', function (event) {
+ const toggle = $(this).closest('.size_toggle');
+ const items = toggle.find('.character_input');
+
+ const name = items[0].name;
+ items[0].name = items[1].name;
+ items[1].name = name;
+
+ const value = items[0].value;
+ items[0].value = items[1].value;
+ items[1].value = value;
+
+ if (STUDIP.wysiwyg.getEditor && STUDIP.wysiwyg.getEditor(items[1])) {
+ STUDIP.wysiwyg.getEditor(items[1]).setData(value);
+ }
+
+ toggle.toggleClass('size_large').toggleClass('size_small');
+ event.preventDefault();
+ });
+
+ $(document).on('change', '.tb_layout', function () {
+ const toggle = $(this).closest('fieldset').find('.size_toggle');
+
+ toggle.find('.small_input').toggleClass('monospace', $(this).val() === 'code');
+
+ if (
+ $(this).val() === '' && toggle.hasClass('size_large')
+ || $(this).val() === 'code' && toggle.hasClass('size_large')
+ || $(this).val() === 'markup' && toggle.hasClass('size_small')
+ ) {
+ toggle.find('.textarea_toggle').click();
+ }
+ });
+
+ $(document).on('click', '.choice_list .add_dynamic_row', function () {
+ $(this).closest('fieldset').find('.choice_select').each(function () {
+ const template = $(this).children('.template').last();
+ const clone = template.clone(true).removeClass('template');
+ const index = template.data('index');
+
+ template.data('index', index + 1);
+ clone.insertBefore(template);
+ clone.find('input[data-value]').each(function () {
+ $(this).attr('value', index);
+ $(this).removeAttr('data-value');
+ });
+ });
+ });
+
+ $(document).on('change', '.choice_list input', function () {
+ const index = $(this).closest('.dynamic_row').data('index');
+ const items = $(this).closest('fieldset').find('.choice_select');
+
+ items.children().filter(function () {
+ return $(this).data('index') === index;
+ }).children('span').text($(this).val());
+ });
+
+ $(document).on('click', '.choice_list .delete_dynamic_row', function () {
+ const index = $(this).closest('.dynamic_row').data('index');
+ const items = $(this).closest('fieldset').find('.choice_select');
+
+ items.children().filter(function () {
+ return $(this).data('index') === index;
+ }).remove();
+ });
+
+ $('.dynamic_list').each(function () {
+ $(this).children('.dynamic_row').each(function (i) {
+ $(this).data('index', i);
+ });
+ });
+
+ $(document).on('click', '.add_dynamic_row', function (event) {
+ const container = $(this).closest('.dynamic_list');
+ const template = container.children('.template').last();
+ const clone = template.clone(true).removeClass('template');
+ const index = template.data('index');
+
+ template.data('index', index + 1);
+ clone.insertBefore(template);
+ clone.find('input[data-name], select[data-name], textarea[data-name]').each(function () {
+ if ($(this).data('name').indexOf(':') === 0) {
+ $(this).data('name', $(this).data('name').substr(1) + '[' + index + ']');
+ } else {
+ $(this).attr('name', $(this).data('name') + '[' + index + ']');
+ $(this).removeAttr('data-name');
+ }
+ });
+ clone.find('input[data-value], select[data-value], textarea[data-value]').each(function () {
+ if ($(this).data('value').indexOf(':') === 0) {
+ $(this).data('value', $(this).data('value').substr(1));
+ } else {
+ $(this).attr('value', index);
+ $(this).removeAttr('data-value');
+ }
+ });
+ clone.find('.wysiwyg-hidden:not(.template *)').toggleClass('wysiwyg wysiwyg-hidden');
+ clone.find('.add_dynamic_row:visible').click();
+ event.preventDefault();
+ });
+
+ $(document).on('click', '.delete_dynamic_row', function (event) {
+ $(this).closest('.dynamic_row').remove();
+ event.preventDefault();
+ });
+
+ $(document).on('click', '.solution-toggle', function (event) {
+ if ($(this).closest('.solution').length > 0) {
+ $(this).closest('.solution').toggleClass('solution-closed');
+ } else if ($('.arrow_all').first().css('display') !== 'none') {
+ $('.arrow_all').toggle();
+ $('.solution').removeClass('solution-closed');
+ } else {
+ $('.arrow_all').toggle();
+ $('.solution').addClass('solution-closed');
+ }
+
+ $(document.body).trigger('sticky_kit:recalc');
+ event.preventDefault();
+ });
+
+ $(document).on('click', '.edit_solution', function (event) {
+ const tabs = $(this).closest('.vips_tabs');
+
+ tabs.removeClass('edit-hidden');
+ tabs.find('.wysiwyg').attr('name', 'commented_solution');
+ tabs.tabs('option', 'active', 0);
+ event.preventDefault();
+ });
+
+ // add select2 to modal dialog including selects with optgroups
+ $(document).on('dialog-open', function (event, parameters) {
+ $('.vips_nested_select').select2({
+ minimumResultsForSearch: 12,
+ dropdownParent: $(parameters.dialog).closest('.ui-dialog, body'),
+ matcher(params, data) {
+ const originalMatcher = $.fn.select2.defaults.defaults.matcher;
+ const result = originalMatcher(params, data);
+
+ if (result && result.children && data.children && data.children.length) {
+ if (data.children.length !== result.children.length &&
+ data.text.toLowerCase().includes(params.term.toLowerCase())) {
+ result.children = data.children;
+ }
+ }
+
+ return result;
+ }
+ });
+ });
+
+ $('.assignment_type').change(function () {
+ $('#assignment').attr('class', $(this).val());
+
+ if ($(this).val() === 'exam') {
+ $('#exam_length input').attr('disabled', null);
+ } else {
+ $('#exam_length input').attr('disabled', 'disabled');
+ }
+
+ if ($(this).val() === 'selftest') {
+ $('#end_date input').attr('required', null);
+ $('#end_date span').removeClass('required');
+ } else {
+ $('#end_date input').attr('required', 'required');
+ $('#end_date span').addClass('required');
+ }
+ });
+
+ $('.rh_select_type').change(function () {
+ $(this).parent().next('table').toggleClass('rh_single');
+ });
+
+ STUDIP.Vips.vips_post_render(document);
+});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 102a558..2210471 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -77,6 +77,7 @@ import "./bootstrap/admin-courses.js"
import "./bootstrap/oer.js"
import "./bootstrap/courseware.js"
import "./bootstrap/external_pages.js"
+import "./bootstrap/vips.js"
import "./mvv_course_wizard.js"
import "./mvv.js"
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index 2103ca2..4af6ed9 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -78,6 +78,7 @@ import * as Gettext from './lib/gettext';
import UserFilter from './lib/user_filter.js';
import wysiwyg from './lib/wysiwyg.js';
import ScrollToTop from './lib/scroll_to_top.js';
+import * as Vips from './lib/vips.js';
const configURLHelper = _.get(window, 'STUDIP.URLHelper', {});
const URLHelper = createURLHelper(configURLHelper);
@@ -165,5 +166,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
domReady,
dialogReady,
ScrollToTop,
+ Vips,
Vue,
});
diff --git a/resources/assets/javascripts/lib/vips.js b/resources/assets/javascripts/lib/vips.js
new file mode 100644
index 0000000..aaff259
--- /dev/null
+++ b/resources/assets/javascripts/lib/vips.js
@@ -0,0 +1,122 @@
+function vips_post_render(element) {
+ $(element).find('.rh_list').sortable({
+ tolerance: 'pointer',
+ connectWith: '.rh_list',
+ update(event, ui) {
+ if (ui.sender) {
+ ui.item.find('input').val($(this).data('group'));
+ }
+ },
+ over() {
+ $(this).addClass('hover');
+ },
+ out() {
+ $(this).removeClass('hover');
+ },
+ receive(event, ui) {
+ const sortable = $(this).not('.multiple');
+ const container = sortable.closest('.rh_table').find('.answer_container');
+
+ // default answer container can have more items
+ if (sortable.children().length > 1 && !sortable.is(container)) {
+ sortable.find('.rh_item').each(function () {
+ if (!ui.item.is(this)) {
+ $(this).find('input').val(-1);
+ $(this).detach().appendTo(container)
+ .css('opacity', 0).animate({opacity: 1});
+ }
+ });
+ }
+ },
+ });
+
+ $(element).find('.rh_item').on('keydown', function (event) {
+ const sortable = $(this).parent();
+ const container = sortable.closest('.rh_table').find('.answer_container');
+ let target = $();
+
+ if (sortable.is('.mc_list')) {
+ if (event.key === 'ArrowUp') {
+ $(this).prev().before(this);
+ $(this).focus();
+ event.preventDefault();
+ } else if (event.key === 'ArrowDown') {
+ $(this).next().after(this);
+ $(this).focus();
+ event.preventDefault();
+ }
+ } else if (sortable.is(container)) {
+ if (event.key === 'ArrowLeft') {
+ target = sortable.parent().find('.rh_list').first();
+ }
+ } else {
+ if (event.key === 'ArrowRight') {
+ target = container;
+ } else if (event.key === 'ArrowUp') {
+ target = sortable.parent().prev().find('.rh_list').first();
+ } else if (event.key === 'ArrowDown') {
+ target = sortable.parent().next().find('.rh_list').first();
+ }
+ }
+
+ if (target.length) {
+ $(this).find('input').val(target.data('group'));
+ $(this).appendTo(target).focus();
+ event.preventDefault();
+ }
+ });
+
+ $(element).find('.cloze_select').filter(':contains("\\\\(")').each(function () {
+ STUDIP.loadChunk('mathjax').then(({ Hub }) => {
+ Hub.Queue(['Typeset', Hub, this]);
+ });
+ }).select2({
+ minimumResultsForSearch: -1,
+ templateResult(data) {
+ if ($(data.element).children('.MathJax').length) {
+ return $(data.element).children('.MathJax').clone();
+ } else {
+ return data.text;
+ }
+ },
+ templateSelection(data) {
+ if ($(data.element).children('.MathJax').length) {
+ return $(data.element).children('.MathJax').clone();
+ } else {
+ return data.text;
+ }
+ }
+ });
+
+ $(element).find('.cloze_item').draggable({
+ revert: 'invalid'
+ });
+
+ $(element).find('.cloze_drop').droppable({
+ accept: '.cloze_item',
+ tolerance: 'pointer',
+ classes: {
+ 'ui-droppable-hover': 'hover'
+ },
+ drop(event, ui) {
+ const container = $(this).closest('fieldset').find('.cloze_items');
+
+ if (!$(this).is(container)) {
+ $(this).find('.cloze_item').detach().appendTo(container)
+ .css('opacity', 0).animate({opacity: 1})
+ }
+
+ ui.draggable.closest('.cloze_drop').find('input').val('');
+ ui.draggable.detach().css({top: 0, left: 0}).appendTo(this);
+ $(this).find('input').val(ui.draggable.attr('data-value'));
+ }
+ });
+
+ $(element).find('.vips_tabs').each(function () {
+ $(this).tabs({
+ active: $(this).hasClass('edit-hidden') ? 1 : 0
+ });
+ })
+}
+
+export { vips_post_render };
diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss
index db400c2..55171f5 100644
--- a/resources/assets/stylesheets/scss/buttons.scss
+++ b/resources/assets/stylesheets/scss/buttons.scss
@@ -25,7 +25,7 @@
&:hover,
&:active,
&.active {
- background: var(--color--button-focus);
+ background-color: var(--color--button-focus);
color: var(--color--font-inverted);
}
diff --git a/resources/assets/stylesheets/scss/courseware/variables.scss b/resources/assets/stylesheets/scss/courseware/variables.scss
index 033c99f..b3da454 100644
--- a/resources/assets/stylesheets/scss/courseware/variables.scss
+++ b/resources/assets/stylesheets/scss/courseware/variables.scss
@@ -74,6 +74,7 @@ $blockadder-items: (
key-point: exclaim-circle,
link: link-extern,
table-of-contents: table-of-contents,
+ test: check-circle,
text: edit,
timeline: date-cycle,
typewriter: block-typewriter,
diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss
index 5c8f3a4..1cb037f 100644
--- a/resources/assets/stylesheets/scss/forms.scss
+++ b/resources/assets/stylesheets/scss/forms.scss
@@ -136,6 +136,7 @@ form.default {
}
.formpart {
+ display: block;
margin-bottom: $gap;
output.calculator_result {
@@ -198,7 +199,7 @@ form.default {
margin-top: 2ex;
}
- fieldset {
+ fieldset:not(.undecorated) {
box-sizing: border-box;
border: solid 1px var(--color--fieldset-border);
margin: 0 0 15px;
@@ -229,6 +230,16 @@ form.default {
}
}
+ fieldset.undecorated {
+ border: none;
+ margin: 0;
+ padding: 0;
+
+ > legend {
+ margin-bottom: 0.5ex;
+ }
+ }
+
.selectbox {
padding: 5px;
max-height: 200px;
diff --git a/resources/assets/stylesheets/scss/jquery-ui/studip.scss b/resources/assets/stylesheets/scss/jquery-ui/studip.scss
index db487f0..002eb17 100644
--- a/resources/assets/stylesheets/scss/jquery-ui/studip.scss
+++ b/resources/assets/stylesheets/scss/jquery-ui/studip.scss
@@ -209,3 +209,45 @@ textarea.ui-resizable-handle.ui-resizable-s {
background-color: var(--base-color);
}
}
+
+.ui-tabs {
+ &.ui-widget-content {
+ border: 1px solid var(--light-gray-color-40);
+ margin-top: 1.5ex;
+ padding: 0;
+ }
+
+ .ui-tabs-nav {
+ background: none;
+ border: none;
+ border-bottom: 1px solid var(--light-gray-color-40);
+ }
+
+ .ui-tabs-tab {
+ background: none;
+ border: none;
+ }
+
+ .ui-tabs-tab:hover {
+ border-bottom: 3px solid var(--dark-gray-color-40);
+ }
+
+ .ui-tabs-nav li.ui-tabs-active {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: 3px solid var(--light-gray-color-80);
+ }
+
+ .ui-tabs-tab .ui-tabs-anchor {
+ color: var(--base-color);
+ padding: 5px 15px;
+ }
+
+ .ui-tabs-active .ui-tabs-anchor {
+ color: black;
+ }
+
+ .ui-tabs-panel {
+ padding: 5px;
+ }
+}
diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss
index 1fcd1ce..0fcc32a 100644
--- a/resources/assets/stylesheets/scss/sidebar.scss
+++ b/resources/assets/stylesheets/scss/sidebar.scss
@@ -179,7 +179,7 @@ div#sidebar-navigation {
.widget-links {
margin: 5px;
> li img {
- vertical-align: text-top;
+ vertical-align: top;
}
a {
display: block;
diff --git a/resources/assets/stylesheets/scss/tables.scss b/resources/assets/stylesheets/scss/tables.scss
index 32a26c2..788392d 100644
--- a/resources/assets/stylesheets/scss/tables.scss
+++ b/resources/assets/stylesheets/scss/tables.scss
@@ -85,6 +85,10 @@ td.blanksmall {
background-color: var(--color--global-background);
}
+table.fixed {
+ table-layout: fixed;
+}
+
td.tree-indent {
img, svg {
vertical-align: bottom;
@@ -476,6 +480,15 @@ table.default {
white-space: nowrap;
}
+ img {
+ vertical-align: text-bottom;
+ }
+
+ input[type="text"], textarea {
+ padding-bottom: 2px;
+ padding-top: 2px;
+ }
+
padding: 10px 5px;
text-align: left;
@@ -715,7 +728,7 @@ table.default {
}
}
- tfoot {
+ tfoot, th {
// Fix button and select alignment
select {
vertical-align: middle;
diff --git a/resources/assets/stylesheets/scss/vips.scss b/resources/assets/stylesheets/scss/vips.scss
new file mode 100644
index 0000000..f11afda
--- /dev/null
+++ b/resources/assets/stylesheets/scss/vips.scss
@@ -0,0 +1,592 @@
+form.default {
+ .inline_select {
+ height: 32px;
+ width: auto;
+ }
+
+ .label-text {
+ display: block;
+ margin: 1.5ex 0 0.5ex 0;
+ text-indent: 0.25ex;
+ }
+
+ .vips_nested_select {
+ transition: inherit;
+ }
+
+ input.cloze_input {
+ margin: 2px;
+ padding-bottom: 2px;
+ padding-top: 2px;
+ }
+
+ select.cloze_select {
+ height: auto;
+ margin: 2px;
+ width: auto;
+ }
+
+ input.percent_input {
+ text-align: right;
+ width: 4em;
+ }
+
+ label:not(.undecorated) .select2-container {
+ display: block;
+ }
+}
+
+button.vips_file_upload {
+ @include background-icon(upload);
+ background-position: 0.5em center;
+ background-repeat: no-repeat;
+ background-size: var(--icon-size-inline);
+ padding-left: 30px;
+
+ &:hover {
+ @include background-icon(upload, info_alt);
+ }
+}
+
+progress.assignment {
+ appearance: none;
+ background: var(--light-gray-color-20);
+ border: none;
+ height: 8px;
+ width: 120px;
+
+ &::-moz-progress-bar {
+ background: var(--base-color);
+ }
+ &::-webkit-progress-bar {
+ background: var(--light-gray-color-20);
+ }
+ &::-webkit-progress-value {
+ background: var(--base-color);
+ }
+}
+
+.vips-teaser {
+ background-color: var(--content-color-20);
+ background-image: url(../images/icons/blue/vips.svg);
+ background-position: 64px 50%;
+ background-repeat: no-repeat;
+ background-size: 120px;
+ max-width: 562px;
+ padding: 24px 24px 24px 244px;
+
+ header {
+ font-size: 1.5em;
+ margin-bottom: 0.5em;
+ }
+}
+
+.width-1200 {
+ max-width: 1200px;
+}
+
+.breadcrumb {
+ margin-bottom: 1ex;
+
+ img {
+ vertical-align: text-bottom;
+ }
+}
+
+.smaller {
+ font-size: smaller;
+}
+
+.monospace,
+.vips_tabs .monospace {
+ font-family: monospace;
+}
+
+.vips_tabs .vips_output {
+ background: none;
+}
+
+.vips_output {
+ background-color: var(--dark-gray-color-5);
+ max-height: 30em;
+ min-height: 1em;
+ overflow-y: auto;
+ padding: 3px;
+
+ pre {
+ margin: 2px;
+ white-space: pre-wrap;
+ }
+}
+
+.sidebar_exercise_label {
+ display: inline-block;
+ width: 120px;
+}
+
+.sidebar_exercise_points {
+ display: inline-block;
+ text-align: right;
+ width: 80px;
+}
+
+.sidebar_exercise_state {
+ display: inline-block;
+ text-align: right;
+ width: 32px;
+}
+
+.sortable .gradebook_header {
+ font-size: smaller;
+ max-width: 8em;
+ overflow-x: hidden;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sortable_item {
+ padding-left: 2ex;
+}
+
+.exercise_types {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ max-width: 50em;
+}
+
+.exercise_type {
+ background-color: transparent;
+ background-position: 0.5em center;
+ background-repeat: no-repeat;
+ border: 1px solid var(--color--fieldset-border);
+ color: var(--base-color);
+ cursor: pointer;
+ margin-top: 1.5ex;
+ min-height: 50px;
+ padding: 4px 4px 4px 56px;
+ text-align: left;
+ width: 342px;
+
+ &:hover {
+ border: 1px solid var(--brand-color-dark);
+ color: var(--active-color);
+ }
+}
+
+.exercise .points {
+ float: right;
+ font-size: 14px;
+ font-weight: normal;
+}
+
+#exercises .points {
+ text-align: right;
+ width: 4em;
+}
+
+.exercise_hint {
+ background: var(--activity-color-20);
+ border: 1px dotted var(--dark-gray-color-75);
+ display: inline-block;
+ margin-top: 1.5ex;
+ padding: 1ex;
+
+ > h4 {
+ margin-top: 0px;
+ }
+}
+
+#exam_timer {
+ background-color: var(--white);
+ border: 3px solid var(--red);
+ cursor: move;
+ font-size: 1.1em;
+ left: calc(100% - 134px);
+ margin: 3px;
+ padding: 3px;
+ position: fixed;
+ top: 0px;
+ white-space: nowrap;
+ z-index: 10001;
+
+ &.alert {
+ color: var(--red);
+ }
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.inline-content .formatted-content {
+ display: inline-block;
+ vertical-align: top;
+
+ > p:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.print_settings {
+ background-color: var(--activity-color-20);
+ border-bottom: 1px dotted var(--dark-gray-color-75);
+ display: none;
+ margin: -2em -2em 1em -2em;
+ padding: 1ex 2em;
+
+ label {
+ margin-left: 1em;
+ }
+}
+
+.choice_select {
+ display: inline-block;
+ padding: 0 1ex;
+}
+
+.rh_single .rh_add_answer:not(:nth-child(2)) {
+ display: none;
+}
+
+.rh_table {
+ border-spacing: 3em 1em;
+ margin-left: -2em;
+}
+
+.rh_list {
+ background-color: var(--dark-gray-color-5);
+ border: 1px dashed var(--dark-gray-color-45);
+ min-width: 160px;
+
+ &.hover {
+ border-color: var(--black);
+ }
+}
+
+.rh_label {
+ padding-bottom: 2ex;
+ padding-top: 2ex;
+}
+
+.rh_item {
+ background-color: var(--white);
+ border: 1px solid var(--base-color-20);
+ margin: 4px;
+ padding: 1ex 1ex 1ex 2ex;
+
+ &:hover {
+ border-color: var(--dark-gray-color-45);
+ }
+}
+
+.cloze_drop {
+ background-color: var(--dark-gray-color-5);
+ border: 1px dashed var(--dark-gray-color-45);
+ display: inline-block;
+ min-height: 32px;
+ min-width: 80px;
+ vertical-align: middle;
+ white-space: normal;
+
+ &.hover {
+ border-color: var(--black);
+ }
+
+ &.cloze_items {
+ display: block;
+ margin-top: 1em;
+ padding: 2px;
+ }
+}
+
+.cloze_item {
+ background-color: var(--white);
+ background-size: auto 22px;
+ border: 1px solid var(--base-color-20);
+ display: inline-block;
+ margin: 2px;
+ min-width: 48px;
+ padding: 3px 1ex 3px 2ex;
+
+ &:hover {
+ border-color: var(--dark-gray-color-45);
+ }
+}
+
+.mc_row {
+ margin: 1ex 0;
+
+ img,
+ input[type="image"] {
+ vertical-align: text-bottom;
+ }
+}
+
+.mc_list {
+ display: inline-block;
+ margin-top: 1.5ex;
+ min-width: 32em;
+}
+
+.mc_item {
+ padding: 4px;
+}
+
+.mc_flex,
+form.default label.mc_flex {
+ align-items: start;
+ column-gap: 6px;
+ display: flex;
+}
+
+.mc_flex > img:first-child {
+ padding: 2px;
+}
+
+.mc_flex > .formatted-content {
+ flex-grow: 1;
+}
+
+.correct_item {
+ background: var(--green-20);
+ border: 1px solid var(--green-40);
+ padding: 3px;
+}
+
+.fuzzy_item {
+ background: var(--yellow-20);
+ border: 1px solid var(--yellow-40);
+ padding: 3px;
+}
+
+.wrong_item {
+ background: var(--red-20);
+ border: 1px solid var(--red-40);
+ padding: 3px;
+}
+
+.neutral_item {
+ border: 1px dotted var(--dark-gray-color-30);
+ margin-right: 2.5em;
+ padding: 3px;
+}
+
+.correction_marker {
+ float: right;
+ margin-left: 1em;
+
+ &.sequence {
+ font-size: 20px;
+ margin-top: -15px;
+ }
+}
+
+.correction_inline {
+ vertical-align: text-bottom;
+}
+
+.group_separator {
+ border-top: 1px dotted var(--dark-gray-color-75);
+ margin-top: 1.5ex;
+ padding-top: 1.5ex;
+}
+
+.dynamic_list {
+ counter-reset: vips_item;
+}
+
+.dynamic_counter::before {
+ counter-increment: vips_item;
+ content: counter(vips_item) ".";
+}
+
+.dynamic_row:first-child .hide_first {
+ display: none;
+}
+
+.template {
+ display: none;
+}
+
+.solution-close {
+ display: inline;
+}
+
+.solution-open {
+ display: none;
+}
+
+.solution-closed {
+ + tr {
+ display: none;
+ }
+
+ .solution-close {
+ display: none;
+ }
+
+ .solution-open {
+ display: inline;
+ }
+}
+
+.solution {
+ vertical-align: top;
+}
+
+.solution-col-5 {
+ vertical-align: top;
+ width: 20%;
+}
+
+.solution-none {
+ color: var(--light-gray-color);
+}
+
+.solution-uncorrected {
+ color: var(--red);
+}
+
+.solution-autocorrected {
+ color: var(--petrol);
+}
+
+.solution-corrected {
+ color: var(--green);
+ font-weight: bold;
+}
+
+.vips_tabs.edit-hidden .edit-tab {
+ display: none;
+}
+
+#assignment.exam .exam-hidden,
+#assignment.practice .practice-hidden,
+#assignment.selftest .selftest-hidden {
+ display: none;
+}
+
+#list > tr:first-child .icon-shape-arr_2up,
+#list > tr:last-child .icon-shape-arr_2down {
+ visibility: hidden;
+}
+
+.options-toggle {
+ display: none;
+}
+
+.options-toggle + .caption + .toggle-box,
+.options-toggle + .caption > .toggle-open,
+.options-toggle:checked + .caption > .toggle-closed {
+ display: none;
+}
+
+.options-toggle:checked + .caption + .toggle-box,
+.options-toggle:checked + .caption > .toggle-open {
+ display: initial;
+}
+
+.options-toggle + .caption {
+ background-color: var(--color--fieldset-header);
+ color: var(--brand-color-dark);
+ display: block;
+ font-weight: bold;
+ margin-bottom: 1.5ex;
+ padding: 4px;
+
+ > img {
+ vertical-align: text-bottom;
+ }
+}
+
+table.default input.small_input {
+ margin-bottom: 2px;
+}
+
+.size_toggle {
+ &.size_small .large_input,
+ &.size_large .small_input {
+ display: none;
+ }
+
+ .flexible_input {
+ display: inline-block;
+ max-width: 48em;
+ vertical-align: top;
+ width: 91%;
+ }
+}
+
+.vs__selected img,
+.vs__dropdown-option img {
+ vertical-align: text-bottom;
+}
+
+.vs__dropdown-option small {
+ margin-left: 20px;
+}
+
+.cw-exercise-header {
+ display: flex;
+ height: 20px;
+
+ span {
+ flex-grow: 1;
+ }
+}
+
+.cw-exercise-fieldset header {
+ background-color: var(--color--fieldset-header);
+ color: var(--brand-color-dark);
+ font-weight: 600;
+ margin: 14px 0 8px -10px;
+ padding: 6px 10px;
+}
+
+#vips-sheets-print_assignments {
+ display: block;
+ min-height: auto;
+ width: auto;
+
+ @media screen {
+ margin: 2em;
+
+ .print_settings {
+ display: block;
+ }
+ }
+
+ footer {
+ display: none;
+ }
+
+ table.content th {
+ padding: 3px;
+ text-align: left;
+ }
+
+ ol, ul {
+ padding-left: 30px;
+ }
+
+ .assignment {
+ page-break-after: always;
+ }
+
+ .exercise {
+ margin-bottom: 1em;
+ page-break-inside: avoid;
+ }
+
+ .label-text {
+ font-weight: bold;
+ margin-top: 1.5ex;
+ }
+
+ .vips_output {
+ background: none;
+ max-height: none;
+ }
+}
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index 624e99a..9183081 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -105,6 +105,7 @@
@import "scss/tree";
@import "scss/typography";
@import "scss/user-administration";
+@import "scss/vips";
@import "scss/wiki";
@import "scss/multi_person_search";
@import "scss/admission";
diff --git a/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue
new file mode 100644
index 0000000..9c90291
--- /dev/null
+++ b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue
@@ -0,0 +1,266 @@
+<template>
+ <div class="cw-block cw-block-test">
+ <courseware-default-block
+ :block="block"
+ :canEdit="canEdit"
+ :isTeacher="isTeacher"
+ :defaultGrade="false"
+ @storeEdit="storeBlock"
+ @closeEdit="initCurrentData"
+ >
+ <template #content>
+ <div class="cw-block-title cw-exercise-header" v-if="assignment">
+ <template v-if="exercises.length > 1">
+ <button class="as-link" @click="prevExercise" :title="$gettext('Zurück')">
+ <studip-icon shape="arr_1left" size="20"/>
+ </button>
+ <span>
+ {{ $gettextInterpolate(
+ $gettext('%{title}, Aufgabe %{num} von %{length}'),
+ { title: assignment.title, num: exercise_pos + 1, length: exercises.length }
+ ) }}
+ </span>
+ <button class="as-link" @click="nextExercise" :title="$gettext('Weiter')">
+ <studip-icon shape="arr_1right" size="20"/>
+ </button>
+ </template>
+ <span v-else>
+ {{assignment.title}}
+ </span>
+ </div>
+ <template v-for="(exercise, index) in exercises" :key="exercise.id">
+ <div v-show="index === exercise_pos">
+ <form class="default" autocomplete="off" :exercise="exercise.id">
+ <fieldset class="cw-exercise-fieldset" v-html="exercise.template" ref="content">
+ </fieldset>
+ <footer v-show="exercise.item_count && (assignment.reset_allowed || !exercise.show_solution)">
+ <button
+ v-show="!exercise.show_solution"
+ class="button accept"
+ @click.prevent="submitSolution"
+ >
+ {{ $gettext('Speichern') }}
+ </button>
+ <button
+ v-show="exercise.show_solution && assignment.reset_allowed"
+ class="button reset"
+ @click.prevent="resetDialogHandler"
+ >
+ {{ $gettext('Lösung dieser Aufgabe löschen') }}
+ </button>
+ <a
+ v-if="canEdit && $store.getters.viewMode === 'edit'"
+ class="button"
+ :href="vips_url('sheets/edit_assignment', { assignment_id: assignment.id })"
+ >
+ {{ $gettext('Aufgabenblatt bearbeiten') }}
+ </a>
+ </footer>
+ </form>
+ </div>
+ </template>
+ <courseware-companion-box
+ :msgCompanion="errorMessage" mood="sad"
+ v-if="errorMessage !== null"
+ />
+ </template>
+ <template v-if="canEdit" #edit>
+ <form class="default" @submit.prevent="">
+ <label>
+ {{ $gettext('Aufgabenblatt') }}
+ <studip-select
+ :options="assignments"
+ label="title"
+ :reduce="assignment => assignment.id"
+ :clearable="false"
+ v-model="assignment_id"
+ class="cw-vs-select"
+ >
+ <template #open-indicator="{ attributes }">
+ <span v-bind="attributes"><studip-icon shape="arr_1down" :size="10"/></span>
+ </template>
+ <template #no-options="{}">
+ {{ $gettext('Es steht keine Auswahl zur Verfügung') }}
+ </template>
+ <template #selected-option="{title, icon, start, end}">
+ <studip-icon :shape="icon" role="info"/>
+ {{title}} ({{start}} - {{end}})
+ </template>
+ <template #option="{title, icon, start, end, block}">
+ <studip-icon :shape="icon" role="info"/>
+ {{ block ? block + ' / ' + title : title }}<br>
+ <small>{{start}} - {{end}}</small>
+ </template>
+ </studip-select>
+ </label>
+ </form>
+ </template>
+ </courseware-default-block>
+ <studip-dialog
+ v-if="exerciseResetId"
+ :title="$gettext('Bitte bestätigen Sie die Aktion')"
+ :question="$gettext('Wollen Sie die Lösung dieser Aufgabe wirklich löschen?')"
+ height="180"
+ @confirm="resetSolution"
+ @close="exerciseResetId = null"
+ ></studip-dialog>
+ </div>
+</template>
+
+<script>
+import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'
+
+export default {
+ name: 'courseware-test-block',
+ components: { CoursewareDefaultBlock, CoursewareCompanionBox },
+ props: {
+ block: Object,
+ canEdit: Boolean,
+ isTeacher: Boolean
+ },
+ data() {
+ return {
+ assignments: [],
+ assignment_id: '',
+ assignment: null,
+ errorMessage: null,
+ exercises: [],
+ exercise_pos: 0,
+ exerciseResetId: null
+ }
+ },
+ methods: {
+ storeBlock() {
+ const attributes = { payload: { assignment: this.assignment_id } };
+ const container = this.$store.getters['courseware-containers/related']({
+ parent: this.block,
+ relationship: 'container',
+ });
+
+ return this.$store.dispatch('updateBlockInContainer', {
+ attributes,
+ blockId: this.block.id,
+ containerId: container.id,
+ });
+ },
+ initCurrentData() {
+ this.assignment_id = this.block.attributes.payload.assignment;
+ this.loadSelectedAssignment();
+ },
+ prevExercise() {
+ if (this.exercise_pos === 0) {
+ this.exercise_pos = this.exercises.length - 1;
+ } else {
+ this.exercise_pos = this.exercise_pos - 1;
+ }
+ },
+ nextExercise() {
+ if (this.exercise_pos === this.exercises.length - 1) {
+ this.exercise_pos = 0;
+ } else {
+ this.exercise_pos = this.exercise_pos + 1;
+ }
+ },
+ loadAssignments() {
+ // axios is this.$store.getters.httpClient
+ $.get(this.vips_url('api/assignments/' + this.$store.getters.context.id))
+ .done(response => {
+ this.assignments = response;
+ });
+ },
+ loadSelectedAssignment() {
+ if (this.assignment_id === '') {
+ this.errorMessage = this.$gettext('Es wurde noch kein Aufgabenblatt ausgewählt.');
+ return;
+ }
+
+ this.assignment = null;
+ this.errorMessage = null;
+ this.exercises = [];
+ $.get(this.vips_url('api/assignment/' + this.assignment_id))
+ .done(response => {
+ this.assignment = response;
+ this.exercises = response.exercises;
+ this.$nextTick(() => {
+ this.loadMathjax();
+ STUDIP.Vips.vips_post_render(this.$refs.content);
+ });
+ })
+ .fail(xhr => {
+ this.errorMessage = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText;
+ });
+ },
+ reloadExercise(exercise_id) {
+ $.get(this.vips_url('api/exercise/' + this.assignment.id + '/' + exercise_id))
+ .done(response => {
+ this.exercises[this.exercise_pos] = response;
+ this.$nextTick(() => {
+ this.loadMathjax();
+ STUDIP.Vips.vips_post_render(this.$refs.content);
+ });
+ });
+ },
+ loadMathjax() {
+ STUDIP.loadChunk('mathjax').then(({ Hub }) => {
+ Hub.Queue(['Typeset', Hub, this.$refs.content]);
+ });
+ },
+ vips_url(url, param_object) {
+ return STUDIP.URLHelper.getURL('dispatch.php/vips/' + url, param_object);
+ },
+ submitSolution(event) {
+ let exercise_id = event.currentTarget.form.getAttribute('exercise');
+ let data = new FormData(event.currentTarget.form);
+ data.set('block_id', this.block.id);
+
+ $.ajax({
+ type: 'POST',
+ url: this.vips_url('api/solution/' + this.assignment.id + '/' + exercise_id),
+ data: data,
+ enctype: 'multipart/form-data',
+ processData: false,
+ contentType: false
+ })
+ .fail(xhr => {
+ let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText;
+
+ if (xhr.status === 422) {
+ info = this.$gettext('Ihre Lösung ist leer und wurde nicht gespeichert.');
+ }
+ this.$store.dispatch('companionError', { info: info });
+ })
+ .done(() => {
+ this.$store.dispatch('companionSuccess', {
+ info: this.$gettext('Ihre Lösung zur Aufgabe wurde gespeichert.'),
+ });
+ this.reloadExercise(exercise_id);
+ });
+ },
+ resetDialogHandler(event) {
+ this.exerciseResetId = event.currentTarget.form.getAttribute('exercise');
+ },
+ resetSolution() {
+ $.ajax({
+ type: 'DELETE',
+ url: this.vips_url('api/solution/' + this.assignment.id + '/' + this.exerciseResetId, { block_id: this.block.id })
+ })
+ .fail(xhr => {
+ let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText;
+ this.$store.dispatch('companionError', { info: info });
+ this.exerciseResetId = null;
+ })
+ .done(() => {
+ this.reloadExercise(this.exerciseResetId);
+ this.exerciseResetId = null;
+ });
+ }
+ },
+ created() {
+ this.initCurrentData();
+ if (this.canEdit) {
+ this.loadAssignments();
+ }
+ }
+};
+</script>
diff --git a/resources/vue/components/courseware/containers/container-components.js b/resources/vue/components/courseware/containers/container-components.js
index 0a89bef..c920dae 100644
--- a/resources/vue/components/courseware/containers/container-components.js
+++ b/resources/vue/components/courseware/containers/container-components.js
@@ -27,6 +27,7 @@ import CoursewareKeyPointBlock from '../blocks/CoursewareKeyPointBlock.vue';
import CoursewareLinkBlock from '../blocks/CoursewareLinkBlock.vue';
import CoursewareLtiBlock from '../blocks/CoursewareLtiBlock.vue';
import CoursewareTableOfContentsBlock from '../blocks/CoursewareTableOfContentsBlock.vue';
+import CoursewareTestBlock from '../blocks/CoursewareTestBlock.vue';
import CoursewareTextBlock from '../blocks/CoursewareTextBlock.vue';
import CoursewareTimelineBlock from '../blocks/CoursewareTimelineBlock.vue';
import CoursewareTypewriterBlock from '../blocks/CoursewareTypewriterBlock.vue';
@@ -66,6 +67,7 @@ const ContainerComponents = {
CoursewareLinkBlock,
CoursewareLtiBlock,
CoursewareTableOfContentsBlock,
+ CoursewareTestBlock,
CoursewareTextBlock,
CoursewareTimelineBlock,
CoursewareTypewriterBlock,