From 339493dbd88f45eee9d044123d13717558047fca 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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
J{>(J+e*ujR5{>`>

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
zcmZ^~2{@E*^gn#hjD44Vi!32!Crh@nrjRW{6lKd6*_j~{vJ2TuQT8H4%phB2Pua(s
zC1&h{G4qbTzu)!$U)TG-b6t35#`8S)eeQFg^Z9(vd16hB^yq20X#fE92Kw5j0FaXo
zVFFYz(jP-zjULh)U~<D;$Aa{pbW8!Iq@kc@V5VoHJHyJ%#=*+Y&BMjR$;ZzpD11)n
zyx4g$5pfCeOHvnQ<Ydk%-&d2jR#LvqujVd%<srAmV|itVOR5irG(E*NT(2qJ71eyg
ze=R^w<H}W)2ZC2aMXvfd$Xc3Qw!f_5rJ?2_tQ{_<{a8phm`^`K>zbyW@;x=3TXH)7
zq6T5&`cH*#JiB!LnZ%6<9Rou>16|RZ39>iC`bFs_jFX96JbKsRHabrvO(PU<Mcg#L
zA#IwjXJUK7Jlp&_LeU~z)goBeBw5omS?=~T6EhP-lXw$ztLtXzH_Z}d@4QyBiL<zE
zu4x^obNi*bO}?~!xwc)TmVK<4Q<bvAduhiHHg<Qc?VS~!KB_sEU2>__b4s$mXMgv;
z6H#9AvP+esTdAQ-n&X4}4i7!eU7pE#eO7x^YwVt>@wmp_!)@GH<F<E(tEaF1<5=4#
zxsCxD-u|HufrWnlz8-<`M3Y;Np+(Bkiyon=K_P(w;W3XQioy_Kp@^qG(eERpBEq8+
z{bI|XJ&z7b{D?>_ex8_-kn{qcQkjsHpOBKCnw}DsQG2&wJvp<$y_)5Dc0<OimtHlH
z?A%ug1<lC?^||@)a|+(17uNVSFciGb4{QSIMPI|duo7cp5KSPY1)5omd|O=fw1x6b
z$%hXmSvjTc&srG~pSH>>N}qq@FRbb=tEx$8znuI{^xemfs!x>-pK?;ZY<#NySW)+_
ztiJbCePd3qM%|aX&c@e`U%z~89%@3hy&JTuZyhM=*s5#))!L34Y<*2^zDTI8ZRz^i
z-u1n+yW`WCS6kl%vBT(lUr*h5XxHFu&)|6f;16{FJ7Rll|IjG0&%WjN{_xMC4s?3|
z@8zN2bHvfW-xI(7{x117F+DoD_<inZXyxq;dU||rb!u*bIQ<eg^Wo>}=h?+&;^frK
z(&qfq+}!f&{GYYyzu$<9l?!V-E9-w2us{C%-CW(oZf@^kw=rA06I-}H+XqLx2Rp=r
z)#IbBgJZ(sG4AAuNO~=1P!y9k0JXQiwJ!j)ET=yZke$m(`jFDk;HD1cG7Tl&B`9xl
zH39$uV4$sK9ysy0nf^xZuRILgSr-<`{LIPQqeQW8=YE$kSI&6EI<YhP?IguloAxJD
zt;LmFb{rE?d3n!SYxP-aX=)2;-9A!>tk*vgkhrjR-s+9~m^Vv=pY?s^i;XX-*c;zh
zo9;*2K2VXSFX~^|(Wn~e?UQSdZ>1Q(xQE;=$lS>d%1?qLefxb|h~yEP0wyokkeX}=
zO#vW4MFxccEdeGfKn0D^1e#2w4?v`*025#$gGK-Z=^HXYMmp#J=U+YV+(c*=-mG*1
zc#G#VUGUkiB#fCI+<t8Dpvu04IMYt7v~LPuI@&u?H8m@^KHVtvWO9s8)wE#ZwF_py
z%4@bMfEbPsAQJ3wn94uVkLpfBx$ps)yDemd9sEw|gXe$!RS)Zr$FJd-iK>2LLQnB(
zQT%cr82Cd(d=ExqVr6^UB^HgJ-z{nz!2j%#b54XW?7m_OTX=AEO9_MtFu9ny>wr<L
zv5*3>(ve{ICGjxkv*U4H1@0CM<zejEg_vxL-?|Ay{Rwz`A@^G^lm4b)yf#2@I&<N%
z_6#UG2&%n`uYc;~YUjO|oWOnrCh$|H4e1rLg;~lR$b*nf1yEx6rheSP*(Lr&X#Hct
zP)SL?X?NYR0Y$x?EYay}%6l0yM9AuyEJTw$zJ{8>%%}1Vr?=XL3s_O;LptA8D*q{Q
z4$#fn&%Kh<ONyDtEYi-Rt4NkN`3p_{gS5ht7xVsnqCk{BbRyUp-t3^7s>|C>B11$z
zvY*wJvpoykWYK)8Z7@{xBWiNQ04;$p*!93Lh7(w?#TUp@QOgiy?L#i@U}*<<#~=>h
z08<QHlz5b2Lg0gI=?apTIc|w6xf70~_QkX_Y6u?fRY{Xk1BFAWx!EUu%@vbYoDD)2
z=O_Jh2{hoDFzVQ1Hx1I@u8{-+sE^dVYFNO;U)2W$zAIVhC=pySE<3YxeJ?ot*E`@B
zKuhOgR~ypIx&N!ru9MZyEbV-eFUiHkG9dbnNPD|4gt8<J;`{*o+^nJf$yWS;dT@b^
zNgnZTQ+VEGqAN@@mEZ=`REErGWu`Z2k|q?@;GW3~CbVf@ENJA|VKD+R{JjXdyREBU
zog)K${e8CnAP`!D>`Kay9mVV6C`pccpACWWCD<VeL;NjoW(}<Vb`+=|GYKW98e<u|
zqCZT!?C?_x2u-F<a-#P!zl|XchMtfq<h7`{HDH3p1id3SaY7Aj*7*{m$q-5>VIp!n
z?@9{ValJ1l$OWOAc<2JguXY*f`$vGBBI3R>)CqTtqmBKTgig{pxBK%C|J|WQB#z&m
zU`C0UM7G!!^R(ymU7mT6+b9o$IJ$jlhzt*yz}Yhp^xQ87XD9W8UHg%IhakEe>42#O
zSz@f`MR<DGfk~_mqK^w$YS-{SI>&rMO%cAg`ci_MU^kiF_{4pU!2;kG(`;Tchc}Hr
z1qUT@w$^jA46VqMQZlQ#Szp1E4qy(|q(j~2B+4>Kae?nZxFWPGDO~FQ&aCnZ86@I9
zC*mvd;Cq+-IR*`bSm?NWR0|IKUEvk{SC!qo2@3>mW<OgTw`mR$peEK;N`u9A`@b;^
zyrX1@izh!qm!tdIW8^Z&$!|~~a}WKkj*go%?bVBx_)lGHh$<P2Ir^htZH!J5N`4l(
zQkGO@o3@}qfvSKa{IE5=6C24eO}hG%z*iNjZzvH1dh@dpPbn7xQ&*(?$&bLfSAEh)
zIsh$E#iv@NdbE_lkyv_%_`6I5{I-ATHSlB>CmS3#TmAFdSAlG|CfX7~s#-0Gei!j1
zV!8PYBQ7dZ@La~Ay?V>l!+>ajy;GVEH&*|^Js%0dDX6qX4|-4<rMJUSC#;ANYtUd%
zi^}vzYGWd6_)#i;0t<yxCtKg$U&S!=P9s&Qy{$N?z8qYG@AmSXo11-AKNClTl5&4k
zN3>k79nZcEL-EwzQ)1I9q^Hu9Aso_vtFd2IziJ=0b?96WezfWg#})N`69~s=IkgHC
zMLZBT0aU;f{y0aa){GnxVrFIm=nN1CI}x(ngz+9MN3bYwCqEbQu}KSztOOZjb>LMT
z4(0XdkiH2gZce_&sUB?i&eGBoXYN1|Kk9^<ajvyKehT0b87U(1$V1;A1mm@q`BPhP
zxNG4;-7nqw!}gueoNV=xZvQK*PJNG+alK9)zu<j5aaXacmEk+mJP(*G7hE6+fm)5G
zoN4lyG<*C38Zrb86k{O`K(-*{=v|)}7)^{+*O@p<d{%;#N*j_Y`8+p!2IPL6^nCuS
zVjoi9jLZlfjD@vXh6Kc_Az^SCtNT(P&P}mMwS0|3y5{aFQ%|UdnSCA~-_$Cox_F@5
z((D9`jF?u2V976tP21w`glkIp<9VUr1IiqFil^Ay1>qts+V&?$G=FA4m*h`ePpNx(
zKqw8?^p~mKc=y_V6eOQVt;rw;QgDn!<?19Tb`YYAeDR{aDkTJ+A9U{8S;8lCpuc%C
zXb4Z*9+ILV^f!MN2FD&>w%09f>eK>EAca3HN3z{{u+w$U3YVQ7H+!U>Z7fwR)#=*9
zKUQpXek+$N`&{!){jWsLgI~WUYn;)4$s*2*gG1b(b^a3_KRAAAWc~IxGO9(XFA}(4
za3(&XmH2g8FmC6qbn<g*P8Xs-r`xW<OpKl)ufN>~Ey|{dcml`bFevp@umF-{;8ncm
z>k(IW=nAOM%UvL@{NN`G{gX(XUd8Sn2*(nxwj)JAV7uj2ZYU2A&uqsxU!%j&3!v^O
z^!8T#1>VKOMwx>}j@qrke9X=l0~&nghFt-svVsD}qzAXC3s+LrD%9hJd^%(#h;<_I
zheZ<icI@Y*v2tTFXVM2t1j(GNLQHoFrE|J%s_Od>+_eck0j=WK9P+#HBu_|L)?f#z
z?3!c8Ia=AISfvX=yVC%BRsYCmaOM!W#oTO8FGL_7Ap1Uz$H{Ezx0FF0DBZVu>l2=R
z#nO){)p+<bpf2R7Nl5fAcY<%2wU@cvWKcD3Fnq{p1%0t;xMEBSkvT-n(Mw~@&Mg`H
zlk6?bnO1IUqG@}v(r=x1WQC0d-tgyAfj>9r@QEQNvS^5(8ZcH%qOVnbz3K@G&+vyk
zNtVzqem0BVotdrPC83VMYdnO!5W}G}9te4dCLT5f%Q>k=j|Z1MooT_?@5;pcH%W-6
zkUkYUg2UW~KlG*z--~b|rSRp}U`C<jeq!j7Tx<7$lqrE3cnYn0aRM2l|6BEQFq2o(
zx|7gu@SX+~Og9(!!$|&LPL2hn@t}7z=_0~6qcdfLprKX==b3br@Df*iHz&J{V;I8;
z`E@%774;d^n)W%DGI#IX=mwAK?Dh^-Mu8uT?p+I{*vFf5tXWX+<iStR3P(@VmLd%Z
zV$@Hb+27w{dq5GY&R5t#-1yEPn1TOUCB;m_0ZYyN=Pn_H!HwyoZ!9)KkmT9(Z6((&
z?vRG|LsyF*4Xy-Y2er<Cvm}xL5&i+^x&_lrfgt@~M8Q>7F+Qn;{CFn9C2~!Bq5kl)
z?=>C|coDEVeE-8HZp7tBhkOavYaDEKi4hl4p}!`k6j*9R>f-9SLnuixeS!&u7VJTZ
zu}R?s+kSYJEfKpgpnkktWN(*(A)f5r4*f77d4^zDG&gIe0;VFi4!(^gvuk<)v499M
zYH3%3&_q3>GwRri;sqpY>-#-f@-;(ajJlt`JkX^A${;CE<=XJ9SIq43@nij?<1rfv
zNTFBt`PqDnVa0aH%6fNw=D~yo_*UR#K{sI`r8{kX=#jvQD0*Nm^0ehur)^VB5CsX$
zdL&aZEnvQPrTP_g`2Pwbge(<lR38E&{#66<fSX@j$N(-3LBBIJzTX(V<0aJ}{t<`O
zkfFNceD-2+QS}tG0e;{ghx~KEoT94<MZI_$W8d?36*DsM-~xC-+7s9+Y+p}N*T9D}
zcIr4xh;zR-12Kbw1gC?i1T|A3@P22+;SDmxZS^#!jopT%!2<OL!A;@`OM&1ztPSaY
zPHqnKpeDE2lps#+?Y6>+;+tUCq|h8Y(P~469S72)A&*(GbM3ol(a!nd;%PU?ZxWIZ
zvDdHxS&Vqcxmjfy945CPjTFPdNb&yTFN)_+Q5v5@5Xd8}$^cxWBmnGuVQM*A`L3}2
zhkKQh`nxh#q_4IbcvCLTTBblov<7Y3p{SEKnVleY7WGv^V7?;J2c!a$%gExAZcyUd
z<nCX<UmNN|ef9tAH}^$uRy7#U8Z)i@?qI=bv7eDd23T{L1t2pq_g}~{0-Z`jQwP~?
z%mA|ZF6x(L7((dkr$r7<#4kw_h<k<<Z5X|Tuu;H|kRh(XW}0&nprQGNUIo11ton@x
zJzZq=7r;f36*7kT{!K4ESg4JI*B%s^kY;H|Xr?~Ja110nE`L4VrHLn6dBs>o?I$Eh
zNO4BM-yXCZz~6WvKB$@sZ5i`{Vo5fzIeT)knRfF~ZGhk52V59jNp5XJUTZ1{&zPkX
zgrcY}A*%9(E8ryrh7x3S_D9J-cf$|qH}d$s*0vc*Zqs2)gI90ry_OouZx=*tUjH>)
zB1B*%3{7QCpZIL~=Ci^zAEO6>N$V`j2j(V3fVb2Q5fDlO+`Ebnyo6ieQ;sGBuX?V*
zLrpbYpPbaKqX)dHlv=%1eQ!75F&)1(+BA3xc?@&>>nBR0$UnFcIaatA0d@+NnyIk9
zKJi^o9s<1Hk5I(hr??SAIz++s^3T|(@tK^!>~jKKmiQ(BB5^mX_AqJNi;?f73v`dn
zKIMw>S8vXlrn><Vy94$fwrM;$+WjCG-GvNG{ju|gEOa*Tu;Dy!sG-J5*bYAdaym<1
z0N2q~67u6eS-p)Jx;4O0;4;XL7GCvb&`h}qzagl36Ey6_ie81G%ukxo{$h^cAZ=Mg
zg=Z{ZErj9kc)c-@PD5h`Bd!fJV=v}VYg}UkXBl?duH0xtj`(4FKa4mB0x=TNV8iDn
zKGQgLmw0A0KOu7Or#%6u-n4%36e;k8On-Y$iW5kM^_}lXB?D%ASUnr?^Ql7{Ceo3#
zKe|>iH6%K-wXeZWNNLh^Im&50%;cgb1wG*aLcZgi{cA;lU(Z|(M2V<qa1u@#6CE^p
zihkRD``~1~mRedCNL>J5{Z_MiL9yEw$77D_84pw_6}qPOka59_W0k0cf;^|zbB+w(
zkDhL$5x)-fNo*xr2^+Govc(d8@yIM8O#REZL5;Ehs02F#gFML3I*9g%N=cBn1a66Y
zEX*Q_qDe4#Y|EkH>}3*$^MJ5|P)j4Y0WDgc8+KMH5QjY%%+kHdk^gQ7?MtQW0ujck
z+UegRCgxwU)4!$`L_x+&VAJJ2I(Ycs6mCFG<RD{v682F~B11!C`-zXY<i4;hPIMSv
z{FK&?EO--OMNlPTY&25B-_P53b7nP%3XV<>q{<+g{5PlC<s62g`fVDrRKcRFCnC5a
z7?5Ll(Cr95<3kes3G6LCy9sI0oTPntp7dD-{GL$3?C3)zGkC5kFjzdc4RABZq4UUt
z-Dj<#sAc#q`lny~wgmYo-V3)FUkblkFsr38eKdM`NfCvWbXbo1a<V?jMcn_(dI>?f
z&)3&?MZY8>Xmj-sgRF!SgVqU1Mq|wH&54s{n6DakL<bSb*+frWmY=f^+&})G`IbYm
zjk1u^eodF)bsmB!V^m9E3NV?(9ZaIbw|9Hy%_f=<Phgs3_?UJLZsO3n!Q;Sn{zOm^
z^zcsvohNx$zMu2eunplQeg<Moz|oYl2%w`UW~>~8)4)il88qW8bL`t);r)$#=S&lu
z(f#^1yEkBTKMc=wl1>)cI_o(-5s`mA%y}!P;G*QN`pd?Jyr$L=jcF;y$n%~)P4y`<
z2w9>F$ul&D&*%qh?DH)yE^t-f1-S1Y(?q2SMrTh{vrkBoQ+{Fgz#A8!@_3(T=6~Jg
z*GqgR3)VNvJndYlcPs@d9$rX0mu!?QdUdZLz|{=RXvPInsuuF@j;0*tp{0;!JCi5h
z1y<}P1H44<dN=KR6Yhb{@ysBl1wc-cB=SY%AlsXavQ$UOXF*Q_@*-aKNn>>~Fd;*9
z8Q?hh;3{+fkuFxNb`2F`bFfOe;Ufal@VlRtFpPl`Ej4txUft(F!{?|M;28Q5F@FN&
zDG%JwL#L1)2Zm8_RZ>>P_g>Neqcv?XY%fcLP3D9ZHPRZbLW4F&KV0P|@QDThj{Ht)
zX^24ajDaj>^|2l)oM;ttQlj?`EXJjBh=)fn8Pln6xe^Q;H5aLGNv*-KIm1!~xbsKv
z=uj93Kwv$&X@K2Vy$C6Wpl7BBNXa$zgodC77m$;CI!#rdvl}ig=cf#WSs-WUYXqFO
z{~l=1ePD?eP2vwLN{%H*D3M(`+8)B_bj{{4WB(C+M#Qi7c@5dUpD!hxuHy&PTXa=5
zo=e^}EB=OjON$aEOxN#a4qukpR~byKU`42A4+Z#t_2u|+*$ixok|NAomOxQ*wB&cB
zT^78SIXMb0l_E39idfScjFE>3xZ;b(rD%y_L>gC)ZXPKlzVl2PLFdgsGxt!Lcp!TI
zAVrP5vw}FJWME1(n&Ygi*`8!sBuu8T5_3Svo1%CFs$nT;j4D!R_BBMn2wv}tPXXS)
zj2?67Q~U_un*Ea>v?-#<(sBko{0>-GG45SPc`8Ar*v0i(75~Uu!W@bbmP{9+9JagU
zRi%PI&G#~tC@enn#!2W&wzmedb9>gXmQwroebW=cE6`B4t=U`jT(EXiG=K4?eqN(t
z4v|37Tm#3<oEUG+)NypdRdvAg!}=YEhv$7Et#h+APOJh02rgo}*5DO<`#9!9nrP3U
zhV1TP?HMrKpLDrjhwtbN8~jwa=>+d{h@ZqvmigBq1oK_m9f0Lf0ika=E~$T3c**!w
z%S-_idM4CaZ%0%EZ-i-^{X6{;e!RI+zURe3Ptcd5q9<9>8&|&rxX5pQ)wBYE`fBuT
z_u^2FQE*Y0eZx*b15*DU@JR7@&GEy~ZvQQ?By{u#+;yt!EGGeX(_0we)9E5w@guIV
z(D|5^dOs$47~0r5QVxS&!PL{z)*J3`PJi^^aV5iZlTPEn8u1byB%hO}biwJ}fiRv`
z(RRM-Kng@u7Jg9YR8<Y5T(OH+4lBHxecoWT@4cRa1}>+s;0uYC!+W%+*h3L&t3vD`
zN`_-8XCN0}O9kRfcp#qd9(q4gEDsnAkPm1b!g?W5vy!>^&8f^3gW`ypYn=qO`m`A`
zy^_{9L;TIOMk7%DExqILq_o!qq|gf%Njv3WY07QupZ~HWn78e3Ame|jAJcrySrGnq
z25r9r^IjjTXrqXNm~<dLvfxc5B~6co1Tgrf>SNFt>obH6RsHC#%yZ5tCn~xcn0oHv
zcbQI&)RX+wG<#Xj?A?>IT`BhGNLI!jESLUgMrrbghnUaR_{^yAtZ@F&edljzJwa5`
z1Hy^mRlMkOt0~O;RFIu)f@6$c`fv31P3d)kO}q*NaYewT4@victo901HZ*w<vlz?L
z)v|ZQBPz3JBy*J4iBmmH$V?jkD7C1Y?CZ6L^$Pk2OJ%4~2kA`7SmPeJR2Y8z4;;sm
zM?j!3>fz>F#*Bz5YT-xcLwN}s(-JZ8T3Vby=WM@PlRoZXFYkE&mgo&$;ztHF-QE?%
zo2?`?X0OOA_iX_1%~LlEOy56AU988_-QzoKx`4j~(L_e{$aCE;wYxABNesZ15Ci!8
z6_{Lh-l1-%;}06o6Pt15cFz(BLU7`nWqVF=s69AmsZR>B?Me3SfRwvb4<7J3jX0$A
zC*`%#q>&-GDCHI<nP$i@Vgu%%<euaUI|Ahs8d`$|ejlwrc1W)lE_NWv4>trRrDbJu
z+3RcEoi~-KENm12eilwVAnqD_S4L>&&?CNUOaBUlh8mC|IFJrC=k)2Gu1L@m1)<V@
zf~V?p39e}On41ey^9S7;<|H@@3-Q;e2@!3gOoS#YwenpNHYUr8g=D21(s&%J`pn~o
zekJ9JUGYhUV#)f$jSH0b6EcxuaXf_ust4y&GT;)ev$CcRj?f@FG>yRs)Z{3Wh`|sU
zbKM07&%@9mbP>1piI^u7wB@^S6CfD-nQ(5G97Am2WJY|rAi>m%+%Vq88XF*F2rC|_
z+8xIMbw#dPDj=u5tJj5c;ysf9_hQ25Dfz6C(E?)!eBPDoJVYaoPc8OF$jeKtxF0X)
zFvqj@XF>BbL2<~RHikt584<gO%xENjgh=2}`lVU;BNIB(msv-ggO_d%2$N_0`9>1R
z{BN-kqha1<P*jC1u*TJ!F@S*KznL#0&W~e!RT-s8w1jC#>M1@gMAMtl8`F}jw0~5e
zm?qqi46v+UShCPbiX^5!TxmanKPc`)UcTLl#K7U?KzpHh@#CthZr5PYHGYV|RpK@g
zZ4Qf26aqoea!-O|0MZA{FSQ)ux$|HqvBWkKKZVrjeMfFg)_Ig9kfJ2n^O{+;#k<L{
zko=H`2Zl}igRiiRf6eMaToZ!vfeD_^5$7o21QB=zlI00`S8fMZRwwJgy@oWr*0T97
zF~TJv0<sINxbk}p=M0%iMO3nhbaCk(xHz#gYKo|9>OdNA$p9xo9)BF|39=e3*ueJ+
zxFDGNob(W>IzEWZ3(0o|!Vm!-SfKQ^RVZnjb#5ITeN0P-UjY{{hA!|(!H*Ms|0*&m
zAB7mYLT%{@mUJlj35ma_&?ko#fk|<-U!-=B$2a+{&hw}2{w!$0{e5c4A@yt-c@Lzs
zI2<)xiy~19eGcr|XGlBBy0q8uQEDf~&#;G%4*L95HJXATbm{j;M^?BVIe`?c;}(+o
zVF_CSn?EqU*af@|-{}nni!#G$P8ACenoym~#CJy^em^vM@p3xc4S4<}LzL5Mvk-2b
z#w!d_t=jiMuBo0@LRbYngGfJ}A#bD5NUOAOL;kXdpKxNx&qL4#&ydLQ1=c+3WnM<2
zJOu5wRWAtc^ktTGCAg~Dk*ksNkjn@(c?UD`(wp!Rm9*IQZ$ht`9o8)OI9JSv506I^
zN6p#5!NvkEB=I5AiAjK5z=KFqS)GoawpV?uOj?)?KB135UgZV(!f9wT{5?XRdY9+%
zOBf0+U`(ub!&gut?0kHlxOBV8{<#Sw0h}K+n#@Ey>K^!#yw#XgII}X;oIdhl+qhJa
zDYial&g)md2N}F+Uz-60%~BeM&zihJvh+a{cf)euNDBPv_VD^MlUjsi^FMwz=fy=b
zmSz$Q5UL|m<L%<Qk@hQzkhg7f_N5tcI(W$bBlz;G=|_St5Y4k-SWEg9iSC5)lZ8~l
z*Y_4=31UE*(4B`W>HY@yx&^|C74=HQvoFF=vopmcsG9prO7NMBtM--D984BCjtt^6
zVB$&Q91C;yZu<MjpY0|M77G4G`=J2_$_^3WqPY1+V`4vGce1LC6zoQ0S)lM}Q7S?U
zoS~gmW4aH}u!VoLm=kFAR;m6g29mssR1G9iR#N2<a#|n+RHst{n*U0K0D4+Z1l}XR
zrjZe(%AgQH-+?i?!N?{qLjQetdMb&6o}Nn@^=aLabV1V5=><XLDT})Z^@hG3Rsr5x
zk!j3i6V)K9G|rN14&aC4rCM;)vlrvth+73z233R`!CMC=pGmjFegtS^=niqcWA>u6
zuLhAXoFv7bVkLm;qCnv0oHg$BFNfn*k4}r306{W8eA`j@1m9FeSRo4T?ju`H)&>X*
z8_2N97^nt(?<c=h;VcmiZ#h~s7T#UpuopSvgP`#NaM({;sK*sBp&6G!i9%tdhr{=u
zhP2LpRY{r&S7pL)280bK!6OL^1z{2O@WgQ9yawfFb6muM-5Cf0eVG$+hQN7hx68Hc
z84whQedyN4?84WKuU$UVlf*c=d48eY9L#VPanFNJ^NbMT20f|(&v_K7B>>PAw>KUO
zYOWU8@12{CCmPJ)b0JJ69mr$JRKhRg8NwzbC+N|W1;h+Vy3@if&z+)PgNS$h&6hsr
z(=$V40bys77!h7XiJB$iDQUg0pW24L5`Wwa1hV0Ke^#8m1`KQIO(5tbf+!8aiX6p{
zuc<{%0yti%JK}Tzy1$x8V(-H#q<Aw*7Ql(q{3lj?5W;fDZFm=W{<a=Y@kf|cES_4)
zCe<V)ZV6C{3ZA~zzK}A;Kynio7DzScp1Z@~S`K|{yLODEf{Nbw&W5KA;cC#1TGJ29
z_*s?doMgoGsV|?01Ug>Gst8_BZv%DmV8i1}#}7pt%VhcYPrR;z31^buk;uNZ`)jvi
zi@lbIGj)$aoP%i_<4F!Pf^<yM8=ivlgK-;YtS?63i};)3PSL}Mj}qis1nyKTQ*tcJ
z^q+5}WOsJ=&hm34^b&;X`Uw%}P#%aw7angxqgm(h^GbF>0i$Gpz(j64ZH-gP48dV%
zS-?T#RH;z|iT=M6FE%~^6K?E2+`N7hQ%W+GUTUT<G_tcU)zr@?9f>~PI60~7?+6aD
zsQxy_RbHP-d94^x1xU_++6&-L4GtsO&M;DK$Tjc({Sf?hMe`<M8K9Z}5eo&tPAOA(
zM0pirb-&E2dW8I$sx{4(*<7}A0sC>+we>P*aV#34AF&lF3(7W~=YDV3Rro&K5pGpD
zHQg8p6l$ohuIB9f2?ocObGR?nkAIqcCd%$94f<=9hrX?>tUOXApM4|;bHGTSJ8N%3
z;o!(5r7UytGRx`S9qfcTu<M?EZBw?<DS81&rq|3Q5ke<L=@9$T+{Y(p-tAliSbwSi
zPCSgPD<Cs<V4|ws0#*ZOrW3hriljIwJ$VuBtsNa7E`(H1WoX1hl(OA1yW<|s^*bIS
zdo<vW;Uk4s*&A0r7V<3pnS4h1_sjl=&5I#A9S@-bevyb%f-}VP2y0(GHKU<1qSy+o
zRNd;wRYf0p)U=;mmIkV#?3no#sj^9qTvv0Tq}1SD_3T+_XlwZg^bO0K-XDKvXec3-
z)@HOp)IDjEqtrpxN<HK!Gt|5t*?4<ITtA=4a8uDkBNP+Hs1VkdHxD0HdR|SeIIQp{
zxv3n@`D<2bJxr%P5Yi7z+E3deP+56QY^VE{-%ID!8UFu{5(G~J9(Zx{=l>ksx&%n}
z0}kahr=&OwDllqX`yH_cA??P~NJk1i|0i^+8G&+|0$R=YMOSzH)sg(9Fc@)G0*pyI
zkY_?r9Y?WU$YMWr8E!gV!?d?em0g^KSC?D(tk=<Wq*Mz7>q4G_WQdR1P#!5<vt1``
zFNH&)%V{k2xBw_N+B*(SZG6;aD7SwuvInpL|K;TXLGzi7I*wUgu@KH5mWvz*J9?v-
zzN+j52)NtH7^K`wA;3)X?FnAc_x;C@=D5hZc|vSNs=%5*C5EH^I_(44dm(qt?CvA7
zFYy?Ps%1)QP&P5xW=Bv;R((nl_f!Uof=20@&Zl{u8%@s2l8b@9^zl(1IqP`)%@;Am
z6&uNKY=!=YSC53RE~`6LT$C1>*1p@WboEk?zHFV%4c8I<(pq6ox*%_VQu&e`p`c#i
z1%Y{k*R84KexE6(>l{nz%|Eg>)Sdm}!>Z)I!wkj-latgMRk2Tf=eJ75!@za5WmT8q
z(df?k^Ll;0E@B8<@e$M6+%6NoEF+iJl)}%>_{UR9jk&_Nz@1I~o5jjp2#PLS)GdUh
zD5c3$i^v)lvIz(A>V!~BGgHg;YbVxAOG};%sIdz)ngt#iVDMMq+eJmVUk18E-wodY
z8+20SmVmuip+|~)aLU|e5a1n|PBlZnF~whr*_m;}cgkLc=$oQ({^W>jR9!Leo#XlQ
z0#H5FP!Da;zc?HO(e<@{LE7IVr4#~J4^({*9d$5{R%Xnm=r3Pc&C`=JGBRvq8clD!
zi*Rj!ONSa3+|SL5;?Upj22;a@5Qp2)tI7sF<i6gIN#>$JJmRDyN57Ff?oOd8Fdt*5
zOH6!|i05+st^SSzjfHZHEhiNyl?;Z$U(`bOxu8!TPfovmXCNEg2C`<pwJd}5J~aXp
zNbESA>mxbW88EKtLW3Gpq-%`}NU2gh(C@Gty=9ZL{_R!)4Psiv=ztZj4HL2MPdrZ2
z08+RUM^{UEkIfXQ-N;D2iGv}BihEvKu&zA#YruPLh89ADlCLt)W73SJ?{>EUE5`D#
zeyooCTW2za+s|6^p-hqXkG5vPK%Vmj^}D-Ky2E`wI=%}G@$XEp5{3gwbOkEV__ZV5
z>W7L}zs(Oz-V=HeYhDgk(0GA)yX1D;ILWA*Al!PvuaWY8WAPh{fv3F@J<550Ed54I
zE-)yQn_FI)vx=0A%sbjrUJ}OW6!?cwPC>~3=g!myGXV@zi-}kN)d{U$fa9>%8S?ky
zr&%QgTx_|1w&YRGZsO(tFc5%(R|I-8s0TL${NhGn?Zv5Tt#=*3qq2buXLV`oxT1o4
z2RA<TPYLa5$=jQSetWpCGi~IYz(}92v{7ekK4arqAik>O(pksU$9=Kq@qobAi}O>5
z59-+@m`~%&1P=Hak%w%kb0O*yb?n2X+&nWP75H2PCu7dDhgsLGc#c6II|t0PNCB5J
z?4_}cb6Z#3`L*S~)O>nLb&9DxLP09464IWckPlhmrTrtpw7QgM`ZJ?L#^I-jgD(C|
zUvjoGWkHYAuwp^rrdZdkLO`#j;Ay}jwNhA(T|etJxy%I&8%fOt<$Ce`pFY(cQ_-hC
zxJ)@j?`+*6Hs4zc8XTG=@_BZ8NiUV9AoEjD=<*a={W+^|b;`D{kiGA$Egi(cE1~<o
zb6&GpPQ`VIRWTHu?s4}@z?eV~*S~3^K<+?AgWAwTQMuVnsVR&%MM@3Nk0v%?^xy%1
zj|W|N5YMFyy29*oA-(UOazJrRzx5#$RfoQ6L4Rhz{w!B|B<$d{UCpHk#&qabQ!}+_
zsT2@%Ott!r?~=czICG(Pad*LwW;L^=i=MO$8I-rG+kb$SGVeyp8flOdU*i`0Svo|Q
zWoi7&p8CH5VS(+{GcxVGKbFF{rk7}4KGxl=A**4v%)gv-dLJO2s!LkOpLUQY%aWQ!
zw}0+*ABsq79^*j-D4IQ(8b{?IcQ`Gn{^4VweimGejHf1H<rl%!OUlpJb~ATJq$c-T
z9xIiu7Hujh8r3)6X}b5R^6tGHUfXx;F=;6-OaA~d&lv={#1|)djG?l*aI-?w%I)y5
z>k319nf(D_p*dw$P&;76aFbn`*Dl}+LF}&+esuh>p<U)IVd)M;dX3pIafR*7oK4L2
zM%9<WoxkH(bM9#rEbbYyBEr3X2c%uHtEG9Dv$5d)g@j7g0Bp!wPF7HdqA<Q(l~0Dk
zPia{&jFf*rz_mhB4l;~hKc>3boET}LgZNvvlq$Do`Q)-j8^I*nE0YkbKk$IrGXbKM
zn(S0e^kL(=w3W|9c*nYU^3pu&?HlBU-)9EH10?=DYK3<puNA&8T&W612g88pW8klx
z0Y!J)`hj`(3Y`E8uk?6DZm=$Bzs4-yIcN1Y`gb1FFvmq|l$eq=k_vn4Eh@;_t}-`=
zir(6;a#()D6qjat`x^6_;DGW3u8I|I83)Ix2H~1&9KBmflTrY#AvVW93{Dcrzm5pM
z`NBevAUxC0bx(q#GF+G)rk{Hn@jXxV>h*qcL+r&-Z&?DcruoX+ZbioFI(g2axrnDC
zx^g){`%rcw&QJ$2_U28Ev(HIAE1JV~LK@>WFh5WlKS&f9m?%c668Yek6A0sXyxaGw
zB$WXAJcUES<CGQKSpu{5%yZy3m~L^KT)y8%9O(U449O^n)qS?W=i$Rk_zCs?5Ew@e
zXBN`@^=Sg|UjR~Xmll`ih)mGcRa~_HG<)t0`y7?G7^6msd(A>IuimY*1fONX1+}U|
z6fMV;E)dXvX8|1?Vr!h4>V6EY!W2z!OSMpRW#~tg71<3lOX6P2k4#(NVnM@%)5}+l
zk@U%Y*J~9~dMVJNFTIJiu6On$tMj*J7Z=&4E(%6?-PKxwn<7b+pi58eFyum0ws7Gn
z1rTugB6BZ_6?Y|!&UGWhWn1-&6U`01td)%bN$&FgUe3{kKiRu3OHZ^nZ{OucER^xz
z*%=f>J*TW&%#^^8>)N?m?h9UeCv_3q=c6xtK))EB&rTJ@>wMzUc<&;*DkrLp7BqZ;
zh^c5TU%%G=aVzB`xML~W-4O1DS?UIU%v1ANvn0-l=X2TNs`m0qrL-Y4bDUS7^E$nY
zdZQqu-YuO)`3M&J9`ud3%><>k>EZ4AT}uG(cWST4Pec~z0zZj)l*`i}X#zwJA7WcN
zYcN9e%7$GqC*AiUq|$(jr1phWjqeH@V%*ylo@-%28MZGVih+a7)QQZr>u9yHK)Eyt
zPnc~m=Z#S+CJ$7^VA#X4$xUM;YMUFMma8KMmB+Ba+fYGS2wKTXiDd*0hY&HwC<fQ=
zoDrna6UwUr(O$LkM=lRv^heU(a?BQ6ec7L(%xjn#bz_wG2=c78yj4lFSxs+)%;$=x
zXm?=S&CW*NJXLjDXCHw?4X^8Iq-yNe?%AUh4j9wsMGBzK0Bk+z&Ur@r-qfA{?dGZ4
zG@|({*Tz8pv){c3tPO%mKVm<<USfD^ZAXR^1HZd^6hG`N?D-8b54)60T_mJ!Rxg#%
zrZzsVLsbZn5H1D!0$W8<z?b!^Pu1j!t5ibNw}%XQ+Yg||O;=nt%PA`*NeRz;;lkJI
zL|6PklV{HC3LCY71YiQ3y<kcyhw_xX1Cj_pN;+Ul{{Jlr{J#=z;S)-MK1jpFAt@gZ
zE>jjV3e%Eu^8XPA{^8~Sx#EA?0ZAnIpRA3dFIC9?>J%EBr^su-VLrjCKo)EZQBswr
z1yd-H{y2U7weQ%%w!g`&YFE&GvecSkdYiNd(S0j_sfMnn{EfARK0VjQK~T}N%`NKZ
zfBh`bfE)0Bq(Oz-jM0U!)$P?BGzWyWDB8#Y>=1Nt?tT3JJ0Tj#FET`MRwGM<(>aNQ
zrN#8bdqHd4kKeKZ?^r5qDA|lJK}Voav{m8RV3tHH3j~4xK;6w;1j@TVJt+l&zZB6s
z^ci#j@QZ_Y@3)xSyLNNKdH1BjdpjlV%R9V%I=|EY*p*R9qYL}<)fi$#p+rCGmDq*X
z1seUNn5BubB1hYWG}ma8lUfaV0zL|mhdY2^>uTBh(!X(8U#DXkP}igGWIzQHvL)?{
zS0An1Px}`C*fa5pQ6(>8BQfJS&{hjteSxr(fH;g&C2ujZBs8?IW0N$ZmXTZAZ4rTR
zc_7#w>BxfcsGAG$Xs1K8?{8c3m?6FD#$Ro8MmU69M5`euV`l%H{qSo0$>ikYjU}^>
z45-L++gS_t+z9$>Wxa?dUh*Nx3Y(N53>(T|lbx{_Dn{%Us#%~HLJtG;uu7KhCrj@v
z)+jRO7<<jqzN+TImRCm3p${qiB$zYb%5Jf{De<!*x*>m`Yk~0CcBFX7C35r&Irzi6
z1Z@N#CqiMrF1$YMccxbv^49$Vti^*TjI2#tkNY<jZ9Mv5*uT5mU_~m`a!V+fS4Zsn
zGcn%}0xE!SrM&R>_#ka4Swez$LbSD+Bt42<<o>XP=y`{|J5UqxPV&598JGOgvV=lj
z_piboHok+;h0Xwp6k5Pp1bC~T^!24X|6l@R4pWDfsfwETWm1QoZ>!}}uhjV<nQ(jK
zd_C9F8|v{sAh7|@iV&(C{%$b(%k-5f+Y^hX&QiNe`fkcLerYhQ(exS=#hzXw{3;mi
z7JgUpa~20Ges^g=;>uTBR~~d;G1X#2Zl+?LEYS5h>!(C!9K$Oiy>f%9u;=U9)HLHa
znpeqy<$Tc-2q3MK#l?k<Z+$f*4MxCwa4c?i+)xR4(S0J15eIqwS(JqVD_^4^O1IHE
zjyk6H{m=QYG4HV&m<~16>uoRNxWMs*5JiyHO=cTB8_mka?v~yj=BnEGaxv(xkoZ^X
zWaywpH62jl^*vg|t{uWPsDPo)@%`?AXH2dwH?qy{4v4J62BV2mT&<+NPXg5?f0v$G
zjf-Rj<tapYFbkr1Sn71+Xn+8vfE)4!BruIE&{_0Y|Jbxuo?CF^X6;awOE#psMB;Ha
z&&NL>C+6P!&Bdfp(W<Nna2dSua7-N3u^gsQ5=}q{x;b5WVg6D!MC}Od=8Eu$H2o5a
zx$Gruy4F&9*CF$(x2qT^^C~s3Aa`J{44b$S!QU9?2T{6NpSjQg15<Ce3-+qGNj`md
z+uTs7X?*@KHC@Az=k+faj+@HqlOcn*)Al^!Ha{)`6H-0r>@8qb0xi^A`%u>V$_eah
zw|r<FVO8$nE6NNuiXxX$6&}5P`%el_*JynCdk)U7h?x{-11a-sLyz4*IZACrOAcBG
zN+t<?o^Dd~Km-P;?OH!pI;sVK&B&jw6tg34l2g5te=+sJuvNo2Eu2hgQS2&tq74(o
z(~@9KZ%K56WEB`vXdP2*?2$bx(!2i9InINapt$=(UZv(cMW`%r&dKw47S%?`f)wOv
zs~H->j>ydM7VLv4S;s=_h4BHNPU9NlUVo5mA3yhM1wXf@vN%F5fG$1VUEiPY!3*y0
zo9x)h6<yxpZ3yQR&c5gET7vFy&YmJDQ%8u2zz>(}v;xLg;sz;{B9oRGYEQ={A}S&*
za(sJ|;(ZZU14m#G&`whhm;a`sB@cLg6+Yj746f@Cwytc~cYVgsjt*4M9q7`<Oq_&~
zsa+%NQ&I>d!kQ&0(9te{*U)|SjY@%OJzf#@zV!SGkEq_J){_+QoQ(!$AVU~uxlC&A
z!EFdb0qL$>eRdC@#v!D++BzFq-j=y<!1olsvKh5)&EmSJu0uF0#Jz+?y0Wi2-R#{d
z2Z$j-O}*nOd2)x$fGL?`57=L`4=R3QeNLgSvs1SAu6W@%DwInX-2@iTbIs8W3I&(h
z0W3_y7Gh7>*hq)&8f4ya!|OA$LsIvVsxxd*>C)kC8Yl9x_bl3UCmAt_vh5kMW|a05
z%bM{!9IKj^8VWEMm`mTIvu61uHMzYR#Zi0bkYdc^lV}zLZS5SD5~*7ow1pyaI1ou%
z^bu#9Pg5hB;xv7M7q>_<(EpXzPU#Zq3tS<vwJj;QNacKqYPT9fW$J1T=)%9KpUUHm
zd<YYZp$jlmlG1jnw*SusPM_8JPj3I;w0O$ef*^rje!FM?rOFOrP&M)BHUyCZA+cS>
zgtMFoi_g0~(>#3K&2^ByNe4&&BL9d&k9V&7b~a28rk_!f7^B)Js_O4!&AcGv<(Hpc
zl)1Dn3c$9Hl;1TA6JVMpC;P)7_tU^{XLHP?+fXS`dU;wX*Cdq-4l3_k{C>o?`060a
z{K?qj5hpm#JG7{ANhWjsToK3@5dI2n=Bsu>^%C9j@DVNFxOM--4;4zY56iTgJNyq=
zDIAchy=q0)q|O%1rTPK7%*7^A3X;Fe(9|ud=5UaM!=}jxd0+I__`fH7I+Z%vAxds$
zuIaGqkk`NYgaL1No))Y_3xcvf7Rc_&d+^GApN#tl!*`(Vp`b)BN&Ut%K>D`|+arl~
zP@ZjHE|p?l7Mr>Gw|_EzyxoO;<fS$uVKdsnw+INp1185luJmHaPv;y%ttp@OQy;8s
zqP+50pa{Mt;q4;&sQ2|`=RTZ1Gw@HNdgk1H;7W7FF~_3f{N2VJpJ%z#x_9WQ!BhXQ
z?)H|+5R<P>9$ogoF8@<lRf<tdiZ)+V$UDoqYxnL06~phnz42bOKyi7Y{z0DK>rdr>
z|2}H6q17}esZsGYe`1%nWhVv6(UBXKZVFq&H)n2|kC$1IDY^SD4CK+$A?EqW9URq8
z*?t8h_;6KKOTT*TVONhcd9Ox{FBedCUwlyfS2#GQexOj64LCn`HO6k>Z@E5$VJkhf
zK4%B6K4i6f|6z_<VBLtBS%B2$d%B(!2-D~aj<EUMt2-yH*2i{!myv8?K~5$#41-D^
zU!{~oW1<yIt6#yqhhBv(>PJ`AKwtBaAv{{6OYiKt&5J-BywNSAQ!iu=mgw9nvi0O(
zZ|IYer!|>EJ(y-K`mabc_o;~V{+IRC)b-$q-WuO^pSXDTB)SnD%1<0&Y(@tT>@Wv+
z9zyB7UBRT?2%HR7T`yebPZ}^j6?nh?_}f}FDU!|FqSYZru1uRk0RC^>q`gvh`=$+3
zn6>QJ!wd8zyx4IO{CXrCCFP!LbbvZvE>EgR_0U>?iAR9!5+lNnqEyK|PdIjf_vQBE
z4R$pF){ZXa&b|E)6VF@9oiptSQI*w6adm^T9pu=eE~Vz&iWNS@kc0HQ={uI^u7pQ*
zzQ|PT9F*cNFTKoe8GGqX`21vhy}7ea4?W{{h+C5O9?x8xu-jUL=ONF_>kIvF@}o2%
z1iR7^g_ye3AKJ;k1#jC=;o>ZB=Rt7jqAImaPlmGH46^vmPde`mF-=J)Zz*o$jQQ`8
z=gqI?wvkFU6et9Td-w0CMq8IJ;D+yugxhs18)lzVs4~BiZ(WDOh6eJ_!jyZ4Uo7(6
z-o8-X3JGl0u=C)x>0j$fqdK%pGX#Ui7i6S{q}FO(s16q_v<2UtUA?;DNE&S=EszqO
z$ND!KtTpX10j0ZWo%(G{TFp=XPI*6QkE;SElp-zZQK+e@x39xI`;oR_jg3TeluaGP
zJI?7eH0<K$F25+(+Ttw(4ye&6V%25#Mf9)OKeriCy~1ubV|#p;d!Vf=;H|eJ!-v_K
zShKr1oxkP}Y&n1>d(?v4G|(Tdbpw(qcWPI@Il1fsVdtFJdIu%;KOq2+WPgbTEd1_G
zai!}9vBoVT_O>Id#fyTW^FNlpSxW~VrLvUoa%c8A!|>%d^b538KdLDcuHJs!W*z(J
zTbeD0vm{O7D%6*E#^`g|sNX$VwR5r2;mR+cHyd2;6dCqaG~p_+TI;%H7TF=<*dVT6
zDPKt&G<+jN@@M`nBW{AENB5$h<1?9CQ_sGA%T>PheQg+OcEwns1A<)|zB9}jM+M$R
zo&UQ2tzlQ@2jsbFO$9EE&Sz_DtJvo;8(qh(%^eYG5qX{b__7RlvRB8!XI{h_l%q#%
z=%`~ISGL)GWm^h|xrgTm60KQAmQ5I`&+OPfP6*n$`0(lL*he0;l@&fu`K3G*?p*nv
zl1!WWGxnPKTN=byn(~+A#cy*S|A^zh7ayNjol4TC&KT+_myVP!bVouFl~-8C>)%^#
zU)z2f!2GCl54#|Dk8Amx<tQ@bNwA%5Xr67V%v0nUOtt=>`t8);Eh5&Hv_2H?!*##b
zS*5I8P3=)RU-d^in`QFdb;HQk<SsZS2PgXbOr4!^s<K*z`IF5%B<23Qc-y)S3{|O3
zYP9iCaL#c%l=(UEP9^&Abx57<a|r-EZ-C$zmzl}YZ$Ho`%w@$uOs=b}cV*Is`y0%p
zOZJS$oVTvAN$Lq@Z%6+p(HlIw?mGOJ3db@j4(NYItVXeq{+us(v3~7m@f&rinZf{V
zkc=E}BlfDh?-{;(_x{`^fuO}36IX<TyzliNkO$wm`!jzsAsi2Hjv_i&7_xTo$qc-H
z4WvY7>@yc-63ZAhc{6_61{ltR-NyOm?w7KcK4&$r!IBMs)X6#vh*w7dZ&|{52_74$
z*;P9y@#zaa3XzcK!QQx5S|h2p{DyO`-G8;l<lzp6!@23V--6^{R<0j}m&}C}Kt%@g
zYUo$t#Hi;#sXyqvv*ZFgY*v>7-ku-HeQ)h^@sUpp%dO$-l&QVv*jWMVF*{p4Tt6Jk
zi`ZrqQnuYDEsW~;eDVjq^eE}JmnE#`dp13VXPg-|VCEV^cWuh`R~ETx&5OI&U$ed_
z>g|Ep1{&aJ!iupQMm$pbX$J4Ue_79>Ria5Zi{h)HK!o0GdA9$^?2<+?-geC_i@&kU
zWrGV&2Jl^^Uy|gSaOe*z>84Vj(CF(tN+3r1k^Y)b?{ngD)8FL2myd;FkZRMvBg+@>
zcW8Wp=s7PU+o2rfg|`f3u`hL-ba)dDTFyfRLfRpm(y0RN!U`e`;;w;>hCTd!XMg55
zcUHOzoxjbHXsqp{_af5=5+rUt_VV{Tb>L%VnR3XPpnGjdo`5OvixU3)o>4PB+e-&h
zc-i2k;~Z(5jOutwr#zik5QHVO<=%~2T`U{El5I6F>US?5s(IJ3QcxW2G6-doQuD}S
z(G!f)T6=H)^8&2!Jv6(ivpznXt*edwk1DgsPmnD2{(8h#%TgTMi46`-4mudkGS4K}
zzadPoveU+C8<2q~;SyZtUnR|*5lVrlKJVTq<_dn-bR_kwF3#G(G+U+{S@+A;1AdlJ
zsV-NuT3KHUsr!(@Zx$~3Q~V;sv}10Fu(&kvb_MLh<Vqm^%<%AV>beqWnzx!XY@&v{
zJt<_4>3Qw<)$;tCZ^C$;)R=s2VUBj^Sm5BwynR`@T#4h2=;4&L?<YNyN6&#=MXN2#
zi{Y!G_phe?Zg>}+M;Q6JYUx-IJm9Kxb9FI0OP-;pW8=EaOA2lARl>!MH5gNGPr@A>
zuaR-rs^n`Pb*nwdd&7i!LD*ojKqb6|tT?K)+flbs@2(IO@fE!*Sexwh`CK%l8j)cL
z(=>I=SX{g=PKRiqo7Z=V{;1+(Uh4J|Vqy+a>%T<*=HZg#>-e3#=c&&iOj7Tm0yFi+
z_YYg+S+j`J`3<fQB-Xg!GD^mX#e%|quXhl(|0{C1yp_Myf&K-UmV`w(`g)K7oM6kp
zhp4s~U|G;)N1bgJMOZ!uzPZP7kK}rkE_J0B7(StizXngv_{IjSCQJt$D-#Io@B}+D
zNfyM$S5?-UGD+iU)TYe!-ETZ`EIx^sV=f7Nl#|V~J7EScm-gO#y*|Vs`STSCAo4J1
z8muNXiy+@-dSA`_HFZ`~DbIBOMfQ9-hdwMH#!+hIX<Se%A8T4FdBF$VS7rk#mtS*Z
zve7~x48p*gX&J<Y&MF>f`TNX^Ghe$SgvVOi-ATwoqTvjraeEZJP1q$pdM9RgOZ*Zy
zh!Z-;v-o8Gf4%Qhs?#R3LfI&lbuLCbJsp^&U*kt)$1PFXzal-Rkt8*D-n8ybrPdMJ
zJ2tgWDSP05djY7x8s3t?WU?ivc_KIe`K1~2blk!JL)TYFRn>L<?sMSK-QCjNp_C%s
zh;%p79l`+t3F+?cmKG$Gl#~>uQ97kVxEr76z2AGs`0g0|!GV3&UVGM>@tbqYwT9i=
zfhD=G0p7=c_&}Ir>OsP@v<f$31hv<Z&j1tO#IboIQur;<n=hdSbSmB*%s0rwx?Md_
z=HD4M_{z%kJ~=0r<@#EsELQx&DPRjtlzemQQHYJO-v%z!c~VQc-}w@?z2x`>{#M2Y
za5^ABTRRh6l=KDu^H|Oh&vt}H8J?13t7=BM^G^{EEU*ecJ7GLV&mV~5)JFsfT{*0H
z!u0Z*g6vS5e!Z1>Dskqt*RIZ+C$0Ut5`^DuPxD*}`kY>O*ity^nM<ites>C|k;14<
z--GcbgA0f6J>{%SZGwD3&SMc+ikw?HR;a?9$kZBukEV1dnaD6wr}Qf)C}13s(E=To
zSyY=aY?Ai=;+$5oDiEVWLt4U<kOv&ae6@&SD_*^vr(jvps9|<tU^p`MkPe_P)+Wc>
zsDpwBq^(i8C@qbhHU+x=f3amJk`Osiq$ie>-0$15{C2Y01nN>`2~s6~e^Dk0q=O*@
zcZTIoK6@GMk>CowmSo>Ja5gUY9k_}NarItg?0C{i1<RB8Y9Uyv8nv7WaJ#qD7bP7R
zXZ(jfG{&Hv0Bb11K)@Cl78Mqv-(SX#Oq+L$tLEq_PLM(Bs@WACh+W46h^vX<d`%~@
zIPl_E9oEoYm<rzj;b?@BiK>Rb3idXPGn}t9_EM1}<#8l?`J*8lTN;NueWi#QjS!T&
zp*3a?kRh}~W*_N+!R6E5uiX$>1-|l7s=9Hcr6TbT&twCW8d*zm-`7Wk4nrcjOQn0H
zZ0XOFo+7>HWIJg6Y}O-B$yU5kfQQ_T4cdcg93c+hpc&*mR)KkiAfAAC5FF(OcnDVz
zOHZWIiBO5B5j`S2h(Gc3FuIZLcixEax?FW=@Ulm=<<pN4)D-&b58Npz^PZ&UnC$bg
zyi5|tGq|c!8FVU|wnJF_gHj9K^{Gt9c=A(#%`KXp^Drd!zf^=Pxt~c$*Y6v)+mz&o
zpkLKx_H`ffXgKR-IZ;h}Tl);EFZBN@`u?LOlSU@Zbmg>_0-NvWxn>K2-gcRQcjk9~
znn2hqVu43G2Ahfko192_*8~xO<ijBtg(2wz+)hvjpgmS#KsX8zPsgB5TWo(0U~FKe
zmbG|@5m%GJV*^M=`s0(B9P03{sP%#vPLvZU#~eUpC#wi|dP$<Ac$jRn8K52dq0NI}
z9luS&5BDsUf`$Ai5*6hy%C?Yqg}*$RuBv`GaCJt1Zi}o+@WqfcR(5o0`OOnJOs8NS
zr$imC7h$h)a8x(7!7hb=VAeh=D4?I2eZvVcv33+sr;SPvat4s>?H(CB1O)tfjAn(I
zg5wv@@}uCJr>wo3_Mu+e+gHy4EX+Q~Qo`Rl`0O$91Ex%|a#KDL5iEwW>+#hXa0kmX
zO9q$~o(Svnw02U=4HdaUSw~HT*c-}ugh_`Rj1Qs|6#XLa+b<oD>;lwSZ4C^{Se{=*
z=@*B-haA^?E52BYAZlQ=018&wZ}%u9(Z_z+<axOe1y08c&?sBHrW=tI2J{!bZ$_h{
z&VFf3Xs_y5anJ(<KMn?KsB?_jE^yK?Ac&DNK<5wRdq-wKU73S%l%RqphGtY?y8De~
zxk^7jv`=NSZ8fuwV}jtaneweM!izQY;}p-JIL5yIejFg>wD8J*mPlu+N6<*0YXlto
z;1@+aQARoN=A@a_k!uYEZ2w?%*~w^o3xaL(NR)$aJ_`mt?XVK+r@Sg@e<v`Y{+#fF
z-dHyDhq#i&$-EV^oF5gENuaimPg}c!Kl{=o$y82sa#J2`WpRO5Xz6w>n$^pZ{D-%F
zGB-j#Fw$cF9ZPM_{id#txaZ+cyH)9a4`uX7T_6VSxR$|s6IY2EgnvSu#oGhp)!rq>
zgqefQr)>#x215gD90i6nKg)|!MM}X|<2rFHuan0Wzkn)R%!;hp#q2lpe8Nx+R$Q?1
zO@*NLDzmD=>VWnkkHM&EB98Gq5n4D#-zH_IBl1Z3$o(bY3}q_wN=9+!xj@SBBBn2|
z;vne-eo;rY=peN57w)r5AqY3_wzvW3YpoML^l1W-8&N#KiFJ|bO%gZ4D)YX_b?~F=
zo<84}-aQ#RIr6-3g0W<C{ZA18)>!(yPR<2?_xc6R07*NFcA~5x&%Y+zW=Kt<8)W(w
zt<~J}1V+7BQfTq!vZwo&WjwiW1!Q?#%au@es2j|D3xnu9AVT8lgb;A+E%fILwqVTy
z#Q1)=-_b{!mC+lu|9LPg&6T&c<qNtDi_H4xzxiQCvJc289mJJF4X22PR+lR;4=}RJ
zWGI7zh|va{bbx<JZ?cSjWS#DdMbC53m)aptFZqY-+?ULn&m+8OPYB=I_u{B=m5P+%
z>aiAnX?SM+-b~jN{|d_?f*hS7?QHX=v_-SLD7{i7a`2CRf012ROm=UP)J;@Krv5YL
zyCT{>s#t}9T%*`q&9FI>En4<=vy_X%oh4)<4J|`z!qLqg=`io{x*?h|N7OPb2iO7M
z*W$(0X3-&|Uh!1@2r&9amNc8j9W8j{ZNN?rSGLdtY&ih$rM2`|EQTBa;RWvXA__~&
zmj(+NpfS!TLG#we0g|?I;pgNa|2yo5d~1U8PWE=1K07u1uC0yp^W}np<Q)q&7Eryu
z3RXvm^rq|F*!SE#XrFdiGXt3;i91iHZtt+^_B$JA<j<wg<+D`4+9eVE^u2>_Pe~!9
z?sYO-7+bN_VS+Iy@U%12fJ(}}_5?`REN4zwp4x%PwPfoM1biJhdsG>SMd=px3jt?0
zMe6gxTLj5OEI@f(kx)QS8mBD|5&qP|=p>wJKu@7m6ofZqlEOuj7Yg2p(988xDMBv{
zOln78e}Wf0`TB|ytszS7%Gnw4)zHHmg#|EFai6~IWu=?D|HWWiLnEF)SeeFe@~fy$
z(SmazX$!54lCAa4=U*HV&)?{N;~WGm#0f6*2&S+gl2-m^3U=|B+#%La!--+J%wgBz
z-U(np9KonbM@#P2jiVVniQceiM^_*HMLqOJ_6WM8m53oZZH5ujYM58^tQ_oP{GF}l
zhR$!+l!g(;W7?MQ@gwgWd*6&j+LX6<S&fd7C|~xdvABU_w5g2(ZBvXyGee9PW4w<M
zz4p6v^CIuKBg!ehQ1bk`Q8#_eyYz}O+D#oBnED0gt%#V<Guo@eGI33AU<ffbBN5^L
z&dhHCIrQ3fu%Rm@C1ZVD@pr?R%ketuJSZP-TOE?q+s`<2_qgcVyAWCNM|@6SqDj04
zVYuL!lK_o_cQmBz`%f3#f;t(QXx);wc(CQo=~q2*-?t#}W~{BPJsvbKJ5^<BvtYyc
zLx`F&cpEUw$kH}_$fb~|OcvhH@mw}Gr08eEon&f}Yt_oHEJ2m^nLnRArGU5_wKz>p
z!F>{*G?#j)`{dIp*pQzlXT#Ael;eUGPudaLUZ>W=W;}W<5;8!v<1W}j{#V(jKlI*^
zvkH00N}Q>|u4fQew&+ix=(3KtgB>a<oc{648ks3&3xeSank5LBVIt9Q-@5iiPPzbg
z(VWk!{V<+vy>BC1<WYHEkro%r0;D5HeB%+*++wfuKQS}C2FaMenbsVfc7v2{kdpIw
zNAr!O{8=+CPuz1R-xn%aXp)^z4&F$=79l`L8g7L;eHmXJcof5AVf=l^0<chF%D<zF
zycyOx%aW+pxIcvejue2aHR4b=S1(UtuMs|=Asf+XC$g>nLxy<?Yw(+7$%cilYLra#
zrwQYD<~B%l`t8%7`2ImCiAbImr-Y>FX-tS3V%kTpW2^7Jn5Ts72aCcundy*=(>&ji
zggMuj*)RIAX*j4Hzml}$LNg`)<o`?>kfY!Rs~dDvLaLP%FIoc;x$=wzSb$SCpu*lF
z;Wk&zR}c{{zduMmo?Tq=bWu=_M-EN6e0{a2Mn_!~OS1ZFXqm_F<17fW?eP;_R>o6v
z5m<~9v6z)LS}5gmhmH=QMj&uXF{O>;woH@TkSP1wPK}wxi5vsToLW`3FK9#S%a3(&
z=IPX@eD{Gy<=(R*oGy=PZzi=%k>IO$bPIn%iRLQ;uR+U+1+?)&?MF;<(DwwyQPT?%
zb3bZ~a|Q=K(lBwQJ4^d3O%a?rag_vyCE{x(;yz&tUvDkbI<xX{qLk-YC@3=U5~to;
zyu=j%46$6*B5mKY!o>B9ma_UAq>c-4JZbmVW}$JSCLz!XdlGe#3Y2>kZOM;zXv#q|
z0&Bb(qgIJSwXhXA16KvHNgYRKM{)*JdiNf!uY1aEW-{AafYv)uU=m|lils-`rwI%H
z=pe16Ak~|DmQ0KujYfsS02%eC<MnZ91qb9m-Ol5$r*YKQoXGK9g1LJEW-NZ$^x2e~
zygD}xOvzOGM!FaOkrND<Nq84Z?zQ#~Yn`6PscNnV;>xG)#qyvlS$NJ0BaFuDd0rR8
zg+ZLE$_8^j+E3|yI(Oh(<3BRejGTTm;)N3n3{011^k#h7en|$PA-@&2(j0Lho(6h#
zkvynzODI`^3VKP!_Wuw<6tKV|iDW~ntigTr@>dz%Expcz25lqO6NI)JB9@_}eV=4F
z41sdTdI*$p2obbkz9-|cVwx#l%6!lrTL_6+bUxIiH}Y!$dw|x^LLmiIL^BOnA<@bd
zBB?$-;l^GRTz2zZ4v_>Ej*=}DOLI1q52d+~%I}*X$rConCoC5Qh2R6Awf~3!ZWoA)
z)or&cY$yL!;`J;HUih+hP1ab}+H2s+bq_q6Ri9mMQ`WMUHRXF7msjI5X%cajuaJMd
ziV!QUTd_*g&oTB{D%-^nfPsi|fi&nYVZlvGTqM1)_g5>fd#U0$r!*1`Inp}Mx!ih$
zvYd|TR_!2^>UUsa(jFQj=-2LOo%9PRFUzaSYG~NfO#FNSQ4<#anLfhs4X1{V0>1DT
zN}Xi(oi?lo=fr&7(zZq(FH_S`u&jf*)lg0tXhT5q>N6p>vh2>xDfQICjUlgs=g(-p
zPDhwHyzos(?4WfdHr)GE`GXFmX5Ekt?<2vX6mq>5OthuPENXUXQ~$_TBY;uguLj{Z
z>$8z4c`15AgZrRfeA1?wshe#~x#f{$zA+JKb_MrN+!vU?L35r0(1=nC)rgB|0YAl=
z8%f?n&}zHmBDE+}g{cgIigQruvsK)0WrTsL-8ikdg23x4Y+(3(=^)=Pn&=^-I>tSr
z-rzU9g(*lYe-5tZjR*+gUdU<^^R($RZBrjxNi|LEzV>9{ZgCAgA^w?|NUA2MOw=$_
zXirQF3?fPUy{idnJ?R~bI)m=k4ql65Afgly>v&Z7RFxo79OlWt73m>V6VtA9JuiaA
zX*m!!qeK58<g4$r*ddG`yK@Nm#x{F5rgJb^xzN6%Qy@tmz<UgNbP&HMSkO@}vC0x|
zP}8E3XehH37p#6UX^ab+&})g}7hJOXF3jL54G3oozx_;XE~(>avnaTQw(X)F>!Z>j
z<k9koEt?2VJ4<2*Jg*?xA0U7)ya_o>(@$2TCusKZ0nNDvD4a?)G#?BPI)fyvnZ`AM
zXPfwp$%#*4lB;h%JzqybNcu_90DGDDV5b89ifHIwqHy-sRTStXBut`%K)QhIDpj<-
zR}NrKPP(p2!P@^|62Q<^!-kt-g8<nbq35?3;ZC7`lK5$s<t(GIx3HS%A873<unv5o
z2&p(c{OY;a`}QA145<lxI{dG#%Horu&J}w(d|<%ScJjV{08_~5TlR~|s1UC3*@(}w
zjK3<Y=`{InLI#kH<AA#5bYp?2;PX{8(KeP`v;LQ6!(O5d@3RlT1W<uJ0|BKU{fU6q
zX0%p3{KPS*6>|5vMmTXZgWi~Uj@9#gJDv4qt#RaE$4Dl-m|B8_&~Eh`?u6Ik+~Clo
zoh}K9cOhJI;rMUc$vHNtbR|DJ&ac1W3WxlQikH%NkytF`Cev+HuGih`)BXvw_m6tf
zRF4SOX-@}%t&X>jD@yM9_3EB)^_}sEaZEddAl@_87R_TpYD`!i-6qP)5*22nVV~AN
zi%0@nj8h6?Ou>SV@eC#D+Q4879hY!@;}rMbx*rTEm5b#X3o6YTV>y1|z(blvHzLHn
zd}&bVuQ3fSmis#2(<IM|XIC+LK-NV9lGlL9rSGqwGp;}qZ378!DKK*Gei*hQxr^Pc
zjn^Dcp(7u3vriEE$)BD9%6@?Dw&r}X$;oMT(cVNwjR7YdsnS5o3qXAM;@{#k6%?qY
zqJ4AksCCK)WQ`usAea(`Tp=N?yqbmcFLR*Qgo?Vji`lzu?Cw2(KkydXt?FzdvP_)>
zVSe}g6HxZ=B1nL3`{JjWgG!xOH12%lDEkB(<VD1np7Zf*M(OtXDijUPrb>c8*cn+a
zF4cf=B!%}`lo!b?*==9tNC2=k>j6pjSuUuJ==tFyxncOc!r%Eg7~Leq4@3fs<*Bf4
zoYc@4_Mr%h$?_$Gi^uWrXr-qezuxe_dT#XbD(a)mu}h50VsLF9luqfc-B%Xv551lO
zttL<Hj4Ifv)=ver&u*o>M~0d(n1?qZm}Hp00@0loj|l7dfe89gkK0vf97Rv{W8q^6
zJWn<iDuDw~qYw{6@+1n37b6?Ni<GcQ;m?#G>i;&604)(}Ej>Jpu*b*pdT(ZIOf|KW
zGn5$3S+bdm9;Lg~G%|k`O}Cc+gwAdK7H1dB6gmI5&NtT}r9W$_m_(1|H%|GHBu(+8
zFln|T7slVD7ITU8d`aWWc2wYe&wQe%u^GiZj<Ra9D$l3;L90@6qXD8qu@h?gN;Rfy
zB00-BMTLk1jm=-EwX%OwkY=Iw>AGCLOP^Y%LWnkk1Rb4`K_?<#AIiL#afo;FJ9Iv*
z1M8qzc!uWIEu02K`Kb}ZSvTK+RS1gS!7$2&t~Xpl1VZHylUrd?dqy`Z`qjO>rUR7v
zH)_UL3u30<y`?ggCNFjChL*P?N5H+x@Sf!l*=jE|1UTiO!F#Xh7Wkxvb+Qi8Pt!WS
z?34{yh>HaiaJ~DVN>J#1=+R=Fp+HI<WHo?NPZq6-E$qW9+8LUd@NmMH>^;gnjeeU^
zYPEQd{j|62g9beARw|3sAQll32%9VWa+UYD2V*eT`5vn|O(-AP7D!WJep;g$8O~Ek
zV77>BhMeE_JKam#F!DC}6<!cz@TElnRR?=QTS8*^L9roBa`K4!fV78_eapT<R+o~g
zXMek&D4G}vH3iLp0ABG4ZyB>#(-f5)=U^`1qe!jYtZbM_19L4r<mjFi3Lhb$Uyy-v
zNq<ymC4GC;fCEhbfk=Tukf#z|k%g_MqT$+;bsLe^kz{ln!fXn>dnqOsqT?0vO)A9b
z$`Ey$J8b>AU_>`SAV@ep{xr$5*VJ_ar-lv77E(6SSCP|{Af}VIz2jb4ud16}!K=O=
zDzzXUrC<rHeX4jZq+|CX1QX?AcS2O7%#df=61^PB<r#mMbsr%MH(%exK{l84{J^Mi
zNNy;Qw}g!nsSLy@0Nq;;!uhW6Y9_a=Hupq3;*@3!8uy=F;S@L8)cAj3K*-~cpaBR1
z$iQ74ryp(ntlyU>ASSba>pYHfV6rOEf{fGjZKYB(7nfJI?F%|R1>9~Kljx4q6yeh!
z(Z(18yO$Uq^U@Vk6u9%)T%5*G9!-qVr9L`d;E`MGUn<=|q<B!fA^HtU-MadP8|_QC
z6IbO6*&oE_($Rcz?%TamKbEw7+^%7|iJ|dY?{`4Z%EG>lU~CY1t><6xA>i>V(Zyb*
zpG{$)k0NGMEhfE36F#;f2T67P6^r8)=AGi~nuua2{z>~UuQgJ87oGmNq@id>ne9t@
z68~j4n9lCV6t_I1QXFq&s#jSL#YK{w_j-fiY)g)_F^9V*m+#r9mda5?){AR;ycYQ=
zjU~+U&<woD>Ol@sfN~L;LF~aNat~c?0h!9QCi=Q5*Uhbd=j}F400!QMM^`%{K)8%M
z{k6*!Qsye|yvjBj%O!Ldfd*%}gbK~%=QBjDOOpU?4Nt>8WXmWK=QVwUn+6z+ugX|@
z)<nkv=390-E^ht!8=t!Oe^bS??>6Nl$G7X5XR$22R&#I{dZbyuC%B1!RMj=KljqeN
z4EfB;_3j8o?b6X107rDu-Tn_NDnV8(v9s0a1gD$XTyPSZ0za)N7RG~txHPbA17|6g
zjQa*%F=Hw=s#S({n;baH*V=vk(PlFPWIY<OH$sDF(!;ce)LHUvd8?p}ynPAA$TsT;
z&h;a+BkL0Sg(v`WR?iaN?Sg_;$v7pI=l85fHhI0aEx+gvu2&r&T4qxAr9{c>8^TfL
zWM!-!1DIem`p&}3DY?11$;r#KfN;E@&}9u@yBS*k?<Ln8rN4<$OreDDktYt#WLi@t
zXSnIko2iRNPL*+l2+ses{fsq(X_8w}NpdQ<ln|0J+Xt!3IaZ<1$zBD0Rj8zJ8{sIW
zAc?4hwcfP$fYxly9;0^EZ*NfW<6^2HwsgDrc1WLyilmH_@gQRx*oE3HhXGCpkN@`E
zEXNy4JdTA3tKmIcJfMN(eV4VE6%V&^wVs=2MPz&m1ex(bi#WFklLF<2^fqzrUV(jD
z7$78R5@si=gO;_-wI>yVit@Uqnz;8fdKidlX(g8RY&o{S{iGbM`u$$&m5c(>#0=Rp
zpzIuXo0*Bl{X$ef^dax5gGi$oAb*qlTRHOyIl8Q$9Z*0%_8v`1?BE(~+L`3olj_AN
zJ8f%<#EIo9gXM8_U5Z~PC}l@hTt44owV0gANO-SD4a@sZRm4Nx#kp&+>7xO{(A1Qm
zarofG5t@hgPcA!61n$i0ZbJ!__Tc$9pQxT8bZ2PWArBB?L;!CNo>M{A_U!@T#9`4K
z9$@C2CR^Z({X^r7o<1;%WkPsAA<3!iE~d!sOr+M8jZnw9I~f1|$EUN;hMmq`pGgVM
zX?XuhRuVAg=s158l!&$4c>_Y3Z1P+21==^?e3ad20u0=(s6{kZ=>e@DZ~Xs4j~4y{
zh|T~SMw80xDA)4?quFP5B3(s4Mgd_Rs%Je|EzW<Oy;@^iT}}t~r7VEi*$vURwu1x2
z7%7&7Tv*nWZ9R~1s}tm4hcSam^ePC5FB+?5TPpt8ZATp+@q2H<_n`$BT8$2T-QpGe
zVb}>La#3h^jsM0)V&Gbz(M)sw-s1AB7TG^3VNThezs*0+f4=;&jGo%2@_v?#1UgXw
zoQLg!7U0?Q;F3IG;{Rq{|I_mR;AcH}0N}X5EI?R5;o(AXToq^_{GMzqQ$1Xd7%2jr
zr(!0wp-lq@T&g$~zJ^oSp3CG9yL+Mq9I&vV!&ZEKf*)E=C;Fi#^H5ueff~el@v+q3
zHmElc=X1j<T|!`P6dy5~h`OBB>De^$Go==?SHq*#`BNW9h%a2xasKHFMS4JGc_6K5
ziOeRU!FM-MUB{Qmvp5)|=CH@3s)gFE-?V^GwgbUl4#~KGY6nNAFaFX%1{jNpi6roL
zch|P4-u;C-soSq%vV-ucWo+YZKg*GgLp1}t<kq>h!{4SVJqcj;a9*>2>2SWGH0Pt9
zglgP|9#%KnqaG_PS`xU*P1P%ibjxAGsCd;so*RnfyN|r07{2bRUQdRD=f2{H1aJV~
zi0~jPjVbJC=s15#$P5rvu$lt~vZ`^Y4=}Y{FuDHm>k}@_C2-De(+ep_2=syA*o1JM
z(?X9^#2%l<hBH>=yl>ZLZsLb9tP~-)e^+7vrsqER1wYpKH%(pkTwSpJAcy9m8}2j?
zgiwNXdf?jw6F|=Xi`;w|e*nNAuK9<LfBF5N=?FHOP@E#X2Y#{u!71eUcjRDCA3qeR
zQPlwZ^aT%PF#hr${Py2Pga7uHJ$NnPlHfuQOM;sLf19TKU*Vqrb4%wxKldx#+gRH>
zj)vi2&$13e9`fhfTI{FJC;?<RHXstt4dC<=mt-&OF=^zN|IqZRda9#ZB|-Z>N||hf
z?x>ax{&=5x;@9tPA53U91e~6(s|Y;`UjAX(QAYuIw&7H~($xoi4Gk~rhX`ph-?D>~
z3;=P1fBT0aH1X!qkZ&(YX-F8BVXW;dy-45_U{k|kDA7f|Ov}=L@kC(^=CO|;x@_o5
z@<O;7!x0VqtHK<xV)Au7ec!bqR!%KLJ(EbM?#nL?X_#RplIkED%O_!%J%T=29#t99
zHo|4w{IYF2X^VHn2Xh^ziGjYE=EsvzBTdC&ics3DZ#3W}2yjsA4$rbwVhJZdkr1hK
zG>la<&Z4dno3`>-%(O*DVuC?}P;nH1<Isbfhasu?fCws_)R6yD{v6X5GW6zg7?u0H
zLG%F$Q#_lco*j>$y6qSDJShl`e%#QEY{Ao1dpwD=wtt_F5YE`}HT+~^hyS*uG030a
zDv9A~s$9_e4<}NW4BvS$OXGf<^)1vuMx*;HJOl5ov(6^ZTKHG^=gv#2p>TSIq-i?R
z5KW?Z02HZ*xd2X+1Uf(*2Z*-#D{0-b$<Xm%#d!rGjg5^zMfo){Qp}DN!)PJh9GLrz
zx5)xffMb8qNnWKUDb8ISck?46Jjwk&(9enZ$Ftdkt1p7646F1B#cl6epQ{@Z#2Ztx
zILfSj3P=A8^MDW^EG0mO6f&DpK2DKi*uH}?UOK>n$We9m6`sk8DHL^^l6wq<3!ToZ
z{IqQ|<;8#WbU@z)$&x|_=S3F1p9<k_)eGvFY^TA}q5I8V&llBtFF0QY_~bOD7!p#h
zBb79L7}k9CUJ`EC6HK6kPorT1&~!W4AoyDi{_!?Qzx8m+eQdxi*;cjtqK&L>8|trZ
z#NOTR;VXGjdF&AcSkf!HV=s)68nZyNrVz`BwC2>rqzE7d_+atMdG~G4$j2I<#N+pz
zB6B=<^vU1%I{<sDpKOF8ZP%_U2fLbxHjY{8q(WE;J&`_;JE^Iy-)?O^roU(Tjc6n%
zZF1DV1G_Q9ZEj&K8a|05Y1p_;c^`Z%Nk;bj`!h93kPy1V(6~S8YD9%hjC$FR<7H7p
zwUDG=W)<#`R<W>2@CjH72A-8E5w8firKf**wPWEkA1@^XH~dAYI=C2#ut|0TO~QUc
zZ_#l(XFc-*Sqx2c#h>fwGTznAc%Hz=Bd_9MiMD`f+5{ngS;LAO2W*@nG)WA#H)aS?
z;p|X@OU-c-tz5?s=ivOV@ZaHkX-yq^b2b+<d_)b9vvjL3F$ois(^3;`z_!AGIhowH
zf(pl@hL;1Wv_Ksy^wqi<b}LEr)ukZ$p?su^kP~*m)`n<b<FeIRFTR0h;>`Sg&zOrr
zqVV@u^~jS2b5twUo!B@uKwc8;GAJxizBBaEM`>&FIXcn(_$9%3)Vg%+=l)+2tsQo&
zh<gza-^CW~o!hiLqVW|`ZAsk?10X;#dp9$&Lh<7i0z#8#1p8R(b!R0qjNR&EnW{k_
z4Kx}*e0#<EB-b#V>MFrkmPQirECez|LOox=gK5suvt%NGn>G_8ILAU6U3Z?wx8Fgi
z&PicW4?A*)rTu*JhoF*=OH%z^ds{pVH;+@YIIiq@$isgdVc+`C-oGW{EJ5d<pmb_3
z(jhm&UnV+h-h|oI9#YAppO4AWpn!(I6M%eo_~M-!uvCi5O)1<bR;a)vSJ7u>`ms9$
zXm@BvUMrjHZ@-}h4QA>D5(>CO8c7r!D0py(0N@|N56+kiHH>pHD@q6e^mY=)jCr=u
zHpRz4I7WUz6ZrV5Na)c`Y}ftK=jHqO#Un4j@<s5v5J2b|JW*m<{nCe5qVjXz<LPYm
z&Sg90+gNJ;yMfydiI}7pIm3aYJ<5FQUDj->)P^a3%?f>rdsO8&8B3#-*BR5R_C*jx
z6iCfecp`sCMpBVHnU!)tCs2NWcb7ZA7kKxv8_xIZ<x;1pNNWc$>TWmDQC3q>iOtpI
zR{_Mn+yoy4A`Rcg%{x8GhMdiJXXh4=6PldArM}A-zZt-m*wQ`X(^OCIgDxnRiV~sM
z9qoKpJjkKz&iu8G2t~x9L2RXhU@dNc%{^Zr)su#+R(w$zi|<c#|IiUNy}(aid9Aae
zZOKbwv|D+%{<bS|uj|jz=Pt{kWe>sx35<)R;n1I)ht-dTO3{uw=PjtTYcU^tbgOlG
zIi<TeMH@+fw5W4ETP5_0MXgh9+oB*`xg2`$S|RlsIhV2`FX&f~UkC>IhTP~P*%bGk
z2>_CTlcPK5YQ&Y7D-#MKtNGcFEx7T=tcEP9c0XDE1#)`%rGWA9a^R)vZzpgh_wrz%
z2Juc?RsC;X%&AVn@uAJJa%fd%x|oj~xRI*fH$u9IWvOahr2a<-tnIR}OV7jDXM`pL
zi)}5x#!o4el*b2u9D_u)l(=JE$41&?gakvi30Jbku6i{-xLI!#Tsr>h0Ls1kF*Sd7
zzvgZ5y7SA^U8+0}s(U3Y;di?sHDwZGNags2VVC8o&eY(eptBL&7j-|OClqSndLftH
zqb6R18h1RC+yM-8HW;OL0OPB*usvSL+GUkH_bfxVHB#|m%Fa_<lhkrKxMo<3Mcu91
z+3WWiK>5!R<5=<$0Re#*ZXW<^j5j+?K_}2@MVnL%qc=cTPGx0=229wieN4&C<TWz#
zu961~cO_OB1jdKcpV_h;CB+XpEEE}SL@zvk#!1aZv~q0LP)BfiZ*_MgD}`3|Gsk*t
z==%$}2Ly}N^UutsD3|2N5K%v6aBkTQWtKXkjkwU$7~`=+8p6Go1M+8vC^(XEVU5Z$
z-4Cg&k=y6ghR79`tlgYGt-m>aw0LBxNH<~LKWunkERD^55|v7W+)JT}N|D8Okaaov
z%TSko>$a1Dr@HaU#@Npxr9@Nm@$V;FpW=Og^k#nM?aQSnv*=lsl{tmbq-`WCEQ47u
zwSzca`?Wdap|<D$+?j1$*Eb8bEwH<_#U&=Lv%)_4*PI1i?hn7MW)ZILV}%U3IN!$U
znj+Slbo&MSiU8>s9>tUHTk&*~>x&2_iEh_TLFCbX$O9J{F}sf%=f8k=U3g+iVNz4W
zC`;e{<Auj28P~3uuvbmR&Lu{=sD|kQuN2(m0`=$~0;{U&eI!d(+fV(=DPn{(xu#)S
za%(2t+>q9djp(Xs5y9jF?dZwi$?|>IjQOWxar<W03oLEz*M{B#r^VuEEwui#2Vkpf
z@WuUZU=3$C=5;f~pA-t<tYEDN-gVi~!2sw&lYAWpXu==|vkaGkQlN(kZx=+ol>qG@
z&Wa-@Br4Ga1qW_O-z~HdO-KG%)YCVJnsY&4gS>jVJa)Nh!}^Kk*`FHm`_cCuF0IQ^
zLeA#xH&S=Rh$!7P1SY$%VdGHo#@o9)H`ry2$YH=P6MEel@rQV!AGY21*MSeFIv-f3
z^z&U+?>TiESPE<gmbo_<FWn)$JIk+}@W7qbp~EguPj6(2(^_gK+s##Tb2e0rz3)#y
z+2)9y-;G`j#HeQ*+I+MVQEvF*RXw1x?0wOB*8gI-{zeIY5kuKtilWAGc}n87_pRP{
zxL*4OTvc_>xVYyRS843)i?C{g`#;8qf$c}b^_7Qh#+*7jgBLfU=EMPh_jfn5Ld31+
zrGayiowfJB8ZD#fKi%I^K@lH#)9!b8Z>C$zDEY|Q59}ge-o5v|KTY*=+Nwg=QjTSJ
zT3T*Bn!6zvT56e{(zwmMs7@(83@q)^g6l~=+L}W6wA?!D<;1z*dp}$+q*Znp==Azz
zE_C*yYsT37dgIB(qVZvkQ64URbr9!W=h+`)3c?)iB+h`7gZ9G~4pi_+{Pqay|CtO(
zzl}tcbGT?vLDLjrVL7TdUOMh#)2&Iko(xy>xxK4!e5_Ze1An!aI(5jkVA+?Pl^T}O
z#q$-sl>9)s63=&O99@m;3(>u+ZxqNiCXj26_$HMyLwfhDcq=!urCk29k4_K1b{Pnl
zpdNktBeW2D*m-ksYFq&Xs>8h3jnj@BgQH-V)4%T;!FOsM<^}`?G0xcy)9@`AFC4y%
z{VLPwX$bdKZ@y$fL;=1TKsv?FGbcxnw_fjFz@U%M+XWy0s=Y!v+V-msY?XsAFPimF
zp@E*sAN0d6zakI8E_J%@4j^#z?!(1v@h@74WrT}F5IPu=$sFoYdB)aKyJYCe+>Nof
zGJIKoXP4{tyz}njA~3te_vWDXqPleXW0wr|^5bk`nwg)P8AO`vwy^T4J2UxXV>|Md
zsS76+i2}l@(5{Y~7`@Y*4nTkT&)MR|p>F$Xd#M8Xidc-eolMJ9IQaG&kmG9Tb4zM(
z7KJ@m?_i(|b@>fQ3ud6+4@YaWcDEDn=$7ua&rYSFca5MQ?OY7#Y;spWWxI??@w;g3
zG(!CuAolv=lxMc=Vi-2O-F_G-@n`Yk1b3wnooH4H9L;DxP{Rr>Y7<@z(Q_w93^(Nl
z_WA|8AGN7I{pj^lU*ahP`?=pm&8axX+n@<hL4Ai!cDJV#V)tsU6eRr#umBVs<1moV
zZv-qYnU-s|^SsgyY7O$%R&L$WZ%^*;!tn_~mlipYa<;Yn_m{T!nHFQGyTOIteH}-6
z%R&oA1q*mkeq+X;5_j9VcUQSC1rO$6h3nZz2a}i8N%Fa-`QPk_P~+;NEXcHpO~&M4
z%=f$ZXR7y@;OH*UmfR`a-h_Q{*^o-?F}Yk%pVxAUj^jf<ND<JUAoVc*D)tm}NFXIO
zJByAo?8dH*?}b<8m(DTV!gSJ8((=oSF&!6G!WH@TMP9v)^T69(iTi=BwPz2OX^AS1
zkSmhkYr=%zE3R!gti9GvcFaAi2zQm#eEwx@nDSer*sg<JG4oc{PTr+MF_V;XV8+!N
zW8=n{yk-P_!n<ZY?wA9A#qhIo+wjrZE~)h0S>XEPze^)}Cl8x#9L$ko&Xo;JPoGz>
z@`{*m6^?&NBDlm}pR-tSj+r<*<RHw`J9uXrHfV_q9x`#E35KV~P1|hg9VH;7jHa71
z4yX+<JvB8?W^JB}o*@i--shA7ShHgPlFOAct&@WnRaD8rDZ{YHP|0j5Ku57I8+u!t
z2TzV&oaWB&F=(P|p6jnQ!X!dft6?K&;)lM{`a*=tZC#-qNq|~cF>di3RLEvD9QxNU
zACt(q?YtYPomQ)V!kgtYY1#V3gM+KP;=Sy;1Q}W2pECgw+m5TB1Bq=|)f5~PmwQF4
zR3c`3JECgL-EZ7|=n1Vn1}I`4gX~fvUb$>To9ej<`5BW|u^eFe-LG1Y*hJRnDmIz@
z?1{W<DqA%20V^&In@~W%^ZTmbB}z*f${U&ebdZGS*WUA5S8V@T$2y-34*(#X49RzU
zFV=NYaDX~fXx7=h!_xHH89f%SeQebS(%iu9E1pWD(q4|Eh`oc0OD+80!<GGuVAq~x
z89!(hm{B3V9pzg3TtnZ9(Ybf^7ZHb1xo3FW)!>fG5iV~DekB)6F<m`_sLWXlUC4n4
z)E*tgs!jbDkF7p?cT-&!usMXT7K{3N|7YRHM2`CNfC@lflK}LsU&R5ImAQRdf1q5#
z;fZK0eU^jniGACJ(8m7oX)9u=E}qx(DsepAy-`~_T{`HYJbr@nw2)sJ5M`|xAcdkJ
z&#5@qG~ZFsd-j1N$Nl@zd?>?@BFSWO*FpCu_9@S@&T0ZgDKT_>r<6TqLteZ*RKf9@
zuy^T2-S#*-U>I_q&iTkG{T%^3M@<SfRst$#RHLWI!FL|O-%sU^dcS~NFOssXFu6qC
zShCtF#+N-!5Ux#gaHO5`uTAp2915P_r`k3T=7u0G*s^f$fS^PcTv~wxe$~YdtclT>
z!H8>NRNdG0^BaN*q{R(*8g@yol!Y4KIOTharZ+N(O}fY*D3AomGfs}25urMVAO$I)
zy^5Ci561aQ*C!H>7Ih|xLa(5rNZissP2k<Lr$Q@LD8?!oA+MqswC!&QryD=`$k8a5
zz+dlyU?f0yD%fzaTB0TnQ)#H4{BRlT17Xa1c(l)ma<=%9h$NQdFCZl%Ed%Sgye?K8
zJ@UC?Ymr|Ips0;yPy_B8bDcUbK1jd{IFoGh>I1-+_3Y66_7xn-4Z3T6;2y7ruUXi&
zMEcUY97<q|Ne_vQ8s31Bc!9izN8{xceNn;9QzSIWO5LEBoY9K}GJWOqzY^H0Lpal_
zlT<ju0dz#><B_QM6GqgJPH;^U-|v~XS93#)pznU*yG9OMc|e|bJnZ!iB8l5+U^x2z
zv@W$q?_qK=kUI4J!%&sJ0|JKNc!HJ|;ff-yycK<dV<T=r(!1|}qO3~gGVZ&-1Ah*5
z+XA(6xm>;`6_qJ(4?5Jg$X9Liu*B90N-}oqggv8Vup@soRL0ru`5`ZJ_L2LfVF47Q
z(Wx3hZcf|(&Iq(?z+G!UuWcnoOk3Ed^fLA=It|5zOdagc`35}=?kh))dzMW^d&)z-
z<AE;Cy=N>ewn4OorUajd8*am9BU^N{6jT<<5nUSm$^9n$g3<x8>|inZpN>t$I)r&!
zo__Rax(UuGqx{gMSLGZ;E3=*U2tS&X0Z(hRj<XRxiTgrl+;K)~7EnV`oHe&^Z+k|9
z)E@#+FeGnHj3ofo5Db5$ADllhDEs@t@iEUFzn`37-$St4ZOABzO>Bj2I@X>H-MjF4
z+$o3j5WjuHg`U331`<oLs6E@TsKsZnAy5>YDtUODJ`-8Zq`!b)W&Uc|`HNbFP5bdq
zwbYz0b%S@0#nt^>-ie=QRX>r?($Z$4z~Y0byxqnI(|usJM!nkensu>+sBZg=v&Jd<
z8CGtjT*Na**B$4-5u;8N;AD`M*6EuQSNWxI#4375JomMFC3}~P;7fx&m(OvRby5uD
z<Y89SQv<>8MJWc<Pac_35J$NzPPx100|P4j+>kHUP7(MSrf0sXWlqt@)Q4Z<PFFvp
zCb&=@yajqV32B&3Jiu$1(vPhM>}<VLHAmgfh(7wsImqVBsl*kSxqfyN^*Cm7eIsLw
z$%XcX2#veSqKD#!z{I&8U?TT*<k?p?NU~uluga|TinyK)+a<t!L<mV3Dc1xEmJLO;
zf8q)AB&iq`7az_sxQ-|@GC$$9C}?ITE$-{L)9t_3AxzQ|<2P=WgBxh-m|#n{M(EQ)
zvlv<$M>3R*_h_GDKD~O_xpGl*$4f$*(XfZK0wh=fRjt!K>efBtNxMgKop4;!enb=g
z@2jvAyU*aOOeHz|&O4-(^~b+ly+=pX8Igu0bO5!pM#)2`nCi7&<=3;%HcffCx<^c@
z5yA9C9V6t<UAz+Y2npXrJAT4_zgKeKT*Pg?%0R734UT|`e_~0DHj#YnHaqA}*ZLla
z@ZPR?FIoApPA|f*U5mmBAYvHyR~BU861mwk4d2%YbkbA5S{4zo$ps;kg`rLTGK1(P
zGK}=eF$`ruU*gdWSISF$%*}-IK}s^Q?V*qV@7H<i<7KjGByyR!#)yjw?dtulLpQqo
z=V8!y7G(e*k06?r59EA7%}gSleGl>KYD!E>6x+H;^DVYMeL@2CAL`7&E~n=6BO>I}
z4L<UhW4!UXG$4)So#2+_#f)v3O681?AW!$NA7GkZ<>DeUQc_Z~II)a^Z7~_yiRb4a
z1+js7mZn(ifw3hs2lzkKuTzObS+4Qt5fYUGbq5TmgY-g)8A>rj!~^XExPSK+hvB#I
z@_)-5A)Z2F-<;moSHv{1!i3|-ga+{A9vD?%9@#y0a6mBh3a`0o81W67lfe1z!Afr*
z!57v8`g2u0uqEedQ~2J_k^Gu*39<LOI+Aqd-DgP~kk|Y8Ca<|E7*T1Z5?1T1qY&lr
zZduNspj<Ljz=57-w77UwS9!sik1wDM^5-%rrCUyBQ^s__L%Jmacu9rwQ69~I#r4V7
za&YQjRX^Z|aQ;=UZ)T7h8u|w#fdeL&z&aAGBpCZ)63}!a98G~KJSgIdB3>TKZ^{2Q
z6hvswQS&tppvx!?SELXF9xMs;Z+;7O4M3Cfuoxnclm#S5O}a`wd@vD0<0im*YtFXY
zLkZpnFfITjs_fqKgT@2sG=LzrdGb7Jo%F1|pDxGoFp{kMP*`Ad(WLWz$2Nf?_~H5-
zKsIqGDb4dYy^*4HqZ`MfBPXlxRdaY|Y=r0162<N>y)}98DUN=j#u;X56W|je&&kt^
zzh&+hzGvPn)>z4q@q4X6uuh?20(29zbldP}<f&$e*eejOFwZU2GRJzcPJ!peN|Yl4
z!A+ahSSeavF8YWpXc~bPbfP?73`R?@l8**jjryN(nR0P5uaND?Y4BH2Hg{AWjx4VC
zm~rUMI%Z~*j;i1(9!1X@$<k%wA~O@NMu0%%=kA3*VPk?pG+MdEY-DbA_=j*gTAY4N
z4)9C?i1W~}a}jkJx_|Hy_{cyu#yaTS{9Y2T{Aw5G1KFV{4_8F=LWd1r7W#WYX2F~7
zDM8j6y4ld`4ku!NSz5b>EG+2<)O46HJmXP{zj;9@yM(84URC)?#|J*WP{T*LMfY>%
ziF6pI+xz2y<Z>$m+?C4TJ@oy*6_`P$*M%V3uyS!7(!UO4Ydel?FDl$iUa&U9%`=#j
zqe{k3lts2+m7MIOlXUxKa?otc$R+u7QLE;F0e|JkN7XXoeDD=a-3fRrqvhr0Ss8sf
zBC;|3J1j@BGa_c4(z#lgW9Ub+to^@bPoVxXHgR$<b40oqkRLJ+DHdoWh~5Vn<sT|h
z>uUY8;=Uk&xhLdnFto*5V#hO)g<(>&VGV-h$1#aOS5pTwdEmRsY=`21*yR_X)Kx`k
zEprg%@mPx^{9SE}o5T<<RB1K%+Wf8mPhS0RFRn617KV8scXkxij2juGR|+yQ{(q9Q
z?AZxu(nTZ}9}@XJZ6Gd9#||Oi1EhK~b%*}kac}pBAXFds2`wL)ZN#COv>l6+W4lD3
zw77D5!VON%d|*fzlf3;)aw{$<=mA00p_+IWKz9rg4Ko1BxECtUfOYYkpw9^ZyFxsi
zw7^3S7XmzDl$0Q*W%zXEHJKAQ0;&wWIvgm65MOeAlzjWqb0D~BabMAqXSzsSP-1X=
zr?hLT(0+MaDR{{zloGT%99HeksNDxjL4OvYt4(d}>A^#`0rB0l?2-|sUfI~Qol`v2
z4OSN_cq_99!`+?^`0zy*$u+L?T#G0XKlGAy!Qb;xB|Zp}xD@<j=f1cGxUY5vIC?mm
zMs1=vlKxgU2NAw-k9>hj(B><KMYAUL?6x2NM6&NbydUL6e9!RHss5W-I`G5c+*1au
z#H3|6{MaXodQ#*#?cmiX-z%udE3NhZRE*<*fVMElS5}A4#kzNAw`y}6P0+3<+{8(x
zlCNnq;St?Y4j2u9ArdWAi6EcjcVl9eB?FfvUIPxD-lRnmWB=EsR~cij=<?;M#1YHc
z@z4Og_s@R`=hD$CYEjj9SU{)I`mciF+@lwXNgT=@Mr$Xy5%zcu9GrR{Kh3|B(mgoZ
zk(aM4d<&ZiB19Q1OU@0}I?S5eU=J+#h66!cg(4biHrQz!uy+&4>pUuk3l)`gpNID{
zeWR0?e;rzu0d2W-U;i2`1I6&s5nqtE+K138!sIS!`y%!oMO&5?*0KgCU6kRDM0{xU
zUzGOS-b?pF9t_~CaNx<cMTuCUYU8msTJ$*@Ozs2hC4Q4(bNjr5y(VmHPsz<Oc7Wc3
z;8=vN0(dhz;;q+12f5yFY>cY;-mhNFB0lU~7|@)K%UMY<imv87`97wVSJSVi3Wree
z=5ItK2w`js7o-J)qV}_dW@(7t4B<kM?qmU)+WT`w{{0uy?a~n6H~*~L?AJun(3xp9
z9XXz`P(=p$E>{m^PCJr-6uDI-Fc}f3rnoC1c?djF;~35&%9Q2Ze`HS!km9WhKzupQ
z&T3jM<l}Ua3G<jvco^jPzh^!w-|7JNSw#Ao%8yTj=@<6ckFOalbw=M_2k1tsC?0U5
zez1(NPvjjn;&q7lQlL1TB0oVt$_wcvNU#v3V*$Aj9so}a(w>Bf9BQN0EpHxQ(vd!-
zUSWanc3!Lbq&2^jgS0nHRfGSd$7T7+xENy<NKR-nXgDtFTpVZOLWELR+{f6P6sO9@
z+{PaBYDA+JYmfX`$3dKDFGWt<Cbbp*c``wLgY{!~Bp_Q&)Ro{b``~S>XP1lL7r?gW
zMInr;l;V4?l;Zvs)KJ8BeYgQG+w{!#P#n9mtpp{JwcI2gIzcW6k68PttQ{%D%1MBK
zA+1=I%+B6^+-|t{L?}u2>iHW%Ca>LRpI@ZRivO2~`;7W6=WNbC%lGHP6jsnItSM?%
zNx?QXxZa<sm)p%vaOGOB761Kt;@lH6`nRewMbGH)MLdL|kYGcOZUK52tcNs3(Dfn(
zccq!S+!E3IY_0cn=7blQyri_$ZKtCiA2Tw~ic4?{+L2FCm}5tRM=cos)93TVp82)#
z*CT(=yyijvQ)4UvW4eBqdXl*()qjv#3G%eN{LfQ_^UJksy{gWy_M*OzbH7aRvXr|%
zT16ac#kzP32cW(2dWLZGCjP2n^>vJ_ajx`NTW!T?JN<6T@Za<D+Me2u-YTV>ch4N_
zn5$P|+gB0tr?XH<STcK>rsy~^x>{}E8r`4qslr!6#<dzVpHFh{^bAXFn9EV(EL5_=
zV;n3NG<^W5*>n$!bLGaPuVfOR;S#*||EgB^Tg}0<D#fxY_Jx#u)6<>cC~RVsID0ho
zJt-6+7sY3A-#_f=o!_2@k%j#}6YNkEOuCv*&CvdB^a9RL4!ZDvR|XT5A9@d*3?H?q
zWIYLFJESyJ3xy_eEz%)0?WGeDi;J__n`Id=clSlt^<~re{&{I%`^DlqtZXh*GmCXr
z#eYX}|FO~rEKOwZ%|8o1hb*-!Fuv`rS0sLkx1ze)AOoeaNt@vPzG)E;7?Mg@M=LP}
zv!Ta@r`E8dH~Kb20;Lj$aBPD;@d~uKRs$RwIN(yGtr|3L&%$k9plx9E;1^qt!mkLt
z^Q4sFOsWK4P&Q~IqkLLayj+3+tcF1{<z-^9uY?fH3aTVTJ&Wj#_7HN#Oz%KPyn@T{
z8-~%2m^Qku#v*_HDMjH<$cK*feq&Di1uNqh!<znLVahZ(p1XuP=_5>^E$aV`EIx_g
z+u!g^Ou*$O@GllX*6sx)p)!?mo}1*VG=GGIJmL4z6&*xnVNmTJQ&~yr%g<v}kIUhx
zFk*aseBF%uyT>9tJnfS3fVJWc{2g1IR%t|G-5YPuy*-Xuq_o;p%r~Sqm+o%$f1P72
z8)+sK&?C)2Ow&Y`9*!NK&H0m#WWH&O;@Onn+`LLeo;9cT!#M}ZFA<>$jq4OLEaGuZ
zBvSi02RzQ%@K<%VWbs!IioXMA*Kfzzeo&b8{PabZZdb9i;Be{ZCaLp-p`hu!0feYL
zNNA!zm)+kEpF*7;f04AA2_!+m^sEMY^kNqo(dxuWuo6S!Xoj4>flOmSy(74xLIks>
zl5i7!*x(>y8$;&K5-TKu;f5|m^HivQn+x~BXEl(AvtfrUF~G!~+o(>RdKCXwYrQf6
zV(3F)^Qs<r8HfA71kV3Y6#e%@CS&*CGa7URQiMJKb%_7#{QnzrKRDa}AK&@kOBcQl
zsDO?%#(yI8|Gv@RiZbxv{sgPynp`8Oa6JU=tljEd7@&*a3O*+x=@EoD?}8@>OgxW%
zUx0ITuHEo`VR_bmbwH@t6wa~0tYZC;(xjIh(|0by&&po95hiac$er@o_+@UEq*$9L
z=$FR(eJGTeUH%x%9e|01QqSN^;hkb1L{83tI^DK_>m=1*`e-sdtp&hh#vu{s_CtM`
zraSuwy1}b%fVf~5%|)CH<3rz9tEeY&B{FzMIqU3Zq9&CWjD0GdKMn|P3_A+j<=~43
zr8Z8EYx~&+->h0bn46-HzB4y#UtEg;5ggrFHXf4--(Phwv6TD~)Z0qx<fwhpt^e94
z4dl*3f+=^9G(vybu)qHo%Nv}H@0y-|jX&^ApcEc%^{wAtZNC#;Fk>3<gYqm*Etep9
z7iO`gE0w%}xOO@+N#LzQ|2Q0L!R=#9KcD(E2!J*^YhwPn^^=tk=8m1l%PFZR?tX2K
zS*Ku<M4wagm>~DPKoo7w^5<xGKHRH4CAb-%a*(X8i5y4j*3aOAj8QbBYT9@_b=a1P
z4BcXukW^|&`aYr*L88GX9pw2lZP7s{ac_r$P7bbA0$E`R6<?549I8Rv47;}^tNui#
zBwD>zS9$1l#{2uxEC;S^<4-C8%C9#3x-{Jf?opv`j0BJXwK?CqB%qvJlXJ{0_Ge2U
zqOe}0>_ZY~W!617Tz%}5s6-Y~jj~6Q#&g0vs&tkU{f!&B$nU@lNTgXsqLVtkWP3v$
zK0}8joTrm7p6KKUXqVwYi!I4J=8|dy{MR<?t*vQ9d)e`lYPcro7c`8%9N2UYOO14u
z!iNX@=g+4VrcJrQK>W%aS6nXlD_(XM*W^;x1md@GpBsdQ4q%-qW}WiSb@N|2a3Dje
z4l&85BgbK`D>foOJa~yn;-!-zQG@{HYPA2V*Y(59fhyez;b#F1y61HwXsoB?$3BKz
zKh=}_yY$SI+o4%A;GzRSh`GCSv`_5h_$z}K{sd#&;r(J9*v=^k0K~BgXt^K8SyL8_
zqJNi20Sc*DSPy9Iz&Q55qtXA5^66+iM5?#cJby#j2Q3E;!QWsP^ytB#hX@3;;~<G#
z!1rJMx7uEYQi}nS5f1?fsO1274d~k=KB)S`&4K^z4_)4O=s;d?5FkNrV2A_f+C)qL
zSF<0eCHa3^`^vDW!gcMLaRv!d8fm1vy9Fr;327Kmx;q6OlvcVM=@LPtYf!qoW9XJn
zLC-?>KKq<=oo|2NkMG}HbG@_HyVjcbxu55L?)zPT7W4m{!ELnw3}Om<^UIfUaPZCY
zDxeSqB>!v&RQ%Mc9H8oOe=xdJ3CwF_;Ce*VbK*?Gi0?VEiln5KjnRQBX>;q=y`v8#
z*pXNT!4W@%E3&$pZ|IM0^s&+271g^}v`DO#N?O(GUVOQ`d6>*c(h00xPGq-B?2_zo
zox!EoJqWbdb>2ioU&!y<lt{T8Slf8cA0js6)6{d|Bn(zfqWrAiiS^EU(XF*tqV;7M
zzI?%>+am-A?Q}@DV3Tl^?Ywz(u{_=KJ2e}Xe}u}W=|y)Msl|tRMU#|T)KxZjme+$?
zxZyhYiko&rZ+-?pRPa}lLaZNkr+iUV7qdyqFKBHi=6l5TbtE@PBJJT%i_C5@sb_)s
zjLJW^tcNu1XP&tR`pHL<o>xgj8{#OxpKb*0WM8BuuN1zJ7S>{DnO34Y3ytj0_e)H|
z;7HiZF=uFI<`fm7t0v{#<ENnTfy?z8a+)R-E(n^`y15Ph>J*hh$e>mJaw)txBRiMW
zfeKZ>S)|9G-ynWzo7a8qTgSq1MxH+B`WzpHVH!D{CB^8|_v*}#_hZXz^%A*7D75wl
zLY}p>C?jJFN1U8UT)o`gY|iL3|9O{kzh$A`Qqr$QIZ^+7kL1-0u^HFUUy^f{*Xv8K
zS3u9pMTH-KZ@Kxo0Ta@0-ftgrm)SYC(Tvn}U0%W<y|>5fdt9E9!6G02N&II?k=a=d
zJUhYHO6c(AE{%~YwulKA+-|<X((WhhqJQXpPSE>f$ee3am0czcGN)t1&C|eG87^qJ
zD_n1BHO)dfLhYo0i&D92d0{p97<l__C~?Pj@MO6B=_IGk_+}~)4~!AS*n^B6XGt5d
z!Y9ARe=;(h743;J#{CX0T?)>aJ`rDdBYi13SzkL0-HqVD866G{RSL>z1jQdO(cA=k
zeKqW*;AE+Ut5ufsZhU+pC2Wv{GsR-cQ0evZ*{{+WjC5+W6~R~-F16@R=cXwumfPn_
zf~2O5N8erap1EI6J$Yc@xA=?r>G5WU4L|vH2N!bN2e`wZNd53}P}NvYUkt-1?uNmH
z$~&#Zgs4>)#OfDw(=d0%>KwGgD9528<X$x%{!D31L@3>=;o8WQ<#xfl)+Yrwc~pDq
zy%IW5HLX0%D4UO+x#?D6FRKj@jFgg8sSm8;Z>~C|zVqe0`2CeZiod9-*^oHoTeEcv
z8A*Jf0&&(f7jqb&^@wqcc6c)ey3;J|A}Z+DU}mL7RL)6L57t3_QE-!}yvnCLjC^1?
zpP0l)em@yRLp@e*S_b!Ee@6vzAjH?fjw0*LZfX2#9fK4pKDIRlMmvO@>qpLIwu`nA
zkJ!R%Fno-!S`EQiR;gzL^>WJN!1*rY^MvC|$H$vUmGRi#Cy0|%+i@v`s2qY{Zp~;)
zN5Z??sc56{(eKD)3mCu5&yt{weM`vPdAa+uwN<Q~%NIML!GNq|y>6S2v|eR*dePN2
zk^xD~;M1|Y?`>5p;TDQ9<Yr{eAn~H{*TG1rq7g_YXge5R8G|tiwyCF8?dSr?Dc%Sl
zL$2k_`jgqD&9DO>7)0J@8--<`+}tm!Q)e~sGrs)3@lu^E>*}h?28bXuNN~=~kZ1RS
z({fwJ&E$C@B&}3pX4JLY>1IhH=Q74wdFw0BgK`P>(Tp4!9Mq2=5Op8U&Ytu4k*w$B
zH*)wW!Jn^u^v}uD#cXCgw@`vTQs9a2qR!5l&Tnktpk-P7-q_ET(FJ#cwr^hb%`V30
zV*wKkgrtepTAE~cxH&1<%{!XS*D2{_<#$-DM7hvy+BP}x74Kz<kKe9Uf!|l-z%(g-
zsn>F^sFRM6Wv&cTlt#b%`3n9uWnv;ru<o<m^*8xM)l%{DK#-ssg6z~B{Xxn3F1KVy
zCciE#XGB8k+2;zGobf5zmtNw~S_~lW{~z$>e<Mc!>rhk#4F-tG`JaGsOAtvRKmc$-
z{-6qotP5!g0sjEG%wU5Kk|A>=<J3rOB2=2o9bjBTg!3L605qNyZ>(y>OapZS$-V<W
zgj~CL=gl7x7!yCdi;ke6Mmn|tmk}tjm#{IAo|oxJPD1Wp4&lW?u|WE2nMlx`XuYH}
zh8G!a2~CWrn`E_gg$9;GKU>=#vOQ9i(C6bE`&u}~B>0KVxy$Qa^AiWs;BiLF50~EW
zg2=_2*3rG~eV_AvRtpc0?+A0{ZM!x-+-*AdYz76B`h-y?>as7pxZ{uPxyXJcX}<e_
zCgs<!GeIMNMaS5Z=Axp7@l_Q-Oqj3K@JR&`shm#_eb}uUe6gxuz5WzG20MR}3Hgg!
z%c6IL-+a$&|EUflNJPu21CWuNBt+G9)Bsr0xI7fQWE`2P$Ap|q))9px2FacuZbd4U
zCZL!q9yUBHbfqlM{2u&(7=vrK8y`@%sswu&#DYY7j45=VB2HATjC&_ax7v>rh^cd2
zNx%&De@-)Q-twh0<sHyA`Bhyw2LV$G=c;hvm|)~0IZ1;C&IrUfs`QhJ2KUX+mtKMy
z@RA){`U@JP%jKZV2Up&?^FV80jzCWi@nuM*ji_N>P>D{d%{uZ#bnkU&u0g556-UrD
zJJBn~ZXu!6d54-@x)y3EJ&h0h$)i<_=}qaY4$QN4w~wHP`PIiwvInDY1@5vhZJizz
z1q2gcDYhW7?l14s)3uw^Nvumr={|*&Hh8P$PX`zEDp=g(qiB8=((UjA@zHmGp(%f<
zdAlLroN<Eytm>x)cQ?QWVf$pibP#hW-ljF%UR?;|<qe1vr(Jz*o_wkGcq^SAnWjK+
zd;303d@AJZ`q6-;fr93!Au@jGipjmH7so+e)qS3yNPxteLxsq`TAEo>r=EJ70aVY(
zgatG06NRsWsnzWYU8M0VzF{HcgoZ&%Rj+a*F=#|@NSIl+uyO>hcA~;y3UB!!*PG})
zBsy`mpY{$Tmy!rpEEOf+gWSO`5nbpDJsNl>Wk;0J4-CoJ@Tik{$_b{p<}7Q)1Yc)G
z_s2fq>){=L8g$f`^+1PT2fJtTtmo*XtE;P}jTr;7?2FFPPV3tdQ8pNZ_aQqg^27U;
z0A)FpP}942nC0Hi#=(rOkx&S&F9b682(s!uaPCic!S<-q@ab~l2lD4C?2pYW1amFw
zr1?0JK!5xW$Q%}9ym`N(GaC4BZWIya^tVwARqIk2QzMxcx<>G*x28lrYiMfHKzbIw
zHDUcf#}!5w<bl^Roc?-|0Er~ahgX=}`LQ5VSnVD^-WtKDzIh*_K5zaK!>_)7&9#C+
z7Y3QT_rtozT^6CC8LOQAl&Gik2y$|%_?Ic9*a%sLU2wBaH7Gt$a=ep^1uN)Wac)b*
zF=>-dtStiRiRL`CaXGGlL7rUe*g)d?6VD}RSTV|b+t%j=$H&H!cE$)U^!!2A)AFyT
z1GY%;P2!Iv8?)THF}U!tgTG^}Nsw!;G~dNtyn|j&r(yCc1kFvu8L9x=yx$j-&<>$v
zD^_A=?ZYZB5;R_cfEnzzogOkE^=#>s>{38RI3`7yQH?y>y~+sJUW|jY#{&CWm)B}c
z$B)*$Rf)5Rz|9$vAS|UsU}))~qdtnj@Yo)By`X0XNR)#Uhq}sG*##}Hbda4GUF{29
zSE(r+LBhe!stJzB1zPab!jPCT@e)p7X1Z>>C$@LU31zu^p$}cmN4}J(#Nc7bB4~+1
zKa8Mr4ZKmnZP7`9;Fu_4M$P&4ceLXCk>6{92OeHom;nO%Bd0a{JnZsY4Zr5y?FWfE
zp4cr{_4>qFzJ=V0qNdt2AgcHWF_I#fw^JoNq!$VYqz-P>$bJ)b&Vw+jKvqt{lpaxq
z6|lEINU=a=?3F(j4bnlrvc(>b$PKJvECQK2w35)%%^NyCczPMAULcF^_Z-%>X$Dai
zE`3B(J++-GM+okbC=eVYpw)=Y)5A-JdbYqIY>Hr>B#<#hVR1tNW>gyA55G_7<rUf2
zDW!6OjJ>@P_lny)TNZ3fg#`1kfPKSsn~k(Aez3AM<v^!~)C^`1UjA}cfs6&WQo|O+
zh{Es8&2h?fp(-g%VC!+mIfJ?yqD|aXf$u;yfwGxUut|eI2w2UsUU>d)d?^4ELe|O)
zfX9?7xcE*6vA_kp|KfNq>PQAbb};C%R0C8{)sGQmQ0Rp<+m!@R&H#+U!wCnVym6Z3
z&Y1^FAyk>^eVsr+lMV$T26;$0KHLX^ME_Jj|HQ7pU`9Pi_owFhTOR#4aJwB~M}5KO
zgb_RH033w?l~9HTb|?oOy6fJ;Tikzzx71fgCP0yd?zB<9nXLf(m4ns>hM3$%KbUoV
zgj9JkY|gzur+0OEf6bI=|Iw8B|A00E)I3Y9cPl+#<xJkL5uV^t5gBpzdyDXq;!^gl
zs?ziqI+16XbYlTE6}d?~g=}xwzwvG~KL^8$^ZGcN^xM|98FFXW!mN0GA5cEwySdqO
ztcS!dFzEPcYBrU0rv?DHl6Z?N`#Oy&mlbx!RvBzz*}a@$m1OkD=K^blx%{OR$HNW3
z9MH?hqAZy-wTz>Un2?osMn{wLbd<`4XoyVDG2934oVi{cEYX02#J#`apz61V9BlDW
zirQtRrt0)RsGDY)z^eE(WktpC%qw(o0!=s}FQA!nv3Hmr|EBEsnzP{nAg7A%^8INq
zu@)Y{Pov;JIY_%#T}_Y_!+XZgh=kX)%fwFN*31(!wvBF8{M2-4rQNQiQ+k3D*Y}yV
zuYuXA(^O3dN+q<V<Cbu8BKZ>O{?WhDX^?s7hr+RPi8?rte$$*V;IrURGwT}$YM>x#
zOw4QJ^&q8al<z8k5uuzuXW(oR)YVZ|j5*?&wX4uCJmR_8dGv)>c*H9vfn%SBU$DP}
zrG6miUA&x(UJ<5K_KVB$dcsrKw4di=%rp}M%%M?=_nn*1YVJY|CC{s7Q(zas(h~;z
zwA;C2DIVX@nJeMfFwzQBRK`pe42~#qWl!KT56J(cLsV01oX-dD*~+(|(Wjvr9l!~7
zy`j^mqEVDk`NqxMF}QAf$ZLvbkOVS4;{$-^rNzE^3=j!6=jP^;N%aJt&%fb!Xk=^=
zmx5W<#*lrkO(o8%vr%iV)}a)Ph<&_bScG{X`~9*~hcN-pzOpRzC>B>pbsv=lv4dYn
z&c~wUA^x}$V6ekGP-a{U18Xi4_bx`oIsK60+}@ZvJf%qFRL=)PPBS$IqU8J5qLGMs
zAk+u5?^rDSdS>1&8|WyB^@bFY?cMYoU3oPpc!MKm<PKF62;75RfF7!OE<SJ2i|L`q
zzgx9Sn4L1!{Q&8yyfk1J(9C}^qQj6_4C0j;#h2t~8^r+LF<0E6%=YnFO`R`C0;EI=
zk=iQ0S1kvYV5OpC#o%JWX_;R+teqhQqv;dqSXZ9l-IM=JgXG)De;;t~IE?{mJxrPH
zVd}q(o@kC`kqioC!j(~RkVyDU&nacwMCE9v=scNw0<wkpeoa(K%IFT#2}l!#QzKn1
z!=OUuUP=x+lp;sPX0sqd(hSZjeFoC|!T3FXLd6|^4``b20-8kNzmsrcTmhPGn(6Zf
z+f-u0x)NA$vtlk{0Hq3)YLZ5Zj2Q=2${#~tO%I)ltCl@bO~Cn{-_T=vI{yv*po#{`
zDjx>(y7a05^&0QhCLV`E;>h4^RVnZJf2m;2Wbuf@;LB}6pc)l~kvYZp@84Z>SB@|t
z*J_KLO2b>yf6h3yl|+v#@$_wfB^@C<5w&$q=x9PATfKB3guFt*>?<|oo=>jcCv2y-
z6r_P3Pgk(blvEPQ<<93-Lt5KAc71CDN#?SMcO8#;YCYkINS5_3+LcC7WNJ^({nPkj
z5LHW3dZV*I!<bFj1>Q`dz5^VAL4oeXt2>6(_)UIk{X@^ixZFr>7HzHq!mQ1->ay+c
zte9YS0}Ja+(b8|*0Y>u6B>S*d9318S7eRo$M7^Z_Ge%UK9W$`<q=N-;uJ*>FF<ld_
zn~9S!qUOp9Z5jnQr7q_dWNP}o!5-tQA-?T~#SrpZ-B-&&3KL9Heh=?ypj5<2h$HT>
zedLshI^eD~>fB2a=JEVe5vPeV*Zxn;xEpI*S}NxB$?TQCqza<93XC=~Y$XD4ZqUK6
zWFQk!SmAvy_!O!J`Won4^gaNWWy&t1-2qJjK^zAMRZy|{^On6rIlRzq0N=9HogX~@
z)-A$@gtroA5J;letd>gz1lk2c``?P^UnS66u~-lS?=Ao^{|l^uK;{0ZN+i9qDsX5C
zO@u)`DYUi~(l+@5a6kOe=%^wC=(sue>yPtCaYOF@0D<=i>0a?~kV6<`Vu{YRl5Iwf
z3`b#j%e~1gRY#oPPrC2UUi$5ub7PJH1GjCM?s88lA0tPl_$Sa*yP-g#+b4aOZH4I@
z*zfXidKTozAj-3Yizj>S73SLSYYpcE+!_pV?SuAP--YD2wA`rZn2gM>7CYJ$d-8k7
z-bVNlAi@Je+{HelmDxbm_hyzkz5%)V7LTOLMO-yCDT0=Yik4e-S}2VTk9JmgE_t9A
zjgN6rD?yJAU%abbwZVwMdtdQ0Kx6~_Q`q;yDH`-WL+k~*)4ZEi4L`=g9Ia2yc{dGs
zMD%mbkRuMYZnQD>dpY<#TpF?7u-c1*dLQ($@;TyG-R68)K(B+vdwSWlD*?2_pU@od
z)m+92;!T85!{CyUUuS1DOs<F)KBpLHAlT`;&65{%5nAXPU<R?qp=Tilqy3_Vr#=*o
zqTSGk6-ogi32uGXo{>03w{ih5D!`j}x8$7iDt?YL^O0*jMPjdVyrnML`%C_0?tAzy
z=Qu^Y30ez@oH|+$?{!wm`gJgBnKK@<CWe7rX<k6cOUy}M`M&P{w;(|Z6S894p#_Wy
zhk5KpSX`(pYIhBAkg3LpOnQgTLV{`~4KC)Yx+IV!W>dgdZ+hDgxub3S0}5ctg<t@Q
zYx3_O)nBt;ZVL&!21Ut{g4aonH#l@QU<$>Kqi=?TT~_n;CWuZ+{yM~TIN}MCm>}eZ
zTBA$!I=!_*s$FK#h*43YLQpF4xw{wMwE{KQH%4mYCt~VoQRO#m1X=AiOEZAa%3MMq
z6EfKu+8{2Oyei2f;~|=BcN1b}pR@m|jZs&KX(@cl57MadU0=%~Cb?;YW#c)R2Try|
z1NNXuqn+G7k$sBkTf@_GL`)|I1o$h)=urBTErG<rb`29kAhOgJ%uldLNpEv-Mnm4i
zYZ#=Wm5Ms>fw;{gbNe|q8qt{-6f%9K`#Ci#+M-!FeSMBHQoajydsZ<`bxlpzP5B8{
z-#{w}xtNZoeG)4k_2oA5W31*C4Uk9YU&bpvMX=@}ldXivRhiD03a{=SQZK&()>|4n
zmrncJmTg-l^r$u=MZ&DM`tm}M<)>)2iZ$578phQ)7AZU%m$tZ?xt;h6f`xZLql&63
zYTtg^e$+q<(eh(2HW=I06(Gor%J@WV-xGir#Nv$WzCvp?7(@O9^t881&-@cNHvw*v
z9VX#<5SdKX+o9MAND3_~6(cFZG3!K%m`=;+{kHNOw8%4tiJlTnrvy)hCt<JS?tt;^
zGU$t#{ygfx_Fj(B--Y28$%aK4uYRuJN;{0o#`AFt*h11u_~PR!NG5@2-LSgU({R-T
zNOg`b+J<&l@`78LmX(|U8?N%^Et+P-QSlO(iMFLoB_z4OOCJ#2AAEV|BkXr62l9c6
zfM+&5&QH~`keO3z7-*YpYk~1}II(|U-~VWypzx@5IRNCD1L`(W(Ys78vVH#ARZFnj
zNozWux$?3^-rRL{+`qW~RSJk~>&<K(Z6dHHv?f`4KX(Jup+yD-&CCQ5vtkDT>-dW6
zVJ<SYGus(qaA1y(K_()O)Gumai#@#a%;SL4p-+AZ@JGKGW_(<YSoa|JEZ6Rc)X~=V
z++7u)10;ZxXb%<SbGCrw9#`x(1K%Ua5<z*tc(;gmVWfh|AqlO0P2@**4`f0Ht$i$o
z$n418zjI4<H3Yu+2l2!U@KV6@7g=}G68aOuFW6$2zHI{0`3~Yw=i<NdSx3qc&{Oh%
zc1VD(3DDa3*8n8i+d)VomoRYX3uUmwmJJRH4GYIO7zWHwiDdF`@o^aJ(OU_dgrVIE
zsXU?YIl`W!ByFU~sX-nHP|FPwWR8Hyi0Js1=-L<gf;Vu#oEDbktj>djk~;_x%V~Kw
z!G>J=GK*!Y7gsXLSV!ReGjU$EFNGx~p*C99oQ>S{Yu4sBjcK7KGF%-$Eo%qoWQ&NE
z^rj>I`%hU^LhO$5Jw;Wc`lXt+wJcuCWyEvVlfIZ=Tiz;5;fbw#e?s!!(pZ>!KP&Or
z8j@z^)!1i)n^=+><&eml-G3nNg)@9|UemILsqB+#c&eguTE^gr2Qd^hnz0Xuq=~9k
zy#>i!iON%+aJW6~-Q9-l4yfh6&Bjt{PfhAx&SaD__7LbfFI&rkPVZPn${<qPs*0Oy
zJO`tfVtGr@5564sP`#NeBZo5~BRXQjLA;iO9JEO2&vU9<h&a<}dkx674e5?pon=6v
z2PL=r0wBv7pFIR7Krv%)4~c83cd>wh2Ud(!_>?fYzUUXB<7ch#!n(eVN*(Lp57U{&
zMR#&7Y_6M&c?oXjv0_}81}8XRvKil8cJM&%{X6V{dhLZY#(<japB!OHtJy4nlIA2i
z9TWYA7b;4tZ5-1ZlQjB+@{vYr|2iRk*`)p9&~w)|KuY!=+7;#_GA~A$-X=Ro<oT47
zu=>cOA-!_i^u|NKoL)it7twpb0OEN-UXTmbK(auKK(e3vfv)RYu#zJjA;Br`e{0#0
z7F_yM>&O^Q@1W)t@jR<$xBc)Ak51iqPjMNfx@T!t*rrXIi!)w6`20_qWLie>UDog*
z9$CbB2}?wyR*#=w$R=dBkWDm})~r0gbUQ`aK?k4bG`{Eyw~ksp9rWTLrZElF@bBsv
z=tZteP$>=4Bl08~?Lc*#d>HRC$s<T%Zuxa`wsveOcZ_N*LX^F|2-F0q)B<xaU&nmO
z8xJcLNM$cWAKH5u|II^jImNP4qM$SpvjTlc+Ao<ni=U<Qk^ncGj<%2zyH-LWJ?&x`
zH;%WAiGAsPzYU~MjvZsfkt`bjl&|H~jIO{``{*i!K>4XX2i}IX-mvQVTq``fBoTva
zQ_i?6h}Y1(bSH)oOuC)#;?CGz>zsCvbR+4O9f_5<Au~RmtOsH1xU3L*cGqq-ive$c
zzy}JaojSu{L$!JWsF0I@Oai`1N4v8$1FciqLhEDLRAcSu@Bb=_DnP-#ce^zptLy}D
zLX(0h6H8irlZI4@ts0HbAy0J~^=3C4m=6zqUC5lHa9$DLNw<+r&t`gB44RD>(`Jre
z9`Pw^hnVpe-lXC3*ivb^+>^*JCEX~h%kHOJ9r`7b;+>HLX0pbtsBi3HxaOnYbuV^i
z{LMX389QLigoMkZZFF_eN{X6_0W};U)@4_mb<#<Ew=!a#^{do)77bEPBMOo!EdQFX
zQaja!0jVRmwxR^f1KN@J*rsNBV7!=+;YxOVrOi=$j?DLYNERHFh10W!PqGL>6?1cQ
zInDN`xTwx@lYMR2rsKy-`P$NDLW`<fn7#2=+JHSI3|ahf=w-NvrD<Zp7_G#b$1_(5
zx3LCm7>CaoI=I_MSIKIC<=ET^JuCI4!$E6N>BYJxYa&KH3=sCPz-|TP%819<J}XXE
zdgL;}G<gO|hdbC~h<Dp#?IZ2yI058e32mIeo@Fr>4A9nIUeWoH^Yy8NB{ZIWe>*}?
zjqI$##>TFyVocCy|4;Vs_nt(^1tri=VuER__QY$NU@9qY&`ObYV?{N0!yR7$WZ={f
zBi&&daD)p<({nh2+n@r}bom1_)cvD6zz7~)v*=^8@xRK=Mpwf(nahaMRKh54degc|
zQT_-_edA}F#6n<FCqk5odC+x@F=43|#JG9{c}{~Y<Kc%qZ*E=b+S_|0oGEnQDH(mJ
zo%DA;%$(`rMvCK|Czy@L&?#qcy}x{*)SUnHw6)T6BBPIupuA0aVI1n$=cRL!#aIV2
zel2XW1><3WFhLe#2dEC&FE#d5y%VNrk|V8eT|FDHsa}ImZ)e=*=4cebs&Pj4F>wk6
z7*YA%_RqT&y>lgikO^cAP;pKvONAaQdqmUQkXW;K$;>U{zdEC7bJY-?)*jA<z@`Ev
zNBqfk1+OL5a}Ik-mVU+$x`887MOK+VVILst41b&{zt|cf%A#q87I1SS1MWj>N!Yew
z@Vy)-Wkk5_rvX{WhdP*L9RJO<DNyif`S|mLE&gN#W_LXD0ACL^tJ!f;%rKfXF-G~M
zwRQKZQl1?VmGz1p?Q`V_DK3DK8GZgi=ZaN=Z+b4&Cpv_^`YM0;B{(L4KK~p7(5P+=
zP0?50V2yM+K5&eH4v}Ry+Nh;~R$3}Efr*YvM3+wAQ_h2&clkZ6GZ-E%GiE%VtF%%)
zx|(I@kd{xZC02ce@xMl(g%936#%%OvBfVQ&*>dl(2HuwJ#;cJ82A3M!?*g$mA-twa
zbw<4M>#70?>jRGeN->_s!V6FbgUfqtS}x)7@psGOH$!?=@KG-Ii2n=cxMMl||CMok
z0|vzj;h-#O|E3#21qmbze|FvYFB*$d8YzcfCf}V}yd0;+CIh6lqhmugR_J5IEIabk
zj9CRpX~&5W%gJT*(Bbf`+P2|VssV_5Z{;AjCW%{Z$e%C%9{W}`^5+Xc!14EFf5!e9
zkxGLMD3wh8<C6Tg#$6)8I^Y-uVsF}oFKe*sjC^}W-1LFLAh#CDxEm!`kov)81Py)Y
zXUxLQ^pVTbwaL$putLEs2{fNX<KNHL9!>D17#G>EKrae(iafp%zrSP%BW`cRK}n;X
z8R}3PoHqil0cnIfM;6)8ib~#$4>6HKRi8sG=rWogo$Gspbl~XgeQDQE_Q7AyRPazw
zvRsF+7v3!Df+M_5cbD=!?fAhFL7f8Xh}h^=D>EQH2@Ji_3IiD@>EnVE0zkCn1!P>k
z;bn${9T;lmgTNL8s-7)PLs~x;24_F!+Q&j4Vmdr#A;n}qU+Nx*>RK(S;GpoN5buS2
z4%^(@-!Vo(_U*tHvxGaR0HYQ6=@;9pdoYN=D_sVSR3mgV#h6~aVO!j{8flT3rxL}H
zw_<Vp&`9CkKWgz4Mocb-T6$T;jKGy%)CAs`t+w+Mx}Qp<=seHP>J@k$@o6gI^5UJR
zG4Q+lsv6)9@>7ZWQCf!v*0QoZ39YkmH_W#>yTo3t9~~9&vjhl_YIJwgGg*;vDy%v<
znm*)tP*Zw3yF^NUkk2%maftqRGOP=R-}kN{cldZCpiTZ$8zEK*46C@Os-L$O-z})?
zyB-X7hr+35qXhBnFd{TGRmz3^&oEe|>T^9c7Grg@CMQRlCBPAZe$5_NKD7Q)Ov1uM
zHW(+WZH*`GHO~fGP>EniCpST?1C8)P^n<omMRX^5z!19((ZBAzJSC`Iv^gE6=4zAA
zgHqAH_LRfCNCue~NYcvY2@&^%ox$;{@)X7%DW=i#&^W`nXXlPWA>=AYz@9-f0&ZX%
z{u29G?a+V}popD&uH}zl^Okvn$&e>N{85z7&k0R2WuK8bl}2^1=!+w*cJv3ehA-%9
zvr!CE_AF-MJ<bxR-jEN>(UVG4TAE6BUlK_VpQV&(fSXUkHRy+E{1e|qe3^C=d3X_v
zd0a`uJ#jTjmee;nHX_RPSWrkrWLnXlH!AHUsDr^OtqVFuncK1#+r-WppFVO%x4xJD
z@bu<qJyfAd1o!@keka54G_`#A`c8j~iMzbVXAsp7GEL~kkdxEmuT&h=0Abc@{xVjK
z!+J}W*y1*kb^fqcVdyjy^&v)mv1~R(`C!_&W9t}S&^86X@Mkt#B*jNcci&=Akx$NZ
z#MR#$VMNVGeT^(2Mjzr->bL1jSe0ZE9b=7(3YI{No1W1$Uz98APM$0RonjY3#tPze
zSe{5KA>f~5Ks6OoCA_&7=^s59kkqNNft_Dg;o61b*?nZE43hE)+-((Ovu1{gA5$Z}
zZ=dZ;Nx<@deT1Z$Jd0)D9tfB{6<607KRY``kD7LO=@Y#wIA048$jd|S?HzOK&PN-u
zBK?mxvT2ap@9w-D%m7px1L2??Wj~d$-(kJg7=SNefp6o?OH3NrH;ScCu<p|DiPH!y
zqIOwgP1WERGlUNOIzVY_b_iBDUfMOK;aH`Asa651eA_#I7R^w9YpDgU((%!2QKSCx
zvXi*NXYuni>WTc+%#bm`Tuil04R{glkvA^N3IP)Z6Y0vnEp{zLF9O~1udWtI+2^79
z`g6)vE3DgGe^eWHI7#sAi+Ofoez&B|!q8@+cIGk#Rpm&bU>By!rfAMyHyZju9$wQ3
zdLlzpI{r40Cj@d{<T(O+mDF1gW{As;N(`SQsrtDRK;~NQ5x9E|`w-ZgZtatI!m?Nk
zS-ERLPoYeNnB|Il;kub1`WhsSlsg%BDho1y;oA>P3&Dmsb^?>nGx+4UR00E1mC^cq
zU1v0-7?Qh;?^A`&r8R_+{p^zxA~whW;2U~*EJisy&!X^fiFv>d4$4gE32kq7%2YW)
zifu7P8w@h`Q|2Un_{?mDMoRm}iuwyRgw40LK@Wk+2AO*R{&5XQb<zB8C%>UumKMdB
z)^z#sEXL%RO;I)(L@zHDQ*Y`14<o~;X9%+i)&6{Y+WyEG^;c@2^|l~e+E26>p9QG$
z>`XwDzUuDSzb;BoDM`hQdc~YqxGXVt-mCGPgs)Is%VS3-%`9Dgd+)&|wzWNCX6y>8
zpfx1Ch^9cLS3*r_^1<Sr>F=^Dldo%Qt8Kl*?igG^8a7Hd@AYcK?3I(<(OPC8WcOaU
zN<?$t{)cMD9RgHlx0wsukTCZ!Wg0m}QW|<=nwe%achkZ*;(K5XFkDiI>u_l74*19B
z3$7-tYxz}paQiTGY*eoSXIr5!KsEz9{oK@@z0%u6Bvgc5dUHhtrXXiUo^Su|1QSR<
z3Mc#OrP#JbDXnNQf`>|F5np0|=*$oi&pSBgN|1R5z-*JQTyA;nF~YHe9*4XXn0)G!
zTn44eTziFmn0CPgvy=^T!I#sCdhA=h_c$CyG~xQOP3aAqf$v_oJBAZa)>i?dF}`6+
zbhU96x8f$`F(kL4^(<eI{EXReU^}B!6Y(Kr0!xe(vpi9N)aKA#b#;f`s}+Zu5Os3O
zqIqiS_ICHc8PS9_`<st|y);(kiYd)5528HPrXp^bd8%Gph28XBehFX`Owptn6#xe|
z-Q3$Dv8T6EQ3w>cf!c~tV_>6|Ta5pILH0kt1N52ynHB(ha~a4*gOondaNB_mnws`z
zIOqT>yz&nb@<${3KLy_qz&!@|Fab&h^e@fNf8#`mk-s0L3j0UP9MEP1V?c_ss!z(K
HP2T<o;0&Y(

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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

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