From 0730980d9352e45007520e021e66e48a46fc3bf5 Mon Sep 17 00:00:00 2001
From: Elmar Ludwig <elmar.ludwig@uni-osnabrueck.de>
Date: Fri, 10 Jan 2025 14:55:22 +0000
Subject: [PATCH] add Vips as CorePlugin, re #4258

Merge request studip/studip!3432
---
 app/controllers/vips/admin.php                |  208 ++
 app/controllers/vips/api.php                  |  256 ++
 app/controllers/vips/config.php               |   95 +
 app/controllers/vips/exam_mode.php            |   29 +
 app/controllers/vips/pool.php                 |  473 ++++
 app/controllers/vips/sheets.php               | 2305 +++++++++++++++
 app/controllers/vips/solutions.php            | 2521 +++++++++++++++++
 app/views/vips/admin/edit_block.php           |   47 +
 app/views/vips/admin/edit_grades.php          |   56 +
 app/views/vips/config/index.php               |   90 +
 app/views/vips/config/pending_assignments.php |   76 +
 app/views/vips/exam_mode/index.php            |   46 +
 .../vips/exercises/ClozeTask/correct.php      |   42 +
 app/views/vips/exercises/ClozeTask/edit.php   |   67 +
 app/views/vips/exercises/ClozeTask/print.php  |   62 +
 app/views/vips/exercises/ClozeTask/solve.php  |   46 +
 app/views/vips/exercises/ClozeTask/xml.php    |   63 +
 .../vips/exercises/MatchingTask/correct.php   |   88 +
 .../vips/exercises/MatchingTask/edit.php      |  117 +
 .../vips/exercises/MatchingTask/print.php     |   95 +
 .../vips/exercises/MatchingTask/solve.php     |   38 +
 app/views/vips/exercises/MatchingTask/xml.php |   50 +
 .../exercises/MatrixChoiceTask/correct.php    |   42 +
 .../vips/exercises/MatrixChoiceTask/edit.php  |   91 +
 .../vips/exercises/MatrixChoiceTask/print.php |   41 +
 .../vips/exercises/MatrixChoiceTask/solve.php |   27 +
 .../vips/exercises/MatrixChoiceTask/xml.php   |   51 +
 .../exercises/MultipleChoiceTask/correct.php  |   32 +
 .../exercises/MultipleChoiceTask/edit.php     |   46 +
 .../exercises/MultipleChoiceTask/print.php    |   30 +
 .../exercises/MultipleChoiceTask/solve.php    |   13 +
 .../vips/exercises/MultipleChoiceTask/xml.php |   43 +
 .../vips/exercises/SequenceTask/correct.php   |   85 +
 .../vips/exercises/SequenceTask/edit.php      |   50 +
 .../vips/exercises/SequenceTask/print.php     |   92 +
 .../vips/exercises/SequenceTask/solve.php     |   14 +
 app/views/vips/exercises/SequenceTask/xml.php |   48 +
 .../exercises/SingleChoiceTask/correct.php    |   41 +
 .../vips/exercises/SingleChoiceTask/edit.php  |   86 +
 .../vips/exercises/SingleChoiceTask/print.php |   41 +
 .../vips/exercises/SingleChoiceTask/solve.php |   23 +
 .../vips/exercises/SingleChoiceTask/xml.php   |   51 +
 .../vips/exercises/TextLineTask/correct.php   |   37 +
 .../vips/exercises/TextLineTask/edit.php      |   80 +
 .../vips/exercises/TextLineTask/print.php     |   35 +
 .../vips/exercises/TextLineTask/solve.php     |    9 +
 app/views/vips/exercises/TextLineTask/xml.php |   53 +
 app/views/vips/exercises/TextTask/correct.php |  184 ++
 app/views/vips/exercises/TextTask/edit.php    |   47 +
 app/views/vips/exercises/TextTask/print.php   |   83 +
 app/views/vips/exercises/TextTask/solve.php   |  131 +
 app/views/vips/exercises/TextTask/xml.php     |   61 +
 app/views/vips/exercises/correct_exercise.php |   39 +
 app/views/vips/exercises/courseware_block.php |   79 +
 .../vips/exercises/evaluation_mode_info.php   |   22 +
 app/views/vips/exercises/flexible_input.php   |   25 +
 .../vips/exercises/flexible_textarea.php      |   17 +
 app/views/vips/exercises/print_exercise.php   |   64 +
 .../vips/exercises/show_exercise_files.php    |   20 +
 .../vips/exercises/show_exercise_hint.php     |   12 +
 app/views/vips/pool/assignments.php           |   25 +
 app/views/vips/pool/copy_exercises_dialog.php |   50 +
 app/views/vips/pool/exercises.php             |   15 +
 app/views/vips/pool/list_assignments.php      |  174 ++
 app/views/vips/pool/list_exercises.php        |  151 +
 app/views/vips/pool/move_exercises_dialog.php |   50 +
 app/views/vips/sheets/add_exercise_dialog.php |   26 +
 app/views/vips/sheets/assign_block_dialog.php |   32 +
 .../vips/sheets/assignment_type_tooltip.php   |   18 +
 app/views/vips/sheets/content_bar_icons.php   |   19 +
 .../vips/sheets/copy_assignment_dialog.php    |  104 +
 .../vips/sheets/copy_assignments_dialog.php   |   34 +
 .../vips/sheets/copy_exercise_dialog.php      |  132 +
 .../vips/sheets/copy_exercises_dialog.php     |   52 +
 app/views/vips/sheets/edit_assignment.php     |  329 +++
 app/views/vips/sheets/edit_exercise.php       |  161 ++
 app/views/vips/sheets/export_assignment.php   |   82 +
 .../vips/sheets/import_assignment_dialog.php  |   21 +
 app/views/vips/sheets/ip_range_tooltip.php    |   26 +
 app/views/vips/sheets/list_assignments.php    |   27 +
 .../vips/sheets/list_assignments_list.php     |  207 ++
 .../vips/sheets/list_assignments_stud.php     |  117 +
 app/views/vips/sheets/list_exercises.php      |   67 +
 .../vips/sheets/move_assignments_dialog.php   |   34 +
 .../vips/sheets/move_exercises_dialog.php     |   52 +
 app/views/vips/sheets/print_assignment.php    |  106 +
 app/views/vips/sheets/print_assignments.php   |   43 +
 app/views/vips/sheets/print_layout.php        |    9 +
 app/views/vips/sheets/show_assignment.php     |  179 ++
 app/views/vips/sheets/show_exercise.php       |  109 +
 app/views/vips/sheets/show_exercise_link.php  |   23 +
 .../vips/sheets/start_assignment_dialog.php   |   40 +
 .../vips/solutions/assignment_solutions.php   |  308 ++
 app/views/vips/solutions/assignments.php      |   18 +
 app/views/vips/solutions/assignments_list.php |  190 ++
 .../solutions/assignments_list_student.php    |  135 +
 .../vips/solutions/autocorrect_dialog.php     |   29 +
 .../solutions/edit_assignment_attempt.php     |   34 +
 .../vips/solutions/edit_group_dialog.php      |   30 +
 app/views/vips/solutions/edit_solution.php    |  216 ++
 app/views/vips/solutions/feedback_files.php   |   20 +
 .../vips/solutions/feedback_files_table.php   |   51 +
 app/views/vips/solutions/gradebook_dialog.php |   39 +
 .../vips/solutions/participants_overview.php  |  225 ++
 .../vips/solutions/show_assignment_log.php    |   56 +
 .../vips/solutions/solution_color_tooltip.php |    4 +
 app/views/vips/solutions/statistics.php       |   95 +
 .../student_assignment_solutions.php          |  125 +
 app/views/vips/solutions/student_grade.php    |  109 +
 .../vips/solutions/update_released_dialog.php |   42 +
 app/views/vips/solutions/view_solution.php    |   77 +
 composer.json                                 |    2 +
 db/migrations/6.0.40_add_vips_module.php      |  485 ++++
 lib/classes/SimpleORMap.php                   |    4 +-
 lib/classes/sidebar/VipsSearchWidget.php      |   42 +
 lib/filesystem/ExerciseFolder.php             |  111 +
 lib/filesystem/FeedbackFolder.php             |   96 +
 lib/filesystem/ResponseFolder.php             |  107 +
 .../Courseware/BlockTypes/TestBlock.php       |  125 +
 lib/models/FileRef.php                        |   17 +-
 lib/models/Folder.php                         |   27 +-
 lib/models/vips/ClozeTask.php                 |  505 ++++
 lib/models/vips/DummyExercise.php             |   83 +
 lib/models/vips/Exercise.php                  |  855 ++++++
 lib/models/vips/MatchingTask.php              |  341 +++
 lib/models/vips/MatrixChoiceTask.php          |  268 ++
 lib/models/vips/MultipleChoiceTask.php        |  196 ++
 lib/models/vips/SequenceTask.php              |  255 ++
 lib/models/vips/SingleChoiceTask.php          |  279 ++
 lib/models/vips/TextLineTask.php              |  271 ++
 lib/models/vips/TextTask.php                  |  279 ++
 lib/models/vips/VipsAssignment.php            | 1308 +++++++++
 lib/models/vips/VipsAssignmentAttempt.php     |   99 +
 lib/models/vips/VipsBlock.php                 |   92 +
 lib/models/vips/VipsExerciseRef.php           |  137 +
 lib/models/vips/VipsGroup.php                 |   79 +
 lib/models/vips/VipsGroupMember.php           |   50 +
 lib/models/vips/VipsSolution.php              |  160 ++
 lib/models/vips/VipsTest.php                  |  121 +
 lib/modules/VipsModule.php                    |  471 +++
 public/assets/images/choice_checked.svg       |    1 +
 public/assets/images/choice_unchecked.svg     |    1 +
 public/assets/images/collapse.svg             |    1 +
 public/assets/images/expand.svg               |    1 +
 public/assets/images/icons/black/vips.svg     |    1 +
 .../images/icons/blue/assessment-mc.svg       |    1 +
 public/assets/images/icons/blue/edit-line.svg |    1 +
 public/assets/images/icons/blue/vips.svg      |    1 +
 public/assets/images/icons/red/vips.svg       |    1 +
 public/assets/images/icons/white/vips.svg     |    1 +
 .../plus/screenshots/Vips/Vips_preview_1.png  |  Bin 0 -> 42033 bytes
 .../plus/screenshots/Vips/Vips_preview_2.png  |  Bin 0 -> 45908 bytes
 .../plus/screenshots/Vips/Vips_preview_3.png  |  Bin 0 -> 56688 bytes
 .../assets/javascripts/bootstrap/vips.js      |  336 +++
 resources/assets/javascripts/entry-base.js    |    1 +
 resources/assets/javascripts/init.js          |    2 +
 resources/assets/javascripts/lib/vips.js      |  122 +
 .../assets/stylesheets/scss/buttons.scss      |    2 +-
 .../scss/courseware/variables.scss            |    1 +
 resources/assets/stylesheets/scss/forms.scss  |   13 +-
 .../stylesheets/scss/jquery-ui/studip.scss    |   42 +
 .../assets/stylesheets/scss/sidebar.scss      |    2 +-
 resources/assets/stylesheets/scss/tables.scss |   15 +-
 resources/assets/stylesheets/scss/vips.scss   |  592 ++++
 resources/assets/stylesheets/studip.scss      |    1 +
 .../courseware/blocks/CoursewareTestBlock.vue |  266 ++
 .../containers/container-components.js        |    2 +
 167 files changed, 21392 insertions(+), 12 deletions(-)
 create mode 100644 app/controllers/vips/admin.php
 create mode 100644 app/controllers/vips/api.php
 create mode 100644 app/controllers/vips/config.php
 create mode 100644 app/controllers/vips/exam_mode.php
 create mode 100644 app/controllers/vips/pool.php
 create mode 100644 app/controllers/vips/sheets.php
 create mode 100644 app/controllers/vips/solutions.php
 create mode 100644 app/views/vips/admin/edit_block.php
 create mode 100644 app/views/vips/admin/edit_grades.php
 create mode 100644 app/views/vips/config/index.php
 create mode 100644 app/views/vips/config/pending_assignments.php
 create mode 100644 app/views/vips/exam_mode/index.php
 create mode 100644 app/views/vips/exercises/ClozeTask/correct.php
 create mode 100644 app/views/vips/exercises/ClozeTask/edit.php
 create mode 100644 app/views/vips/exercises/ClozeTask/print.php
 create mode 100644 app/views/vips/exercises/ClozeTask/solve.php
 create mode 100644 app/views/vips/exercises/ClozeTask/xml.php
 create mode 100644 app/views/vips/exercises/MatchingTask/correct.php
 create mode 100644 app/views/vips/exercises/MatchingTask/edit.php
 create mode 100644 app/views/vips/exercises/MatchingTask/print.php
 create mode 100644 app/views/vips/exercises/MatchingTask/solve.php
 create mode 100644 app/views/vips/exercises/MatchingTask/xml.php
 create mode 100644 app/views/vips/exercises/MatrixChoiceTask/correct.php
 create mode 100644 app/views/vips/exercises/MatrixChoiceTask/edit.php
 create mode 100644 app/views/vips/exercises/MatrixChoiceTask/print.php
 create mode 100644 app/views/vips/exercises/MatrixChoiceTask/solve.php
 create mode 100644 app/views/vips/exercises/MatrixChoiceTask/xml.php
 create mode 100644 app/views/vips/exercises/MultipleChoiceTask/correct.php
 create mode 100644 app/views/vips/exercises/MultipleChoiceTask/edit.php
 create mode 100644 app/views/vips/exercises/MultipleChoiceTask/print.php
 create mode 100644 app/views/vips/exercises/MultipleChoiceTask/solve.php
 create mode 100644 app/views/vips/exercises/MultipleChoiceTask/xml.php
 create mode 100644 app/views/vips/exercises/SequenceTask/correct.php
 create mode 100644 app/views/vips/exercises/SequenceTask/edit.php
 create mode 100644 app/views/vips/exercises/SequenceTask/print.php
 create mode 100644 app/views/vips/exercises/SequenceTask/solve.php
 create mode 100644 app/views/vips/exercises/SequenceTask/xml.php
 create mode 100644 app/views/vips/exercises/SingleChoiceTask/correct.php
 create mode 100644 app/views/vips/exercises/SingleChoiceTask/edit.php
 create mode 100644 app/views/vips/exercises/SingleChoiceTask/print.php
 create mode 100644 app/views/vips/exercises/SingleChoiceTask/solve.php
 create mode 100644 app/views/vips/exercises/SingleChoiceTask/xml.php
 create mode 100644 app/views/vips/exercises/TextLineTask/correct.php
 create mode 100644 app/views/vips/exercises/TextLineTask/edit.php
 create mode 100644 app/views/vips/exercises/TextLineTask/print.php
 create mode 100644 app/views/vips/exercises/TextLineTask/solve.php
 create mode 100644 app/views/vips/exercises/TextLineTask/xml.php
 create mode 100644 app/views/vips/exercises/TextTask/correct.php
 create mode 100644 app/views/vips/exercises/TextTask/edit.php
 create mode 100644 app/views/vips/exercises/TextTask/print.php
 create mode 100644 app/views/vips/exercises/TextTask/solve.php
 create mode 100644 app/views/vips/exercises/TextTask/xml.php
 create mode 100644 app/views/vips/exercises/correct_exercise.php
 create mode 100644 app/views/vips/exercises/courseware_block.php
 create mode 100644 app/views/vips/exercises/evaluation_mode_info.php
 create mode 100644 app/views/vips/exercises/flexible_input.php
 create mode 100644 app/views/vips/exercises/flexible_textarea.php
 create mode 100644 app/views/vips/exercises/print_exercise.php
 create mode 100644 app/views/vips/exercises/show_exercise_files.php
 create mode 100644 app/views/vips/exercises/show_exercise_hint.php
 create mode 100644 app/views/vips/pool/assignments.php
 create mode 100644 app/views/vips/pool/copy_exercises_dialog.php
 create mode 100644 app/views/vips/pool/exercises.php
 create mode 100644 app/views/vips/pool/list_assignments.php
 create mode 100644 app/views/vips/pool/list_exercises.php
 create mode 100644 app/views/vips/pool/move_exercises_dialog.php
 create mode 100644 app/views/vips/sheets/add_exercise_dialog.php
 create mode 100644 app/views/vips/sheets/assign_block_dialog.php
 create mode 100644 app/views/vips/sheets/assignment_type_tooltip.php
 create mode 100644 app/views/vips/sheets/content_bar_icons.php
 create mode 100644 app/views/vips/sheets/copy_assignment_dialog.php
 create mode 100644 app/views/vips/sheets/copy_assignments_dialog.php
 create mode 100644 app/views/vips/sheets/copy_exercise_dialog.php
 create mode 100644 app/views/vips/sheets/copy_exercises_dialog.php
 create mode 100644 app/views/vips/sheets/edit_assignment.php
 create mode 100644 app/views/vips/sheets/edit_exercise.php
 create mode 100644 app/views/vips/sheets/export_assignment.php
 create mode 100644 app/views/vips/sheets/import_assignment_dialog.php
 create mode 100644 app/views/vips/sheets/ip_range_tooltip.php
 create mode 100644 app/views/vips/sheets/list_assignments.php
 create mode 100644 app/views/vips/sheets/list_assignments_list.php
 create mode 100644 app/views/vips/sheets/list_assignments_stud.php
 create mode 100644 app/views/vips/sheets/list_exercises.php
 create mode 100644 app/views/vips/sheets/move_assignments_dialog.php
 create mode 100644 app/views/vips/sheets/move_exercises_dialog.php
 create mode 100644 app/views/vips/sheets/print_assignment.php
 create mode 100644 app/views/vips/sheets/print_assignments.php
 create mode 100644 app/views/vips/sheets/print_layout.php
 create mode 100644 app/views/vips/sheets/show_assignment.php
 create mode 100644 app/views/vips/sheets/show_exercise.php
 create mode 100644 app/views/vips/sheets/show_exercise_link.php
 create mode 100644 app/views/vips/sheets/start_assignment_dialog.php
 create mode 100644 app/views/vips/solutions/assignment_solutions.php
 create mode 100644 app/views/vips/solutions/assignments.php
 create mode 100644 app/views/vips/solutions/assignments_list.php
 create mode 100644 app/views/vips/solutions/assignments_list_student.php
 create mode 100644 app/views/vips/solutions/autocorrect_dialog.php
 create mode 100644 app/views/vips/solutions/edit_assignment_attempt.php
 create mode 100644 app/views/vips/solutions/edit_group_dialog.php
 create mode 100644 app/views/vips/solutions/edit_solution.php
 create mode 100644 app/views/vips/solutions/feedback_files.php
 create mode 100644 app/views/vips/solutions/feedback_files_table.php
 create mode 100644 app/views/vips/solutions/gradebook_dialog.php
 create mode 100644 app/views/vips/solutions/participants_overview.php
 create mode 100644 app/views/vips/solutions/show_assignment_log.php
 create mode 100644 app/views/vips/solutions/solution_color_tooltip.php
 create mode 100644 app/views/vips/solutions/statistics.php
 create mode 100644 app/views/vips/solutions/student_assignment_solutions.php
 create mode 100644 app/views/vips/solutions/student_grade.php
 create mode 100644 app/views/vips/solutions/update_released_dialog.php
 create mode 100644 app/views/vips/solutions/view_solution.php
 create mode 100644 db/migrations/6.0.40_add_vips_module.php
 create mode 100644 lib/classes/sidebar/VipsSearchWidget.php
 create mode 100644 lib/filesystem/ExerciseFolder.php
 create mode 100644 lib/filesystem/FeedbackFolder.php
 create mode 100644 lib/filesystem/ResponseFolder.php
 create mode 100644 lib/models/Courseware/BlockTypes/TestBlock.php
 create mode 100644 lib/models/vips/ClozeTask.php
 create mode 100644 lib/models/vips/DummyExercise.php
 create mode 100644 lib/models/vips/Exercise.php
 create mode 100644 lib/models/vips/MatchingTask.php
 create mode 100644 lib/models/vips/MatrixChoiceTask.php
 create mode 100644 lib/models/vips/MultipleChoiceTask.php
 create mode 100644 lib/models/vips/SequenceTask.php
 create mode 100644 lib/models/vips/SingleChoiceTask.php
 create mode 100644 lib/models/vips/TextLineTask.php
 create mode 100644 lib/models/vips/TextTask.php
 create mode 100644 lib/models/vips/VipsAssignment.php
 create mode 100644 lib/models/vips/VipsAssignmentAttempt.php
 create mode 100644 lib/models/vips/VipsBlock.php
 create mode 100644 lib/models/vips/VipsExerciseRef.php
 create mode 100644 lib/models/vips/VipsGroup.php
 create mode 100644 lib/models/vips/VipsGroupMember.php
 create mode 100644 lib/models/vips/VipsSolution.php
 create mode 100644 lib/models/vips/VipsTest.php
 create mode 100644 lib/modules/VipsModule.php
 create mode 100644 public/assets/images/choice_checked.svg
 create mode 100644 public/assets/images/choice_unchecked.svg
 create mode 100644 public/assets/images/collapse.svg
 create mode 100644 public/assets/images/expand.svg
 create mode 100644 public/assets/images/icons/black/vips.svg
 create mode 100644 public/assets/images/icons/blue/assessment-mc.svg
 create mode 100644 public/assets/images/icons/blue/edit-line.svg
 create mode 100644 public/assets/images/icons/blue/vips.svg
 create mode 100644 public/assets/images/icons/red/vips.svg
 create mode 100644 public/assets/images/icons/white/vips.svg
 create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_1.png
 create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_2.png
 create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_3.png
 create mode 100644 resources/assets/javascripts/bootstrap/vips.js
 create mode 100644 resources/assets/javascripts/lib/vips.js
 create mode 100644 resources/assets/stylesheets/scss/vips.scss
 create mode 100644 resources/vue/components/courseware/blocks/CoursewareTestBlock.vue

diff --git a/app/controllers/vips/admin.php b/app/controllers/vips/admin.php
new file mode 100644
index 00000000000..92c6cc7bc13
--- /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 00000000000..8c2dbe0c75a
--- /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 00000000000..d6d4e4857db
--- /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 00000000000..914a0e077da
--- /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 00000000000..bcbe302fdec
--- /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 00000000000..036ff10a779
--- /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 00000000000..ca46922cbe9
--- /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 00000000000..70710cfccc4
--- /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 00000000000..d83db43966e
--- /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 00000000000..d4b1f69bbe3
--- /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 00000000000..8a21874fec1
--- /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 00000000000..50bf0a68169
--- /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 00000000000..8e4aac1bd69
--- /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 00000000000..51c04a92c3e
--- /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 00000000000..fce4bc41c9c
--- /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 00000000000..bc17a032c10
--- /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 00000000000..65ebb98ccf0
--- /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 00000000000..64faca60064
--- /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 00000000000..3ddc8f557d9
--- /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 00000000000..a9617dfdad9
--- /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 00000000000..82028195ddf
--- /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 00000000000..41d35af6511
--- /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 00000000000..1b4f8ba1cdd
--- /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 00000000000..946a2178436
--- /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 00000000000..314730fafd5
--- /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 00000000000..5b767641cc9
--- /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 00000000000..2b8238b13e8
--- /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 00000000000..2f2a6dc80a5
--- /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 00000000000..c1ab69f0f78
--- /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 00000000000..d352f17375a
--- /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 00000000000..3c716e4f93d
--- /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 00000000000..4c1389daf23
--- /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 00000000000..72cfba5edaf
--- /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 00000000000..d8d61ccbb53
--- /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 00000000000..1ccb76d365e
--- /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 00000000000..9ffe605b742
--- /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 00000000000..244784c928b
--- /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 00000000000..cecc743089a
--- /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 00000000000..bd368c716fc
--- /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 00000000000..a61bc84a199
--- /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 00000000000..70689bff1c4
--- /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 00000000000..423a183ae11
--- /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 00000000000..27bcefcb7f7
--- /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 00000000000..3d2af0a3300
--- /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 00000000000..3bfbc2e25ca
--- /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 00000000000..8fab98ab0fa
--- /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 00000000000..d4b46c08349
--- /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 00000000000..bdd99a6763e
--- /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 00000000000..df7304e1f43
--- /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 00000000000..e8e29c5cbb5
--- /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 00000000000..ddafce606ef
--- /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 00000000000..dd79d5360f2
--- /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 00000000000..c62cb0eef5d
--- /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 00000000000..4259f909738
--- /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 00000000000..d39d01e9828
--- /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 00000000000..f2e89403764
--- /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 00000000000..64db62fb6b1
--- /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 00000000000..a3da7bad63b
--- /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 00000000000..822fcee048c
--- /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 00000000000..8d686471e92
--- /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 00000000000..795e2f607c2
--- /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 00000000000..810be1121f4
--- /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 00000000000..de11e7a2bca
--- /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 00000000000..4caf303aa85
--- /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 00000000000..83544f08555
--- /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 00000000000..09e7ac34c89
--- /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 00000000000..f13a08d580c
--- /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 00000000000..ba75f964f8d
--- /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 00000000000..a6bf99e49d2
--- /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 00000000000..5d6268a7b38
--- /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 00000000000..49eecb95bbf
--- /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 00000000000..2c376616f2a
--- /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 00000000000..7eeec269864
--- /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 00000000000..17f66781edb
--- /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 00000000000..1d2c66e8a4f
--- /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 00000000000..156d789e67d
--- /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 00000000000..1a24f3c57ee
--- /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 00000000000..f8a478b0048
--- /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 00000000000..24a407f0dbc
--- /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 00000000000..29e85b3ccb7
--- /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 00000000000..bc3fc1e1e0b
--- /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 00000000000..647755701ce
--- /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 00000000000..45911636996
--- /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 00000000000..c022fc18e54
--- /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 00000000000..49015647f4e
--- /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 00000000000..9e7b5e6ba4b
--- /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 00000000000..071e13c32fd
--- /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 00000000000..866fa168198
--- /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 00000000000..4f75521eeef
--- /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 00000000000..b680450bd42
--- /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 00000000000..6f41a6a5a20
--- /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 00000000000..4dd47d99830
--- /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 00000000000..86b4e8a2a3d
--- /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 00000000000..d1629cfbaf8
--- /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 00000000000..b9865d10f39
--- /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 00000000000..a19a3611659
--- /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 00000000000..29392e94799
--- /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 00000000000..55f13ec46be
--- /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 00000000000..378aa73050f
--- /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 00000000000..955fd405374
--- /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 00000000000..4630bb587d2
--- /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 00000000000..dff986963e1
--- /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 00000000000..9ebc6842251
--- /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 00000000000..72bd316156d
--- /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 00000000000..0b1b5c81ef9
--- /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 00000000000..41c43370623
--- /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 00000000000..d0327b3a1b5
--- /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 00000000000..5a4238b632c
--- /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 00000000000..421434f0253
--- /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 00000000000..a8a88adc951
--- /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 00000000000..bac4a896931
--- /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 410e14d1cae..ea95764e32a 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 00000000000..8fc50c842b8
--- /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 d8cdb8eca06..bea9595890b 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 00000000000..dbfdea6e064
--- /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 00000000000..e400bbcecf4
--- /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 00000000000..17511b8de7d
--- /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 00000000000..598bf284bde
--- /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 00000000000..181fff64129
--- /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 2a7f48574ac..4196367c9f2 100644
--- a/lib/models/FileRef.php
+++ b/lib/models/FileRef.php
@@ -299,7 +299,6 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
         return mb_strpos($this->mime_type, 'audio/') === 0;
     }
 
-
     /**
      * Determines if the FileRef references a video file.
      *
@@ -310,6 +309,22 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
         return mb_strpos($this->mime_type, 'video/') === 0;
     }
 
+    /**
+     * Get the preferred content disposition of this file.
+     */
+    public function getContentDisposition(): string
+    {
+        if ($this->isImage() || $this->isAudio() || $this->isVideo()) {
+            return 'inline';
+        }
+
+        if (in_array($this->mime_type, ['application/pdf', 'text/plain'])) {
+            return 'inline';
+        }
+
+        return 'attachment';
+    }
+
     /**
      * Export available data of a given user into a storage object
      * (an instance of the StoredUserData class) for that user.
diff --git a/lib/models/Folder.php b/lib/models/Folder.php
index 1c7a13ea49a..111decf0d95 100644
--- a/lib/models/Folder.php
+++ b/lib/models/Folder.php
@@ -273,6 +273,17 @@ class Folder extends SimpleORMap implements FeedbackRange
         return $ret;
     }
 
+    /**
+     * Retrieves folders by range id and folder type.
+     *
+     * @param string $range_id    range id of the folder
+     * @param string $folder_type folder type name
+     */
+    public static function findByRangeIdAndFolderType(?string $range_id, string $folder_type)
+    {
+        return self::findBySQL('range_id = ? AND folder_type = ?', [$range_id, $folder_type]);
+    }
+
     /**
      * This callback is called before storing a Folder object.
      * In case the name field is changed this callback assures that the
@@ -381,11 +392,15 @@ class Folder extends SimpleORMap implements FeedbackRange
      *
      * @param string range_id The ID of the Stud.IP object whose top folder shall be found.
      * @param string folder_type The expected folder type related to the Stud.IP object (defaults to RootFolder, use MessageFolder for the top folder of a message)
+     * @param string range_type The expected range type of the Stud.IP object (defaults to auto detect)
      *
      * @returns Folder|null Folder object on success or null, if no folder can be created.
      **/
-    public static function findTopFolder($range_id, $folder_type = 'RootFolder')
-    {
+    public static function findTopFolder(
+        string $range_id,
+        string $folder_type = 'RootFolder',
+        ?string $range_type = null
+    ) {
         $top_folder = self::findOneBySQL(
             "range_id = ? AND folder_type = ? AND parent_id=''",
             [$range_id, $folder_type]
@@ -395,10 +410,12 @@ class Folder extends SimpleORMap implements FeedbackRange
         if (!$top_folder) {
             //top_folder doest not exist: create it
             //determine range type:
-            $range_type = self::findRangeTypeById($range_id);
             if (!$range_type) {
-                //no range type means we can't create a folder!
-                return null;
+                $range_type = self::findRangeTypeById($range_id);
+                if (!$range_type) {
+                    //no range type means we can't create a folder!
+                    return null;
+                }
             }
 
             $top_folder = self::createTopFolder($range_id, $range_type, $folder_type);
diff --git a/lib/models/vips/ClozeTask.php b/lib/models/vips/ClozeTask.php
new file mode 100644
index 00000000000..b5c8069938e
--- /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 00000000000..daa9dc5ca33
--- /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 00000000000..a4ef00a1cbe
--- /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 00000000000..bb559e224b4
--- /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 00000000000..1ba2d900cf1
--- /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 00000000000..68470ef467c
--- /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 00000000000..696fe6ac221
--- /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 00000000000..4029a6502df
--- /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 00000000000..4a2e7d255d9
--- /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 00000000000..5684195f72b
--- /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 00000000000..d73d62aa72f
--- /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 00000000000..9eba3716cb6
--- /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 00000000000..217925481e7
--- /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 00000000000..3255e278b4c
--- /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 00000000000..8b43c2ed31e
--- /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 00000000000..c6be629f0a5
--- /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 00000000000..14b98264279
--- /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 00000000000..178b352d5e3
--- /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 00000000000..9c37c3a7909
--- /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 00000000000..ba483b556e4
--- /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 00000000000..4fa3b2a13fc
--- /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 00000000000..9aba4d51c09
--- /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 00000000000..12c5fa1e73f
--- /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 00000000000..fdd66a71136
--- /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 00000000000..cb919d8f68b
--- /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 00000000000..b8e2f4acd22
--- /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 00000000000..35dae000eab
--- /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 00000000000..49c4caea81f
--- /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 00000000000..516901d8f1e
--- /dev/null
+++ b/public/assets/images/icons/white/vips.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-2 0 18 18"><path fill="#FFF" d="M13.67 7.312v6.781A1.915 1.915 0 0 1 11.749 16H1.917a1.916 1.916 0 0 1-1.921-1.907V4.329c0-1.053.863-1.908 1.921-1.908h9.832c.475 0 .911.171 1.244.458l-.982.983a.53.53 0 0 0-.262-.067H1.917a.537.537 0 0 0-.538.535v9.764c0 .296.241.534.538.534h9.832c.297 0 .54-.238.54-.534V8.69z"/><path fill="#FFF" d="m8.341 11.406 7.664-7.664-1.321-1.32-7.664 7.665L3.902 6.97l-1.32 1.32 4.436 4.438h.002l1.066-1.066z"/></svg>
\ No newline at end of file
diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_1.png
new file mode 100644
index 0000000000000000000000000000000000000000..067324574fdcf67d5d1d1b3784d4dd63d0697267
GIT binary patch
literal 42033
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
R1qKEN22WQ%mvv4FO#qDz5{>`>

literal 0
HcmV?d00001

diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png
new file mode 100644
index 0000000000000000000000000000000000000000..54322a1a5a7fcbb8e4307d9c72f39d55695202ac
GIT binary patch
literal 45908
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

literal 0
HcmV?d00001

diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png
new file mode 100644
index 0000000000000000000000000000000000000000..7b1c2b2b84c4f2680c923ea8d828ee4a82f77dd0
GIT binary patch
literal 56688
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
JF6*2Ung9;xy-@%F

literal 0
HcmV?d00001

diff --git a/resources/assets/javascripts/bootstrap/vips.js b/resources/assets/javascripts/bootstrap/vips.js
new file mode 100644
index 00000000000..69eb34c5dd8
--- /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 102a558fde1..22104712676 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 2103ca2e5d8..4af6ed9659d 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 00000000000..aaff2599037
--- /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 db400c2bc54..55171f5de54 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 033c99fa005..b3da454fb18 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 5c8f3a4d68c..1cb037fb1c7 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 db487f0d4c3..002eb1716cf 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 1fcd1ce8392..0fcc32a74ab 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 32a26c29038..788392df104 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 00000000000..f11afda08e4
--- /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 624e99a4326..9183081efe8 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 00000000000..9c90291278e
--- /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 0a89bef5e89..c920dae3464 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,
-- 
GitLab