From 0730980d9352e45007520e021e66e48a46fc3bf5 Mon Sep 17 00:00:00 2001 From: Elmar Ludwig <elmar.ludwig@uni-osnabrueck.de> Date: Fri, 10 Jan 2025 14:55:22 +0000 Subject: [PATCH] add Vips as CorePlugin, re #4258 Merge request studip/studip!3432 --- app/controllers/vips/admin.php | 208 ++ app/controllers/vips/api.php | 256 ++ app/controllers/vips/config.php | 95 + app/controllers/vips/exam_mode.php | 29 + app/controllers/vips/pool.php | 473 ++++ app/controllers/vips/sheets.php | 2305 +++++++++++++++ app/controllers/vips/solutions.php | 2521 +++++++++++++++++ app/views/vips/admin/edit_block.php | 47 + app/views/vips/admin/edit_grades.php | 56 + app/views/vips/config/index.php | 90 + app/views/vips/config/pending_assignments.php | 76 + app/views/vips/exam_mode/index.php | 46 + .../vips/exercises/ClozeTask/correct.php | 42 + app/views/vips/exercises/ClozeTask/edit.php | 67 + app/views/vips/exercises/ClozeTask/print.php | 62 + app/views/vips/exercises/ClozeTask/solve.php | 46 + app/views/vips/exercises/ClozeTask/xml.php | 63 + .../vips/exercises/MatchingTask/correct.php | 88 + .../vips/exercises/MatchingTask/edit.php | 117 + .../vips/exercises/MatchingTask/print.php | 95 + .../vips/exercises/MatchingTask/solve.php | 38 + app/views/vips/exercises/MatchingTask/xml.php | 50 + .../exercises/MatrixChoiceTask/correct.php | 42 + .../vips/exercises/MatrixChoiceTask/edit.php | 91 + .../vips/exercises/MatrixChoiceTask/print.php | 41 + .../vips/exercises/MatrixChoiceTask/solve.php | 27 + .../vips/exercises/MatrixChoiceTask/xml.php | 51 + .../exercises/MultipleChoiceTask/correct.php | 32 + .../exercises/MultipleChoiceTask/edit.php | 46 + .../exercises/MultipleChoiceTask/print.php | 30 + .../exercises/MultipleChoiceTask/solve.php | 13 + .../vips/exercises/MultipleChoiceTask/xml.php | 43 + .../vips/exercises/SequenceTask/correct.php | 85 + .../vips/exercises/SequenceTask/edit.php | 50 + .../vips/exercises/SequenceTask/print.php | 92 + .../vips/exercises/SequenceTask/solve.php | 14 + app/views/vips/exercises/SequenceTask/xml.php | 48 + .../exercises/SingleChoiceTask/correct.php | 41 + .../vips/exercises/SingleChoiceTask/edit.php | 86 + .../vips/exercises/SingleChoiceTask/print.php | 41 + .../vips/exercises/SingleChoiceTask/solve.php | 23 + .../vips/exercises/SingleChoiceTask/xml.php | 51 + .../vips/exercises/TextLineTask/correct.php | 37 + .../vips/exercises/TextLineTask/edit.php | 80 + .../vips/exercises/TextLineTask/print.php | 35 + .../vips/exercises/TextLineTask/solve.php | 9 + app/views/vips/exercises/TextLineTask/xml.php | 53 + app/views/vips/exercises/TextTask/correct.php | 184 ++ app/views/vips/exercises/TextTask/edit.php | 47 + app/views/vips/exercises/TextTask/print.php | 83 + app/views/vips/exercises/TextTask/solve.php | 131 + app/views/vips/exercises/TextTask/xml.php | 61 + app/views/vips/exercises/correct_exercise.php | 39 + app/views/vips/exercises/courseware_block.php | 79 + .../vips/exercises/evaluation_mode_info.php | 22 + app/views/vips/exercises/flexible_input.php | 25 + .../vips/exercises/flexible_textarea.php | 17 + app/views/vips/exercises/print_exercise.php | 64 + .../vips/exercises/show_exercise_files.php | 20 + .../vips/exercises/show_exercise_hint.php | 12 + app/views/vips/pool/assignments.php | 25 + app/views/vips/pool/copy_exercises_dialog.php | 50 + app/views/vips/pool/exercises.php | 15 + app/views/vips/pool/list_assignments.php | 174 ++ app/views/vips/pool/list_exercises.php | 151 + app/views/vips/pool/move_exercises_dialog.php | 50 + app/views/vips/sheets/add_exercise_dialog.php | 26 + app/views/vips/sheets/assign_block_dialog.php | 32 + .../vips/sheets/assignment_type_tooltip.php | 18 + app/views/vips/sheets/content_bar_icons.php | 19 + .../vips/sheets/copy_assignment_dialog.php | 104 + .../vips/sheets/copy_assignments_dialog.php | 34 + .../vips/sheets/copy_exercise_dialog.php | 132 + .../vips/sheets/copy_exercises_dialog.php | 52 + app/views/vips/sheets/edit_assignment.php | 329 +++ app/views/vips/sheets/edit_exercise.php | 161 ++ app/views/vips/sheets/export_assignment.php | 82 + .../vips/sheets/import_assignment_dialog.php | 21 + app/views/vips/sheets/ip_range_tooltip.php | 26 + app/views/vips/sheets/list_assignments.php | 27 + .../vips/sheets/list_assignments_list.php | 207 ++ .../vips/sheets/list_assignments_stud.php | 117 + app/views/vips/sheets/list_exercises.php | 67 + .../vips/sheets/move_assignments_dialog.php | 34 + .../vips/sheets/move_exercises_dialog.php | 52 + app/views/vips/sheets/print_assignment.php | 106 + app/views/vips/sheets/print_assignments.php | 43 + app/views/vips/sheets/print_layout.php | 9 + app/views/vips/sheets/show_assignment.php | 179 ++ app/views/vips/sheets/show_exercise.php | 109 + app/views/vips/sheets/show_exercise_link.php | 23 + .../vips/sheets/start_assignment_dialog.php | 40 + .../vips/solutions/assignment_solutions.php | 308 ++ app/views/vips/solutions/assignments.php | 18 + app/views/vips/solutions/assignments_list.php | 190 ++ .../solutions/assignments_list_student.php | 135 + .../vips/solutions/autocorrect_dialog.php | 29 + .../solutions/edit_assignment_attempt.php | 34 + .../vips/solutions/edit_group_dialog.php | 30 + app/views/vips/solutions/edit_solution.php | 216 ++ app/views/vips/solutions/feedback_files.php | 20 + .../vips/solutions/feedback_files_table.php | 51 + app/views/vips/solutions/gradebook_dialog.php | 39 + .../vips/solutions/participants_overview.php | 225 ++ .../vips/solutions/show_assignment_log.php | 56 + .../vips/solutions/solution_color_tooltip.php | 4 + app/views/vips/solutions/statistics.php | 95 + .../student_assignment_solutions.php | 125 + app/views/vips/solutions/student_grade.php | 109 + .../vips/solutions/update_released_dialog.php | 42 + app/views/vips/solutions/view_solution.php | 77 + composer.json | 2 + db/migrations/6.0.40_add_vips_module.php | 485 ++++ lib/classes/SimpleORMap.php | 4 +- lib/classes/sidebar/VipsSearchWidget.php | 42 + lib/filesystem/ExerciseFolder.php | 111 + lib/filesystem/FeedbackFolder.php | 96 + lib/filesystem/ResponseFolder.php | 107 + .../Courseware/BlockTypes/TestBlock.php | 125 + lib/models/FileRef.php | 17 +- lib/models/Folder.php | 27 +- lib/models/vips/ClozeTask.php | 505 ++++ lib/models/vips/DummyExercise.php | 83 + lib/models/vips/Exercise.php | 855 ++++++ lib/models/vips/MatchingTask.php | 341 +++ lib/models/vips/MatrixChoiceTask.php | 268 ++ lib/models/vips/MultipleChoiceTask.php | 196 ++ lib/models/vips/SequenceTask.php | 255 ++ lib/models/vips/SingleChoiceTask.php | 279 ++ lib/models/vips/TextLineTask.php | 271 ++ lib/models/vips/TextTask.php | 279 ++ lib/models/vips/VipsAssignment.php | 1308 +++++++++ lib/models/vips/VipsAssignmentAttempt.php | 99 + lib/models/vips/VipsBlock.php | 92 + lib/models/vips/VipsExerciseRef.php | 137 + lib/models/vips/VipsGroup.php | 79 + lib/models/vips/VipsGroupMember.php | 50 + lib/models/vips/VipsSolution.php | 160 ++ lib/models/vips/VipsTest.php | 121 + lib/modules/VipsModule.php | 471 +++ public/assets/images/choice_checked.svg | 1 + public/assets/images/choice_unchecked.svg | 1 + public/assets/images/collapse.svg | 1 + public/assets/images/expand.svg | 1 + public/assets/images/icons/black/vips.svg | 1 + .../images/icons/blue/assessment-mc.svg | 1 + public/assets/images/icons/blue/edit-line.svg | 1 + public/assets/images/icons/blue/vips.svg | 1 + public/assets/images/icons/red/vips.svg | 1 + public/assets/images/icons/white/vips.svg | 1 + .../plus/screenshots/Vips/Vips_preview_1.png | Bin 0 -> 42033 bytes .../plus/screenshots/Vips/Vips_preview_2.png | Bin 0 -> 45908 bytes .../plus/screenshots/Vips/Vips_preview_3.png | Bin 0 -> 56688 bytes .../assets/javascripts/bootstrap/vips.js | 336 +++ resources/assets/javascripts/entry-base.js | 1 + resources/assets/javascripts/init.js | 2 + resources/assets/javascripts/lib/vips.js | 122 + .../assets/stylesheets/scss/buttons.scss | 2 +- .../scss/courseware/variables.scss | 1 + resources/assets/stylesheets/scss/forms.scss | 13 +- .../stylesheets/scss/jquery-ui/studip.scss | 42 + .../assets/stylesheets/scss/sidebar.scss | 2 +- resources/assets/stylesheets/scss/tables.scss | 15 +- resources/assets/stylesheets/scss/vips.scss | 592 ++++ resources/assets/stylesheets/studip.scss | 1 + .../courseware/blocks/CoursewareTestBlock.vue | 266 ++ .../containers/container-components.js | 2 + 167 files changed, 21392 insertions(+), 12 deletions(-) create mode 100644 app/controllers/vips/admin.php create mode 100644 app/controllers/vips/api.php create mode 100644 app/controllers/vips/config.php create mode 100644 app/controllers/vips/exam_mode.php create mode 100644 app/controllers/vips/pool.php create mode 100644 app/controllers/vips/sheets.php create mode 100644 app/controllers/vips/solutions.php create mode 100644 app/views/vips/admin/edit_block.php create mode 100644 app/views/vips/admin/edit_grades.php create mode 100644 app/views/vips/config/index.php create mode 100644 app/views/vips/config/pending_assignments.php create mode 100644 app/views/vips/exam_mode/index.php create mode 100644 app/views/vips/exercises/ClozeTask/correct.php create mode 100644 app/views/vips/exercises/ClozeTask/edit.php create mode 100644 app/views/vips/exercises/ClozeTask/print.php create mode 100644 app/views/vips/exercises/ClozeTask/solve.php create mode 100644 app/views/vips/exercises/ClozeTask/xml.php create mode 100644 app/views/vips/exercises/MatchingTask/correct.php create mode 100644 app/views/vips/exercises/MatchingTask/edit.php create mode 100644 app/views/vips/exercises/MatchingTask/print.php create mode 100644 app/views/vips/exercises/MatchingTask/solve.php create mode 100644 app/views/vips/exercises/MatchingTask/xml.php create mode 100644 app/views/vips/exercises/MatrixChoiceTask/correct.php create mode 100644 app/views/vips/exercises/MatrixChoiceTask/edit.php create mode 100644 app/views/vips/exercises/MatrixChoiceTask/print.php create mode 100644 app/views/vips/exercises/MatrixChoiceTask/solve.php create mode 100644 app/views/vips/exercises/MatrixChoiceTask/xml.php create mode 100644 app/views/vips/exercises/MultipleChoiceTask/correct.php create mode 100644 app/views/vips/exercises/MultipleChoiceTask/edit.php create mode 100644 app/views/vips/exercises/MultipleChoiceTask/print.php create mode 100644 app/views/vips/exercises/MultipleChoiceTask/solve.php create mode 100644 app/views/vips/exercises/MultipleChoiceTask/xml.php create mode 100644 app/views/vips/exercises/SequenceTask/correct.php create mode 100644 app/views/vips/exercises/SequenceTask/edit.php create mode 100644 app/views/vips/exercises/SequenceTask/print.php create mode 100644 app/views/vips/exercises/SequenceTask/solve.php create mode 100644 app/views/vips/exercises/SequenceTask/xml.php create mode 100644 app/views/vips/exercises/SingleChoiceTask/correct.php create mode 100644 app/views/vips/exercises/SingleChoiceTask/edit.php create mode 100644 app/views/vips/exercises/SingleChoiceTask/print.php create mode 100644 app/views/vips/exercises/SingleChoiceTask/solve.php create mode 100644 app/views/vips/exercises/SingleChoiceTask/xml.php create mode 100644 app/views/vips/exercises/TextLineTask/correct.php create mode 100644 app/views/vips/exercises/TextLineTask/edit.php create mode 100644 app/views/vips/exercises/TextLineTask/print.php create mode 100644 app/views/vips/exercises/TextLineTask/solve.php create mode 100644 app/views/vips/exercises/TextLineTask/xml.php create mode 100644 app/views/vips/exercises/TextTask/correct.php create mode 100644 app/views/vips/exercises/TextTask/edit.php create mode 100644 app/views/vips/exercises/TextTask/print.php create mode 100644 app/views/vips/exercises/TextTask/solve.php create mode 100644 app/views/vips/exercises/TextTask/xml.php create mode 100644 app/views/vips/exercises/correct_exercise.php create mode 100644 app/views/vips/exercises/courseware_block.php create mode 100644 app/views/vips/exercises/evaluation_mode_info.php create mode 100644 app/views/vips/exercises/flexible_input.php create mode 100644 app/views/vips/exercises/flexible_textarea.php create mode 100644 app/views/vips/exercises/print_exercise.php create mode 100644 app/views/vips/exercises/show_exercise_files.php create mode 100644 app/views/vips/exercises/show_exercise_hint.php create mode 100644 app/views/vips/pool/assignments.php create mode 100644 app/views/vips/pool/copy_exercises_dialog.php create mode 100644 app/views/vips/pool/exercises.php create mode 100644 app/views/vips/pool/list_assignments.php create mode 100644 app/views/vips/pool/list_exercises.php create mode 100644 app/views/vips/pool/move_exercises_dialog.php create mode 100644 app/views/vips/sheets/add_exercise_dialog.php create mode 100644 app/views/vips/sheets/assign_block_dialog.php create mode 100644 app/views/vips/sheets/assignment_type_tooltip.php create mode 100644 app/views/vips/sheets/content_bar_icons.php create mode 100644 app/views/vips/sheets/copy_assignment_dialog.php create mode 100644 app/views/vips/sheets/copy_assignments_dialog.php create mode 100644 app/views/vips/sheets/copy_exercise_dialog.php create mode 100644 app/views/vips/sheets/copy_exercises_dialog.php create mode 100644 app/views/vips/sheets/edit_assignment.php create mode 100644 app/views/vips/sheets/edit_exercise.php create mode 100644 app/views/vips/sheets/export_assignment.php create mode 100644 app/views/vips/sheets/import_assignment_dialog.php create mode 100644 app/views/vips/sheets/ip_range_tooltip.php create mode 100644 app/views/vips/sheets/list_assignments.php create mode 100644 app/views/vips/sheets/list_assignments_list.php create mode 100644 app/views/vips/sheets/list_assignments_stud.php create mode 100644 app/views/vips/sheets/list_exercises.php create mode 100644 app/views/vips/sheets/move_assignments_dialog.php create mode 100644 app/views/vips/sheets/move_exercises_dialog.php create mode 100644 app/views/vips/sheets/print_assignment.php create mode 100644 app/views/vips/sheets/print_assignments.php create mode 100644 app/views/vips/sheets/print_layout.php create mode 100644 app/views/vips/sheets/show_assignment.php create mode 100644 app/views/vips/sheets/show_exercise.php create mode 100644 app/views/vips/sheets/show_exercise_link.php create mode 100644 app/views/vips/sheets/start_assignment_dialog.php create mode 100644 app/views/vips/solutions/assignment_solutions.php create mode 100644 app/views/vips/solutions/assignments.php create mode 100644 app/views/vips/solutions/assignments_list.php create mode 100644 app/views/vips/solutions/assignments_list_student.php create mode 100644 app/views/vips/solutions/autocorrect_dialog.php create mode 100644 app/views/vips/solutions/edit_assignment_attempt.php create mode 100644 app/views/vips/solutions/edit_group_dialog.php create mode 100644 app/views/vips/solutions/edit_solution.php create mode 100644 app/views/vips/solutions/feedback_files.php create mode 100644 app/views/vips/solutions/feedback_files_table.php create mode 100644 app/views/vips/solutions/gradebook_dialog.php create mode 100644 app/views/vips/solutions/participants_overview.php create mode 100644 app/views/vips/solutions/show_assignment_log.php create mode 100644 app/views/vips/solutions/solution_color_tooltip.php create mode 100644 app/views/vips/solutions/statistics.php create mode 100644 app/views/vips/solutions/student_assignment_solutions.php create mode 100644 app/views/vips/solutions/student_grade.php create mode 100644 app/views/vips/solutions/update_released_dialog.php create mode 100644 app/views/vips/solutions/view_solution.php create mode 100644 db/migrations/6.0.40_add_vips_module.php create mode 100644 lib/classes/sidebar/VipsSearchWidget.php create mode 100644 lib/filesystem/ExerciseFolder.php create mode 100644 lib/filesystem/FeedbackFolder.php create mode 100644 lib/filesystem/ResponseFolder.php create mode 100644 lib/models/Courseware/BlockTypes/TestBlock.php create mode 100644 lib/models/vips/ClozeTask.php create mode 100644 lib/models/vips/DummyExercise.php create mode 100644 lib/models/vips/Exercise.php create mode 100644 lib/models/vips/MatchingTask.php create mode 100644 lib/models/vips/MatrixChoiceTask.php create mode 100644 lib/models/vips/MultipleChoiceTask.php create mode 100644 lib/models/vips/SequenceTask.php create mode 100644 lib/models/vips/SingleChoiceTask.php create mode 100644 lib/models/vips/TextLineTask.php create mode 100644 lib/models/vips/TextTask.php create mode 100644 lib/models/vips/VipsAssignment.php create mode 100644 lib/models/vips/VipsAssignmentAttempt.php create mode 100644 lib/models/vips/VipsBlock.php create mode 100644 lib/models/vips/VipsExerciseRef.php create mode 100644 lib/models/vips/VipsGroup.php create mode 100644 lib/models/vips/VipsGroupMember.php create mode 100644 lib/models/vips/VipsSolution.php create mode 100644 lib/models/vips/VipsTest.php create mode 100644 lib/modules/VipsModule.php create mode 100644 public/assets/images/choice_checked.svg create mode 100644 public/assets/images/choice_unchecked.svg create mode 100644 public/assets/images/collapse.svg create mode 100644 public/assets/images/expand.svg create mode 100644 public/assets/images/icons/black/vips.svg create mode 100644 public/assets/images/icons/blue/assessment-mc.svg create mode 100644 public/assets/images/icons/blue/edit-line.svg create mode 100644 public/assets/images/icons/blue/vips.svg create mode 100644 public/assets/images/icons/red/vips.svg create mode 100644 public/assets/images/icons/white/vips.svg create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_1.png create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_2.png create mode 100644 public/assets/images/plus/screenshots/Vips/Vips_preview_3.png create mode 100644 resources/assets/javascripts/bootstrap/vips.js create mode 100644 resources/assets/javascripts/lib/vips.js create mode 100644 resources/assets/stylesheets/scss/vips.scss create mode 100644 resources/vue/components/courseware/blocks/CoursewareTestBlock.vue diff --git a/app/controllers/vips/admin.php b/app/controllers/vips/admin.php new file mode 100644 index 00000000000..92c6cc7bc13 --- /dev/null +++ b/app/controllers/vips/admin.php @@ -0,0 +1,208 @@ +<?php +/** + * vips/admin.php - course administration controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_AdminController extends AuthenticatedController +{ + /** + * Edit or create a block in the course. + */ + public function edit_block_action() + { + Navigation::activateItem('/course/vips/sheets'); + PageLayout::setHelpKeyword('Basis.Vips'); + + $block_id = Request::int('block_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } else { + $block = new VipsBlock(); + $block->range_id = Context::getId(); + } + + VipsModule::requireStatus('tutor', $block->range_id); + + $this->block = $block; + $this->groups = Statusgruppen::findBySeminar_id($block->range_id); + } + + /** + * Store changes to a block. + */ + public function store_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $block_id = Request::int('block_id'); + $group_id = Request::option('group_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } else { + $block = new VipsBlock(); + $block->range_id = Context::getId(); + } + + VipsModule::requireStatus('tutor', $block->range_id); + + $block->name = Request::get('block_name'); + $block->group_id = $group_id ?: null; + $block->visible = $group_id !== ''; + + if (!Request::int('block_grouped')) { + $block->weight = null; + } else if ($block->weight === null) { + $block->weight = 0; + + if ($block_id) { + // sum up individual assignment weights for total block weight + foreach (VipsAssignment::findByBlock_id($block_id) as $assignment) { + $block->weight += $assignment->weight; + } + } + } + + $block->store(); + + PageLayout::postSuccess(sprintf(_('Der Block „%s“ wurde gespeichert.'), htmlReady($block->name))); + + $this->redirect($this->url_for('vips/sheets', ['group' => 1])); + } + + /** + * Delete a block from the course. + */ + public function delete_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $block_id = Request::int('block_id'); + $block = VipsBlock::find($block_id); + $block_name = $block->name; + + VipsModule::requireStatus('tutor', $block->range_id); + + if ($block->delete()) { + PageLayout::postSuccess(sprintf(_('Der Block „%s“ wurde gelöscht.'), htmlReady($block_name))); + } + + $this->redirect('vips/sheets'); + } + + /** + * Stores the weights of blocks, sheets and exams + */ + public function store_weight_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_weight = Request::floatArray('assignment_weight'); + $block_weight = Request::floatArray('block_weight'); + + foreach ($assignment_weight as $assignment_id => $weight) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $assignment->weight = $weight; + $assignment->store(); + } + + foreach ($block_weight as $block_id => $weight) { + $block = VipsBlock::find($block_id); + VipsModule::requireStatus('tutor', $block->range_id); + + $block->weight = $weight; + $block->store(); + } + + $this->redirect('vips/solutions'); + } + + /** + * Edit the grade distribution settings. + */ + public function edit_grades_action() + { + Navigation::activateItem('/course/vips/solutions'); + PageLayout::setHelpKeyword('Basis.VipsErgebnisse'); + + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $grades = ['1,0', '1,3', '1,7', '2,0', '2,3', '2,7', '3,0', '3,3', '3,7', '4,0']; + $percentages = array_fill(0, count($grades), ''); + $comments = array_fill(0, count($grades), ''); + $settings = CourseConfig::get($course_id); + + foreach ($settings->VIPS_COURSE_GRADES as $value) { + $index = array_search($value['grade'], $grades); + + if ($index !== false) { + $percentages[$index] = $value['percent']; + $comments[$index] = $value['comment']; + } + } + + $this->grades = $grades; + $this->grade_settings = $settings->VIPS_COURSE_GRADES; + $this->percentages = $percentages; + $this->comments = $comments; + } + + /** + * Stores the distribution of grades + */ + public function store_grades_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $grades = ['1,0', '1,3', '1,7', '2,0', '2,3', '2,7', '3,0', '3,3', '3,7', '4,0']; + $percentages = Request::floatArray('percentage'); + $comments = Request::getArray('comment'); + $grade_settings = []; + $percent_last = 101; + $error = false; + + foreach ($percentages as $i => $percent) { + if ($percent) { + $grade_settings[] = [ + 'grade' => $grades[$i], + 'percent' => $percent, + 'comment' => trim($comments[$i]) + ]; + + if ($percent < 0 || $percent > 100) { + PageLayout::postError(_('Die Notenwerte müssen zwischen 0 und 100 liegen!')); + $error = true; + } else if ($percent_last <= $percent) { + PageLayout::postError(sprintf(_('Die Notenwerte müssen monoton absteigen (%s > %s)!'), $percent_last, $percent)); + $error = true; + } + + $percent_last = $percent; + } + } + + if (!$error) { + $settings = CourseConfig::get($course_id); + $settings->store('VIPS_COURSE_GRADES', $grade_settings); + + PageLayout::postSuccess(_('Die Notenwerte wurden eingetragen.')); + } + + $this->redirect('vips/solutions'); + } +} diff --git a/app/controllers/vips/api.php b/app/controllers/vips/api.php new file mode 100644 index 00000000000..8c2dbe0c75a --- /dev/null +++ b/app/controllers/vips/api.php @@ -0,0 +1,256 @@ +<?php +/** + * vips/api.php - API controller for Vips + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ApiController extends AuthenticatedController +{ + public function assignments_action($range_id) + { + if ($range_id !== $GLOBALS['user']->id) { + VipsModule::requireStatus('tutor', $range_id); + } + + $assignments = VipsAssignment::findByRangeId($range_id); + + $data = []; + + foreach ($assignments as $assignment) { + if ($assignment->type !== 'exam') { + $data[] = [ + 'id' => (string) $assignment->id, + 'title' => $assignment->test->title, + 'type' => $assignment->type, + 'icon' => $assignment->getTypeIcon()->getShape(), + 'start' => date('d.m.Y, H:i', $assignment->start), + 'end' => date('d.m.Y, H:i', $assignment->end), + 'active' => $assignment->active, + 'block' => $assignment->block_id ? $assignment->block->name : null + ]; + } + } + + $this->render_json($data); + } + + public function assignment_action($assignment_id) + { + $assignment = VipsAssignment::find($assignment_id); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment); + + $released = $assignment->releaseStatus($user_id); + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ( + !$assignment->checkAccess($user_id) + && $released < VipsAssignment::RELEASE_STATUS_CORRECTIONS + ) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + $data = [ + 'id' => (string) $assignment->id, + 'title' => $assignment->test->title, + 'type' => $assignment->type, + 'icon' => $assignment->getTypeIcon()->getShape(), + 'start' => date('d.m.Y, H:i', $assignment->start), + 'end' => date('d.m.Y, H:i', $assignment->end), + 'active' => $assignment->active, + 'block' => $assignment->block_id ? $assignment->block->name : null, + 'reset_allowed' => $assignment->isRunning($user_id) && $assignment->isResetAllowed(), + 'points' => $assignment->test->getTotalPoints(), + 'release_status' => $released, + 'exercises' => [] + ]; + + foreach ($assignment->getExerciseRefs($user_id) as $exercise_ref) { + $template = $this->courseware_template($assignment, $exercise_ref, $released); + $exercise = $exercise_ref->exercise; + + $data['exercises'][] = [ + 'id' => $exercise->id, + 'type' => $exercise->type, + 'title' => $exercise->title, + 'template' => $template->render(), + 'item_count' => $exercise->itemCount(), + 'show_solution' => $template->show_solution + ]; + } + + $this->render_json($data); + } + + public function exercise_action($assignment_id, $exercise_id) + { + $assignment = VipsAssignment::find($assignment_id); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment, $exercise_id); + + $released = $assignment->releaseStatus($user_id); + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if ( + !$assignment->checkAccess($user_id) + && $released < VipsAssignment::RELEASE_STATUS_CORRECTIONS + ) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $template = $this->courseware_template($assignment, $exercise_ref, $released); + $exercise = $exercise_ref->exercise; + + $data = [ + 'id' => $exercise->id, + 'type' => $exercise->type, + 'title' => $exercise->title, + 'template' => $template->render(), + 'item_count' => $exercise->itemCount(), + 'show_solution' => $template->show_solution + ]; + + $this->render_json($data); + } + + private function courseware_template($assignment, $exercise_ref, $released) + { + $user_id = $GLOBALS['user']->id; + $exercise = $exercise_ref->exercise; + $solution = $assignment->getSolution($user_id, $exercise->id); + $max_tries = $assignment->getMaxTries(); + $max_points = $exercise_ref->points; + $sample_solution = false; + $show_solution = false; + $tries_left = null; + + if ($assignment->isRunning($user_id)) { + // if a solution has been submitted during a selftest + if ($max_tries && $solution) { + $tries_left = $max_tries - $solution->countTries(); + + if ( + $solution->points == $max_points + || !$solution->state + || $solution->grader_id + || $tries_left <= 0 + ) { + $show_solution = true; + $sample_solution = true; + } + } + } else { + $show_solution = true; + $sample_solution = $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS; + + if (!$solution) { + $solution = new VipsSolution(); + $solution->assignment = $assignment; + } + } + + $template = $this->get_template_factory()->open('vips/exercises/courseware_block'); + $template->user_id = $user_id; + $template->assignment = $assignment; + $template->exercise = $exercise; + $template->tries_left = $tries_left; + $template->solution = $solution; + $template->max_points = $max_points; + $template->sample_solution = $sample_solution; + $template->show_solution = $show_solution; + + return $template; + } + + public function solution_action($assignment_id, $exercise_id) + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment = VipsAssignment::find($assignment_id); + $block_id = Request::int('block_id'); + $user_id = $GLOBALS['user']->id; + + VipsModule::requireViewPermission($assignment, $exercise_id); + + // check access to courseware block + if ($block_id) { + $block = Courseware\Block::find($block_id); + $payload = $block->type->getPayload(); + + if ($payload['assignment'] != $assignment_id) { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diesen Block!')); + } + } + + if ($assignment->type === 'exam') { + throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!')); + } + + if (!$assignment->checkAccess($user_id)) { + throw new AccessDeniedException(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } + + // enter user start time the moment he/she first clicks on any exercise + if (!$assignment->checkEditPermission()) { + $assignment->recordAssignmentAttempt($user_id); + } + + if (Request::isPost()) { + $request = Request::getInstance(); + $exercise = Exercise::find($exercise_id); + $solution = $exercise->getSolutionFromRequest($request, $_FILES); + $solution->user_id = $user_id; + + if ($solution->isEmpty()) { + $this->set_status(422); + } else { + $assignment->storeSolution($solution); + $this->set_status(201); + } + } + + if (Request::isDelete()) { + if ($assignment->isResetAllowed()) { + $assignment->deleteSolution($user_id, $exercise_id); + $this->set_status(204); + } else { + $this->set_status(403); + } + } + + // update user progress in Courseware + if ($block_id) { + $progress = new Courseware\UserProgress([$user_id, $block_id]); + $progress->grade = $assignment->getUserProgress($user_id); + $progress->store(); + } + + $this->render_nothing(); + } +} diff --git a/app/controllers/vips/config.php b/app/controllers/vips/config.php new file mode 100644 index 00000000000..d6d4e4857db --- /dev/null +++ b/app/controllers/vips/config.php @@ -0,0 +1,95 @@ +<?php +/** + * vips/config.php - global configuration controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ConfigController extends AuthenticatedController +{ + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $GLOBALS['perm']->check('root'); + + Navigation::activateItem('/admin/config/vips'); + PageLayout::setHelpKeyword('Basis.VipsEinstellungen'); + PageLayout::setTitle(_('Einstellungen für Aufgaben')); + } + + public function index_action() + { + $this->fields = DataField::getDataFields('user'); + $this->config = Config::get(); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Anstehende Klausuren anzeigen'), + $this->pending_assignmentsURL(), + Icon::create('doctoral_cap') + )->asDialog('size=big'); + Sidebar::get()->addWidget($widget); + } + + public function save_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exam_mode = Request::int('exam_mode', 0); + $exam_terms = trim(Request::get('exam_terms')); + $exam_terms = Studip\Markup::purifyHtml($exam_terms); + + $config = Config::get(); + $config->store('VIPS_EXAM_RESTRICTIONS', $exam_mode); + $config->store('VIPS_EXAM_TERMS', $exam_terms); + + $room = Request::getArray('room'); + $ip_range = Request::getArray('ip_range'); + $ip_ranges = []; + + foreach ($room as $i => $name) { + $name = preg_replace('/[ ,]+/', '_', trim($name)); + + if ($name !== '') { + $ip_ranges[$name] = trim($ip_range[$i]); + } + } + + if ($ip_ranges) { + ksort($ip_ranges); + $config->store('VIPS_EXAM_ROOMS', $ip_ranges); + } + + PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.')); + + $this->redirect('vips/config'); + } + + public function pending_assignments_action() + { + $this->assignments = VipsAssignment::findBySQL( + "range_type = 'course' AND type = 'exam' AND + start BETWEEN UNIX_TIMESTAMP(NOW() - INTERVAL 1 DAY) AND UNIX_TIMESTAMP(NOW() + INTERVAL 14 DAY) AND end > UNIX_TIMESTAMP() + ORDER BY start" + ); + } +} diff --git a/app/controllers/vips/exam_mode.php b/app/controllers/vips/exam_mode.php new file mode 100644 index 00000000000..914a0e077da --- /dev/null +++ b/app/controllers/vips/exam_mode.php @@ -0,0 +1,29 @@ +<?php +/** + * vips/exam_mode.php - restricted exam mode controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_ExamModeController extends AuthenticatedController +{ + /** + * Display a list of courses with currently active tests of type 'exam'. + * Only used when there are multiple courses with running exams. + */ + public function index_action() + { + PageLayout::setTitle(_('Klausurübersicht')); + + Helpbar::get()->addPlainText('', + _('Der normale Betrieb von Stud.IP ist für Sie zur Zeit gesperrt, da Klausuren geschrieben werden.')); + + $this->courses = VipsModule::getCoursesWithRunningExams($GLOBALS['user']->id); + } +} diff --git a/app/controllers/vips/pool.php b/app/controllers/vips/pool.php new file mode 100644 index 00000000000..bcbe302fdec --- /dev/null +++ b/app/controllers/vips/pool.php @@ -0,0 +1,473 @@ +<?php +/** + * vips/pool.php - assignment pool controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_PoolController extends AuthenticatedController +{ + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + PageLayout::setHelpKeyword('Basis.Vips'); + } + + /** + * Display all exercises that are available for this user. + * Available in this case means the exercise is in a course where the user + * is at least tutor. + * Lecturer/tutor can select which exercise to edit/assign/delete. + */ + public function exercises_action() + { + Navigation::activateItem('/contents/vips/exercises'); + PageLayout::setTitle(_('Meine Aufgaben')); + + Helpbar::get()->addPlainText('', + _('Auf dieser Seite finden Sie eine Übersicht über alle Aufgaben, auf die Sie Zugriff haben.')); + + $range_type = $_SESSION['view_context'] ?? 'user'; + $range_type = Request::option('range_type', $range_type); + $_SESSION['view_context'] = $range_type; + + $widget = new ViewsWidget(); + $widget->addLink( + _('Persönliche Aufgabensammlung'), + $this->url_for('vips/pool/exercises', ['range_type' => 'user']) + )->setActive($range_type === 'user'); + $widget->addLink( + _('Aufgaben in Veranstaltungen'), + $this->url_for('vips/pool/exercises', ['range_type' => 'course']) + )->setActive($range_type === 'course'); + Sidebar::get()->addWidget($widget); + + $sort = Request::option('sort', 'mkdate'); + $desc = Request::int('desc', $sort === 'mkdate'); + $page = Request::int('page', 1); + $size = Config::get()->ENTRIES_PER_PAGE; + + $search_filter = Request::getArray('search_filter') + ['search_string' => '', 'exercise_type' => '']; + $search_filter['search_string'] = Request::get('pool_search_parameter', $search_filter['search_string']); + $search_filter['exercise_type'] = Request::get('exercise_type', $search_filter['exercise_type']); + + if (Request::submitted('start_search') || Request::int('pool_search')) { + $search_filter = [ + 'search_string' => Request::get('pool_search_parameter'), + 'exercise_type' => Request::get('exercise_type') + ]; + } else if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'exercise_type'], ''); + } + + // get exercises of this user and where he/she has permission + if ($range_type === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + // set up the sql query for the quicksearch + $sql = "SELECT etask_tasks.id, etask_tasks.title FROM etask_tasks + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_assignments USING (test_id) + WHERE etask_assignments.range_id IN ('" . implode("','", $course_ids) . "') + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND (etask_tasks.title LIKE :input OR etask_tasks.description LIKE :input) + AND IF(:exercise_type = '', 1, etask_tasks.type = :exercise_type) + ORDER BY title"; + $search = new SQLSearch($sql, _('Titel der Aufgabe')); + + $widget = new VipsSearchWidget($this->url_for('vips/pool/exercises', ['exercise_type' => $search_filter['exercise_type']])); + $widget->addNeedle(_('Suche'), 'pool_search', true, $search, 'function(id, name) { this.value = name; this.form.submit(); }', $search_filter['search_string']); + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Aufgabentyp'), $this->url_for('vips/pool/exercises', ['pool_search_parameter' => $search_filter['search_string']]), 'exercise_type'); + $element = new SelectElement('', _('Alle Aufgabentypen')); + $widget->addElement($element); + Sidebar::get()->addWidget($widget); + + foreach (Exercise::getExerciseTypes() as $type => $entry) { + $element = new SelectElement($type, $entry['name'], $type === $search_filter['exercise_type']); + $widget->addElement($element); + } + + $result = $this->getAllExercises($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->count = count($result); + $this->exercises = array_slice($result, $size * ($page - 1), $size); + $this->search_filter = $search_filter; + } + + /** + * Display all assignments that are available for this user. + * Available in this case means the assignment is in a course where the user + * is at least tutor. + * Lecturer/tutor can select which assignment to edit/delete. + */ + public function assignments_action() + { + Navigation::activateItem('/contents/vips/assignments'); + PageLayout::setTitle(_('Meine Aufgabenblätter')); + + Helpbar::get()->addPlainText('', + _('Auf dieser Seite finden Sie eine Übersicht über alle Aufgabenblätter, auf die Sie Zugriff haben.')); + + $range_type = $_SESSION['view_context'] ?? 'user'; + $range_type = Request::option('range_type', $range_type); + $_SESSION['view_context'] = $range_type; + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt erstellen'), + $this->url_for('vips/sheets/edit_assignment'), + Icon::create('add') + ); + $widget->addLink( + _('Aufgabenblatt kopieren'), + $this->url_for('vips/sheets/copy_assignment_dialog'), + Icon::create('copy') + )->asDialog('size=1200x800'); + $widget->addLink( + _('Aufgabenblatt importieren'), + $this->url_for('vips/sheets/import_assignment_dialog'), + Icon::create('import') + )->asDialog('size=auto'); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Persönliche Aufgabensammlung'), + $this->url_for('vips/pool/assignments', ['range_type' => 'user']) + )->setActive($range_type === 'user'); + $widget->addLink( + _('Aufgaben in Veranstaltungen'), + $this->url_for('vips/pool/assignments', ['range_type' => 'course']) + )->setActive($range_type === 'course'); + Sidebar::get()->addWidget($widget); + + $sort = Request::option('sort', 'mkdate'); + $desc = Request::int('desc', $sort === 'mkdate'); + $page = Request::int('page', 1); + $size = Config::get()->ENTRIES_PER_PAGE; + + $search_filter = Request::getArray('search_filter') + ['search_string' => '', 'assignment_type' => '']; + $search_filter['search_string'] = Request::get('pool_search_parameter', $search_filter['search_string']); + $search_filter['assignment_type'] = Request::get('assignment_type', $search_filter['assignment_type']); + + // get assignments of this user and where he/she has permission + if ($range_type === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + // set up the sql query for the quicksearch + $sql = "SELECT etask_assignments.id, etask_tests.title FROM etask_tests + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + WHERE etask_assignments.range_id IN ('" . implode("','", $course_ids) . "') + AND etask_assignments.type IN ('exam', 'practice', 'selftest') + AND (etask_tests.title LIKE :input OR etask_tests.description LIKE :input) + AND IF(:assignment_type = '', 1, etask_assignments.type = :assignment_type) + ORDER BY title"; + $search = new SQLSearch($sql, _('Titel des Aufgabenblatts')); + + $widget = new VipsSearchWidget($this->url_for('vips/pool/assignments', ['assignment_type' => $search_filter['assignment_type']])); + $widget->addNeedle(_('Suche'), 'pool_search', true, $search, 'function(id, name) { this.value = name; this.form.submit(); }', $search_filter['search_string']); + Sidebar::get()->addWidget($widget); + + $widget = new SelectWidget(_('Modus'), $this->url_for('vips/pool/assignments', ['pool_search_parameter' => $search_filter['search_string']]), 'assignment_type'); + $element = new SelectElement('', _('Beliebiger Modus')); + $widget->addElement($element); + Sidebar::get()->addWidget($widget); + + foreach (VipsAssignment::getAssignmentTypes() as $type => $entry) { + $element = new SelectElement($type, $entry['name'], $type === $search_filter['assignment_type']); + $widget->addElement($element); + } + + $result = $this->getAllAssignments($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->count = count($result); + $this->assignments = array_slice($result, $size * ($page - 1), $size); + $this->search_filter = $search_filter; + } + + /** + * Get all matching exercises from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get exercises from + * @param sort sort exercise list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching exercises + */ + public function getAllExercises($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $exercise_type = $search_filter['exercise_type']; + + $sql = "SELECT etask_tasks.*, + auth_user_md5.Nachname, + auth_user_md5.Vorname, + etask_assignments.id AS assignment_id, + etask_assignments.range_id, + etask_assignments.range_type, + etask_tests.title AS test_title + FROM etask_tasks + LEFT JOIN auth_user_md5 USING(user_id) + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_tests ON etask_tests.id = etask_test_tasks.test_id + JOIN etask_assignments USING (test_id) + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN ('exam', 'practice', 'selftest') " . + ($search_string ? 'AND (etask_tasks.title LIKE :input OR + etask_tasks.description LIKE :input) ' : '') . + ($exercise_type ? 'AND etask_tasks.type = :exercise_type ' : '') . + "ORDER BY :sort :desc, title"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':exercise_type', $exercise_type); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Get all matching assignments from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get assignments from + * @param sort sort assignment list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching assignments + */ + public function getAllAssignments($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $assignment_type = $search_filter['assignment_type']; + $types = $assignment_type ? [$assignment_type] : ['exam', 'practice', 'selftest']; + + $sql = "SELECT etask_assignments.*, + etask_tests.title AS test_title, + auth_user_md5.Nachname, + auth_user_md5.Vorname, + seminare.Name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tests + LEFT JOIN auth_user_md5 USING(user_id) + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + LEFT JOIN seminare ON etask_assignments.range_id = seminare.Seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN (:types) " . + ($search_string ? 'AND (etask_tests.title LIKE :input OR + etask_tests.description LIKE :input) ' : '') . + "ORDER BY :sort :desc, title"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':types', $types); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Delete a list of exercises from their respective assignments. + */ + public function delete_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $deleted = 0; + + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment, $exercise_id); + + if (!$assignment->isLocked()) { + $assignment->test->removeExercise($exercise_id); + ++$deleted; + } + } + + if ($deleted > 0) { + PageLayout::postSuccess(sprintf(ngettext('Die Aufgabe wurde gelöscht.', 'Es wurden %s Aufgaben gelöscht.', $deleted), $deleted)); + } + + if ($deleted < count($exercise_ids)) { + PageLayout::postError(_('Einige Aufgaben konnten nicht gelöscht werden, da die Aufgabenblätter gesperrt sind.'), [ + _('Falls Sie diese wirklich löschen möchten, müssen Sie zuerst die Lösungen aller Teilnehmenden zurücksetzen.') + ]); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Dialog for copying a list of exercises into an existing assignment. + */ + public function copy_exercises_dialog_action() + { + PageLayout::setTitle(_('Aufgaben in vorhandenes Aufgabenblatt kopieren')); + + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Copy the selected exercises into the selected assignment. + */ + public function copy_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $target_assignment_id = Request::int('assignment_id'); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->copyIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Dialog for moving a list of exercises into an existing assignment. + */ + public function move_exercises_dialog_action() + { + PageLayout::setTitle(_('Aufgaben in vorhandenes Aufgabenblatt verschieben')); + + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Move the selected exercises into the selected assignment. + */ + public function move_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $target_assignment_id = Request::int('assignment_id'); + $target_assignment = VipsAssignment::find($target_assignment_id); + $moved = 0; + + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->moveIntoTest($target_assignment->test_id); + ++$moved; + } + } + } + + if ($moved > 0) { + PageLayout::postSuccess(sprintf(ngettext('Die Aufgabe wurde verschoben.', 'Es wurden %s Aufgaben verschoben.', $moved), $moved)); + } + + if ($moved < count($exercise_ids)) { + PageLayout::postError(_('Einige Aufgaben konnten nicht verschoben werden, da die Aufgabenblätter gesperrt sind.')); + } + + $this->redirect('vips/pool/exercises'); + } + + /** + * Return the appropriate CSS class for sortable column (if any). + * + * @param boolean $sort sort by this column + * @param boolean $desc set sort direction + */ + public function sort_class(bool $sort, ?bool $desc): string + { + return $sort ? ($desc ? 'sortdesc' : 'sortasc') : ''; + } + + /** + * Render a generic page chooser selector. The first occurence of '%d' + * in the URL is replaced with the selected page number. + * + * @param string $url URL for one of the pages + * @param string $count total number of entries + * @param string $page current page to display + * @param string|null $dialog Optional dialog attribute content + * @param int|null $page_size page size (defaults to system default) + * @return mixed + */ + function page_chooser(string $url, string $count, string $page, ?string $dialog = null, ?int $page_size = null) + { + $template = $GLOBALS['template_factory']->open('shared/pagechooser'); + $template->dialog = $dialog; + $template->num_postings = $count; + $template->page = $page; + $template->perPage = $page_size ?: Config::get()->ENTRIES_PER_PAGE; + $template->pagelink = str_replace('%%25d', '%d', str_replace('%', '%%', $url)); + + return $template->render(); + } +} diff --git a/app/controllers/vips/sheets.php b/app/controllers/vips/sheets.php new file mode 100644 index 00000000000..036ff10a779 --- /dev/null +++ b/app/controllers/vips/sheets.php @@ -0,0 +1,2305 @@ +<?php +/** + * vips/sheets.php - course assignments controller + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + */ + +class Vips_SheetsController extends AuthenticatedController +{ + /** + * Return the default action and arguments + * + * @return an array containing the action, an array of args and the format + */ + public function default_action_and_args() + { + return ['list_assignments', [], null]; + } + + /** + * Callback function being called before an action is executed. If this + * function does not return FALSE, the action will be called, otherwise + * an error will be generated and processing will be aborted. If this function + * already #rendered or #redirected, further processing of the action is + * withheld. + * + * @param string Name of the action to perform. + * @param array An array of arguments to the action. + * + * @return bool|void + */ + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + $course_id = Context::getId(); + + if ($action === 'list_assignments' && !VipsModule::hasStatus('tutor', $course_id)) { + $action = 'list_assignments_stud'; + } + + if ($action !== 'relay') { + if (Context::getId()) { + Navigation::activateItem('/course/vips/sheets'); + } else { + Navigation::activateItem('/contents/vips/assignments'); + PageLayout::setTitle(_('Meine Aufgabenblätter')); + } + PageLayout::setHelpKeyword('Basis.Vips'); + } + } + + ##################################### + # # + # Student Methods # + # # + ##################################### + + /** + * Restores an archived solution as the current solution. + */ + public function restore_solution_action() + { + // CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + $solutions = $assignment->getArchivedUserSolutions($solver_id, $exercise_id); + + if ($assignment->checkAccess($solver_id) || $assignment->checkEditPermission()) { + if ($assignment->type === 'exam' && $solutions) { + $assignment->restoreSolution($solutions[0]); + PageLayout::postSuccess(_('Die vorherige Lösung wurde wiederhergestellt.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * Only possible if test is selftest: Delete the solution of a student for + * a particular exercise. + */ + public function delete_solution_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + if ($assignment->checkAccess($solver_id) || $assignment->checkEditPermission()) { + if ($assignment->isResetAllowed() || $assignment->type === 'exam') { + $assignment->deleteSolution($solver_id, $exercise_id); + $undo_link = ''; + + if ($assignment->type === 'exam' && !$assignment->isSelfAssessment()) { + $undo_link = sprintf(' <a href="%s">%s</a>', + $this->link_for('vips/sheets/restore_solution', compact('assignment_id', 'exercise_id', 'solver_id')), + _('Diese Aktion zurücknehmen.')); + } + + PageLayout::postSuccess(_('Die Lösung wurde gelöscht.') . $undo_link); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * Only possible if test is selftest: Deletes all the solutions of a student or + * the student's group to enable him/her to redo it. + */ + public function delete_solutions_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + + VipsModule::requireViewPermission($assignment); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + if ($assignment->isRunning() || $assignment->checkEditPermission()) { + if ($assignment->isResetAllowed()) { + $assignment->deleteSolutions($solver_id); + PageLayout::postSuccess(_('Die Lösungen wurden gelöscht.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id', 'solver_id'))); + } + + /** + * Only possible if test is exam: Begin working on the exam. + */ + public function begin_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $terms_accepted = Request::int('terms_accepted'); + $access_code = Request::get('access_code'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $ip_address = $_SERVER['REMOTE_ADDR']; + + VipsModule::requireViewPermission($assignment); + + if ($assignment->type === 'exam') { + if (!$assignment->getAssignmentAttempt($GLOBALS['user']->id)) { + $exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + if (!$assignment->isRunning() || !$assignment->active) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + } else if (!$assignment->checkIPAccess($ip_address)) { + PageLayout::postError(sprintf(_('Sie haben mit Ihrer IP-Adresse „%s“ keinen Zugriff!'), htmlReady($ip_address))); + } else if ($exam_terms && !$terms_accepted) { + PageLayout::postError(_('Ein Start der Klausur ist nur mit Bestätigung der Teilnahmebedingungen möglich.')); + } else if (!$assignment->checkAccessCode($access_code)) { + PageLayout::postError(_('Der eingegebene Zugangscode ist nicht korrekt.')); + } else { + $assignment->recordAssignmentAttempt($GLOBALS['user']->id); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id', 'access_code'))); + } + + /** + * Only possible if test is exam: Immediately finish working on the exam. + */ + public function finish_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment); + + if ($assignment->checkAccess($GLOBALS['user']->id)) { + if ($assignment->finishAssignmentAttempt($GLOBALS['user']->id)) { + PageLayout::postSuccess(_('Das Aufgabenblatt wurde abgeschlossen, eine weitere Bearbeitung ist nicht mehr möglich.')); + } else { + PageLayout::postError(_('Eine Abgabe ist erst nach Start des Aufgabenblatts möglich.')); + } + } + + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id'))); + } + + /** + * SHEETS/EXAMS + * + * Is called when the submit button at the bottom of an exercise is called. + * If there is already a solution of this exercise by the same user or same group, + * a dialog pops up to confirm the submission. On database-level: EVERY solution is stored + * (even the unconfirmed ones), with the last solution being marked as last. + */ + public function submit_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment, $exercise_id); + + ################################################################## + # in case student solution is submitted by tutor or lecturer # + # (can happen if the student submits his/her solution by email) # + ################################################################## + + $solver_id = Request::option('solver_id'); + + if ($solver_id == '' || !$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + ############################ + # Checks before submission # + ############################ + + if (!$assignment->checkEditPermission()) { + $end = $assignment->getUserEndTime($solver_id); + + // not yet started + if (!$assignment->isStarted()) { + PageLayout::postError(_('Das Aufgabenblatt wurde noch nicht gestartet.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + // already ended + if ($end && time() - $end > 120) { + PageLayout::postError(_('Das Aufgabenblatt wurde bereits beendet.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if (!$assignment->checkIPAccess($_SERVER['REMOTE_ADDR']) || !$assignment->checkAccessCode()) { + PageLayout::postError(_('Kein Zugriff möglich!')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + $assignment->recordAssignmentAttempt($solver_id); + } + + /* if an exercise has been submitted */ + if (Request::submitted('submit_exercise') || Request::int('forced')) { + $request = Request::getInstance(); + $exercise = Exercise::find($exercise_id); + $solution = $exercise->getSolutionFromRequest($request, $_FILES); + $solution->user_id = $solver_id; + + if ($solution->isEmpty()) { + PageLayout::postWarning(_('Ihre Lösung ist leer und wurde nicht gespeichert.')); + } else { + $assignment->storeSolution($solution); + + PageLayout::postSuccess(sprintf(_('Ihre Lösung zur Aufgabe „%s“ wurde gespeichert.'), htmlReady($exercise->title))); + } + } + + $this->redirect($this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id', 'solver_id'))); + } + + /** + * SHEETS/EXAMS + * + * Displays an exercise (from student perspective) + */ + public function show_exercise_action() + { + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id'); // solver is handed over via address line, ie. user is a lecturer + + VipsModule::requireViewPermission($assignment, $exercise_id); + + if ($solver_id == '' || !$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + ############################################################## + # check for ip_address, remaining time and interrupted # + ############################################################## + + // restrict access for students! + if (!$assignment->checkEditPermission()) { + // the assignment is not accessible any more after it has run out + if (!$assignment->checkAccess()) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if ($assignment->isFinished($solver_id)) { + PageLayout::postError(_('Die Zeit ist leider abgelaufen!')); + $this->redirect($this->url_for('vips/sheets/show_assignment', compact('assignment_id'))); + return; + } + + // enter user start time the moment he/she first clicks on any exercise + $assignment->recordAssignmentAttempt($solver_id); + } + + // fetch exercise info, type, points + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise = $exercise_ref->exercise; + + ################################### + # get user solution if applicable # + ################################### + + $solution = $assignment->getSolution($solver_id, $exercise_id); + $max_tries = $assignment->getMaxTries(); + $max_points = $exercise_ref->points; + $exercise_position = $exercise_ref->position; + $show_solution = false; + $tries_left = null; + + // if a solution has been submitted during a selftest + if ($max_tries && $solution) { + $tries_left = $max_tries - $solution->countTries(); + + if ($solution->points == $max_points || !$solution->state || $solution->grader_id || $tries_left <= 0) { + $show_solution = true; + } + } + + ############################## + # set template variables # + ############################## + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->exercise = $exercise; + $this->exercise_id = $exercise_id; + $this->exercise_position = $exercise_position; + + $this->solver_id = $solver_id; + $this->solution = $solution; // can be empty + $this->max_points = $max_points; + $this->show_solution = $show_solution; + $this->tries_left = $tries_left; + $this->user_end_time = $assignment->getUserEndTime($solver_id); + $this->remaining_time = $this->user_end_time - time(); + + $this->contentbar = $this->create_contentbar($assignment, $exercise_id, 'show'); + + $widget = new ActionsWidget(); + + if (($assignment->isResetAllowed() || $assignment->type === 'exam') && $solution) { + $widget->addLink( + _('Lösung dieser Aufgabe löschen'), + $this->url_for('vips/sheets/delete_solution', compact('assignment_id', 'exercise_id', 'solver_id')), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösung dieser Aufgabe wirklich löschen?')] + )->asButton(); + } + + Sidebar::get()->addWidget($widget); + + if ($assignment->checkEditPermission()) { + Helpbar::get()->addPlainText('', + _('Dies ist die Studierendenansicht (Vorschau) der Aufgabe. Sie können hier auch Lösungen von Teilnehmenden ansehen oder für sie abgeben.')); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabe bearbeiten'), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id]) + ); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id]) + )->setActive(); + Sidebar::get()->addWidget($widget); + + if ($assignment->range_type === 'course') { + $widget = new SelectWidget(_('Anzeigen für'), $this->url_for('vips/sheets/show_exercise', compact('assignment_id', 'exercise_id')), 'solver_id'); + $widget->class = 'nested-select'; + $element = new SelectElement($GLOBALS['user']->id, ' ', $GLOBALS['user']->id == $solver_id); + $widget->addElement($element); + + foreach ($assignment->course->members->findBy('status', 'autor')->orderBy('nachname, vorname') as $member) { + if ($assignment->isVisible($member->user_id)) { + $element = new SelectElement($member->user_id, $member->nachname . ', ' . $member->vorname, $member->user_id == $solver_id); + $widget->addElement($element); + } + } + Sidebar::get()->addWidget($widget); + } + } else { + Helpbar::get()->addPlainText('', + _('Bitte denken Sie daran, vor dem Verlassen der Seite Ihre Lösung zu speichern.')); + } + + $widget = new ViewsWidget(); + $widget->setTitle(_('Aufgabenblatt')); + + setlocale(LC_NUMERIC, $_SESSION['_language'] . '.UTF-8'); + + foreach ($assignment->getExerciseRefs($solver_id) as $i => $item) { + $this->item = $item; + $this->position = $i + 1; + $element = new WidgetElement($this->render_template_as_string('vips/sheets/show_exercise_link')); + $element->active = $item->task_id === $exercise->id; + $widget->addElement($element, 'exercise-' . $item->task_id); + } + + setlocale(LC_NUMERIC, 'C'); + + Sidebar::get()->addWidget($widget); + } + + /** + * Displays all running assignments "work-on ready" for students (view of + * students when clicking on tab Uebungsblatt), respectively student view + * for lecturers and tutors. + */ + public function list_assignments_stud_action() + { + $course_id = Context::getId(); + $sort = Request::option('sort', 'start'); + $desc = Request::int('desc'); + VipsModule::requireStatus('autor', $course_id); + + $this->sort = $sort; + $this->desc = $desc; + $this->assignments = []; + + $assignments = VipsAssignment::findByRangeId($course_id); + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + $blocks[] = VipsBlock::build(['name' => _('Aufgabenblätter')]); + $ip_address = $_SERVER['REMOTE_ADDR']; + + usort($assignments, function($a, $b) use ($sort) { + if ($sort === 'title') { + return strcoll($a->test->title, $b->test->title); + } else if ($sort === 'type') { + return strcmp($a->type, $b->type); + } else if ($sort === 'start') { + return strcmp($a->start, $b->start); + } else { + return strcmp($a->end ?: '~', $b->end ?: '~'); + } + }); + + if ($desc) { + $assignments = array_reverse($assignments); + } + + foreach ($blocks as $block) { + $this->blocks[$block->id]['title'] = $block->name; + } + + foreach ($assignments as $assignment) { + if ($assignment->isRunning() && $assignment->isVisible($GLOBALS['user']->id)) { + if ($assignment->checkIPAccess($ip_address)) { + if (isset($assignment->block->group_id)) { + $this->blocks['']['assignments'][] = $assignment; + } else { + $this->blocks[$assignment->block_id]['assignments'][] = $assignment; + } + } + } + } + + // delete empty blocks + foreach ($blocks as $block) { + if (empty($this->blocks[$block->id]['assignments'])) { + unset($this->blocks[$block->id]); + } + } + + $this->user_id = $GLOBALS['user']->id; + } + + /** + * Display one assignment to the student, including the list of exercises. + */ + public function show_assignment_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $solver_id = Request::option('solver_id', $GLOBALS['user']->id); + $ip_address = $_SERVER['REMOTE_ADDR']; + + VipsModule::requireViewPermission($assignment); + + if (!$assignment->checkEditPermission()) { + $solver_id = $GLOBALS['user']->id; + } + + $this->solver_id = $solver_id; + $this->user_end_time = $assignment->getUserEndTime($solver_id); + $this->remaining_time = $this->user_end_time - time(); + $this->access_code = trim(Request::get('access_code')); + $this->assignment = $assignment; + $this->needs_code = false; + $this->exam_terms = null; + $this->preview_exam_terms = null; + + $this->contentbar = $this->create_contentbar($assignment, null, 'show'); + + if (!$assignment->checkEditPermission()) { + if (!$assignment->isRunning() || !$assignment->active) { + PageLayout::postError(_('Das Aufgabenblatt kann zur Zeit nicht bearbeitet werden.')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + if (!$assignment->checkIPAccess($ip_address)) { + PageLayout::postError(sprintf(_('Sie haben mit Ihrer IP-Adresse „%s“ keinen Zugriff!'), htmlReady($ip_address))); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + + $this->assignment_attempt = $assignment->getAssignmentAttempt($solver_id); + + if ($assignment->type === 'exam') { + if (!$assignment->checkAccessCode()) { + $this->needs_code = true; + } + + if (!$this->assignment_attempt) { + $this->exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + if ($this->exam_terms || $this->needs_code) { + $this->contentbar = $this->contentbar->withProps(['toc' => null]); + } + } + + $widget = new ActionsWidget(); + + if ($assignment->type !== 'exam') { + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'print_files' => 1]), + Icon::create('print'), + ['target' => '_blank'] + ); + } + if ($assignment->isResetAllowed()) { + $widget->addLink( + _('Lösungen dieses Blatts löschen'), + $this->url_for('vips/sheets/delete_solutions', ['assignment_id' => $assignment_id]), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösungen dieses Aufgabenblatts wirklich löschen?')] + )->asButton(); + } + if ($assignment->type === 'exam' && $this->assignment_attempt && $this->remaining_time > 0) { + $widget->addLink( + _('Klausur vorzeitig abgeben'), + $this->url_for('vips/sheets/finish_assignment', ['assignment_id' => $assignment_id]), + Icon::create('lock-locked'), + ['data-confirm' => _('Achtung: Wenn Sie die Klausur abgeben, sind keine weiteren Eingaben mehr möglich!')] + )->asButton(); + } + if ($assignment->type === 'selftest' && $this->assignment_attempt && $this->assignment_attempt->end === null) { + $widget->addLink( + _('Aufgabenblatt jetzt abgeben'), + $this->url_for('vips/sheets/finish_assignment', ['assignment_id' => $assignment_id]), + Icon::create('lock-locked'), + ['data-confirm' => _('Achtung: Wenn Sie das Aufgabenblatt abgeben, sind keine weiteren Eingaben mehr möglich!')] + )->asButton(); + } + Sidebar::get()->addWidget($widget); + } else { + if ($assignment->type === 'exam') { + $this->preview_exam_terms = Config::get()->VIPS_EXAM_TERMS; + } + + Helpbar::get()->addPlainText('', + _('Dies ist die Studierendensicht (Vorschau) des Aufgabenblatts.')); + + $widget = new ActionsWidget(); + + if ($assignment->type !== 'exam') { + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'print_files' => 1, 'user_ids[]' => $solver_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + } + if ($assignment->isResetAllowed()) { + $widget->addLink( + _('Lösungen dieses Blatts löschen'), + $this->url_for('vips/sheets/delete_solutions', ['assignment_id' => $assignment_id, 'solver_id' => $solver_id]), + Icon::create('refresh'), + ['data-confirm' => _('Wollen Sie die Lösungen dieses Aufgabenblatts wirklich löschen?')] + )->asButton(); + } + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabenblatt bearbeiten'), + $this->url_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment_id]) + ); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment_id]) + )->setActive(); + Sidebar::get()->addWidget($widget); + + if ($assignment->range_type === 'course') { + $widget = new SelectWidget(_('Anzeigen für'), $this->url_for('vips/sheets/show_assignment', compact('assignment_id')), 'solver_id'); + $widget->class = 'nested-select'; + $element = new SelectElement($GLOBALS['user']->id, ' ', $GLOBALS['user']->id == $solver_id); + $widget->addElement($element); + + foreach ($assignment->course->members->findBy('status', 'autor')->orderBy('nachname, vorname') as $member) { + if ($assignment->isVisible($member->user_id)) { + $element = new SelectElement($member->user_id, $member->nachname . ', ' . $member->vorname, $member->user_id == $solver_id); + $widget->addElement($element); + } + } + Sidebar::get()->addWidget($widget); + } + } + } + + ##################################### + # # + # Lecturer Methods # + # # + ##################################### + + + /** + * Dialog for confirming the end date of a starting assignment. + */ + public function start_assignment_dialog_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $this->assignment = $assignment; + } + + /** + * EXAMS/SHEETS + * + * If an assignment hasn't started yet this function sets the start time to NOW + * so that it's running + * + */ + public function start_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $end_date = trim(Request::get('end_date')); + $end_time = trim(Request::get('end_time')); + $end_datetime = DateTime::createFromFormat('d.m.Y H:i', $end_date.' '.$end_time); + + // unlimited selftest + if ($assignment->type === 'selftest' && $end_date === '' && $end_time === '') { + $end = null; + } else if ($end_datetime) { + $end = strtotime($end_datetime->format('Y-m-d H:i:s')); + } else { + $end = $assignment->end; + PageLayout::postWarning(_('Ungültiger Endzeitpunkt, der Wert wurde nicht übernommen.')); + } + + // set new start and end time in database + $assignment->start = time(); + $assignment->end = $end; + $assignment->active = 1; + $assignment->store(); + + // delete start time for exam from database + VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$assignment_id]); + + $this->redirect('vips/sheets'); + } + + + /** + * EXAMS/SHEETS + * + * Stops/continues an assignment (no change of start/end time but temporary closure) + * + */ + public function stopgo_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $db = DBManager::get(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->type === 'exam') { + if ($assignment->active) { + $assignment->options['stopdate'] = date('Y-m-d H:i:s'); + } else if ($assignment->options['stopdate']) { + // extend exam duration for already active participants + $interval = time() - strtotime($assignment->options['stopdate']); + $sql = 'UPDATE etask_assignment_attempts SET end = end + ? + WHERE assignment_id = ? AND end > ?'; + $stmt = $db->prepare($sql); + $stmt->execute([$interval, $assignment_id, $assignment->options['stopdate']]); + + unset($assignment->options['stopdate']); + } + } + + $assignment->active = !$assignment->active; + $assignment->store(); + + $this->redirect('vips/sheets'); + } + + + /** + * EXAMS/SHEETS + * + * Deletes an assignment from the course (and block if applicable). + */ + public function delete_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $test_title = $assignment->test->title; + + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $assignment->delete(); + PageLayout::postSuccess(sprintf(_('Das Aufgabenblatt „%s“ wurde gelöscht.'), htmlReady($test_title))); + } + + $this->redirect('vips/sheets'); + } + + /** + * Delete a list of assignments from the course (and block if applicable). + */ + public function delete_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $deleted = 0; + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + $assignment->delete(); + ++$deleted; + } + } + + if ($deleted > 0) { + PageLayout::postSuccess(sprintf(_('Es wurden %s Aufgabenblätter gelöscht.'), $deleted)); + } + + if ($deleted < count($assignment_ids)) { + PageLayout::postError(_('Einige Aufgabenblätter konnten nicht gelöscht werden, da bereits Lösungen abgegeben wurden.'), [ + _('Falls Sie diese wirklich löschen möchten, müssen Sie zuerst die Lösungen aller Teilnehmenden zurücksetzen.') + ]); + } + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Dialog for selecting a block for a list of assignments. + */ + public function assign_block_dialog_action() + { + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + } + + /** + * Assign a list of assignments to the specified block. + */ + public function assign_block_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $block_id = Request::int('block_id'); + + if ($block_id) { + $block = VipsBlock::find($block_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if (!$block_id || $block->range_id === $assignment->range_id) { + $assignment->block_id = $block_id ?: null; + $assignment->store(); + } + } + + PageLayout::postSuccess(_('Die Blockzuordnung wurde gespeichert.')); + + $this->redirect('vips/sheets'); + } + + /** + * Dialog for copying a list of assignments into a course. + */ + public function copy_assignments_dialog_action() + { + PageLayout::setTitle(_('Aufgabenblätter kopieren')); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + $this->course_id = Context::getId(); + } + + /** + * Copy the selected assignments into the selected course. + */ + public function copy_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $course_id = Request::option('course_id'); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->copyIntoCourse($course_id); + } else { + $assignment->copyIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde kopiert.', 'Die Aufgabenblätter wurden kopiert.', count($assignment_ids))); + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Dialog for moving a list of assignments to another course. + */ + public function move_assignments_dialog_action() + { + PageLayout::setTitle(_('Aufgabenblätter verschieben')); + + $this->assignment_ids = Request::intArray('assignment_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + $this->course_id = Context::getId(); + } + + /** + * Move a list of assignments to the specified course. + */ + public function move_assignments_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_ids = Request::intArray('assignment_ids'); + $course_id = Request::option('course_id'); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->moveIntoCourse($course_id); + } else { + $assignment->moveIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde verschoben.', 'Die Aufgabenblätter wurden verschoben.', count($assignment_ids))); + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Delete the solutions of all students and reset the assignment. + */ + public function reset_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + if ($assignment->type === 'exam') { + $assignment->deleteAllSolutions(); + PageLayout::postSuccess(_('Die Klausur wurde zurückgesetzt und alle abgegebenen Lösungen archiviert.')); + } + + $this->redirect(Context::getId() ? 'vips/sheets' : 'vips/pool/assignments'); + } + + + /** + * SHEETS/EXAMS + * + * Takes an exercise off an assignment and deletes it. + */ + public function delete_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $exercise = Exercise::find($exercise_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if (!$assignment->isLocked()) { + $assignment->test->removeExercise($exercise_id); + PageLayout::postSuccess(sprintf(_('Die Aufgabe „%s“ wurde gelöscht.'), htmlReady($exercise->title))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Deletes a list of exercises from a specific assignment. + */ + public function delete_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_ids = Request::intArray('exercise_ids'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + if (!$assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + VipsModule::requireEditPermission($assignment, $exercise_id); + $assignment->test->removeExercise($exercise_id); + } + + PageLayout::postSuccess(sprintf(_('Es wurden %s Aufgaben gelöscht.'), count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Reorder exercise positions within an assignment. + */ + public function move_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $list = Request::intArray('item'); + + VipsModule::requireEditPermission($assignment); + + /* renumber all exercises in current assignment */ + foreach ($list as $i => $exercise_id) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + + if ($exercise_ref) { + $exercise_ref->position = $i + 1; + $exercise_ref->store(); + } + } + + $this->render_nothing(); + } + + /** + * SHEETS/EXAMS + * + * Displays the form for editing an exercise. + * + * Is called when editing an existing exercise or creating a new exercise. + */ + public function edit_exercise_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgaben'); + + $exercise_id = Request::int('exercise_id'); // is not set when creating new exercise + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if ($exercise_id) { + // edit already existing exercise + $exercise_ref = $assignment->test->getExerciseRef($exercise_id); + $exercise = $exercise_ref->exercise; + + $max_points = $exercise_ref->points; + $exercise_position = $exercise_ref->position; + } else { + // create new exercise + $exercise_type = Request::option('exercise_type'); + $exercise = new $exercise_type(); + + $max_points = null; + $exercise_position = null; + } + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->exercise = $exercise; + $this->exercise_position = $exercise_position; + $this->max_points = $max_points; + + $this->contentbar = $this->create_contentbar($assignment, $exercise_id); + + Helpbar::get()->addPlainText('', + _('Sie können hier den Aufgabentext und die Antwortoptionen dieser Aufgabe bearbeiten.')); + + $widget = new ActionsWidget(); + + if (!$assignment->isLocked()) { + $widget->addLink( + _('Neue Aufgabe erstellen'), + $this->url_for('vips/sheets/add_exercise_dialog', ['assignment_id' => $assignment_id]), + Icon::create('add') + )->asDialog('size=auto'); + } + + Sidebar::get()->addWidget($widget); + + if ($exercise->id) { + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabe bearbeiten'), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) + )->setActive(); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $exercise->id]) + ); + Sidebar::get()->addWidget($widget); + } + + $widget = new ViewsWidget(); + $widget->setTitle(_('Aufgabenblatt')); + + foreach ($assignment->test->exercise_refs as $item) { + $widget->addLink( + sprintf(_('Aufgabe %d'), $item->position), + $this->url_for('vips/sheets/edit_exercise', ['assignment_id' => $assignment_id, 'exercise_id' => $item->task_id]) + )->setActive($item->task_id === $exercise->id); + } + + Sidebar::get()->addWidget($widget); + } + + + /** + * SHEETS/EXAMS + * + * Inserts/Updates an exercise into the database + */ + public function store_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $exercise_id = Request::int('exercise_id'); // not set when storing new exercise + $exercise_type = Request::option('exercise_type'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $test_id = $assignment->test_id; + $file_ids = Request::optionArray('file_ids'); + $request = Request::getInstance(); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + if ($exercise_id) { + // update existing exercise. + $exercise = Exercise::find($exercise_id); + $item_count = $exercise->itemCount(); + $exercise->initFromRequest($request); + $exercise->store(); + + // update maximum points + if ($exercise->itemCount() != $item_count) { + $exercise_ref = VipsExerciseRef::find([$test_id, $exercise_id]); + $exercise_ref->points = $exercise->itemCount(); + $exercise_ref->store(); + } + } else { + // store exercise in database. + $exercise = new $exercise_type(); + $exercise->initFromRequest($request); + $exercise->user_id = $GLOBALS['user']->id; + $exercise->store(); + + // link new exercise to the assignment. + $assignment->test->addExercise($exercise); + $exercise_id = $exercise->id; + } + + $upload = $_FILES['upload'] ?: ['name' => []]; + $folder = Folder::findTopFolder($exercise->id, 'ExerciseFolder', 'task'); + + foreach ($folder->file_refs as $file_ref) { + if (!in_array($file_ref->id, $file_ids) || in_array($file_ref->name, $upload['name'])) { + $file_ref->delete(); + } + } + + FileManager::handleFileUpload($upload, $folder->getTypedFolder()); + + PageLayout::postSuccess(_('Die Aufgabe wurde eingetragen.')); + + $this->redirect($this->url_for('vips/sheets/edit_exercise', compact('assignment_id', 'exercise_id'))); + } + + /** + * Copy the selected exercises into this assignment. + */ + public function copy_exercise_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + $exercise_id = Request::int('exercise_id'); + $exercise_ids = $exercise_id ? [$exercise_id => $assignment_id] : Request::intArray('exercise_ids'); + + VipsModule::requireEditPermission($assignment); + + if (!$assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id => $copy_assignment_id) { + $copy_assignment = VipsAssignment::find($copy_assignment_id); + VipsModule::requireEditPermission($copy_assignment); + + $exercise_ref = VipsExerciseRef::find([$copy_assignment->test_id, $exercise_id]); + $exercise_ref->copyIntoTest($assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Dialog for copying a list of exercises to another assignment. + */ + public function copy_exercises_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Copy a list of exercises to the specified assignment. + */ + public function copy_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $target_assignment_id = Request::int('target_assignment_id'); + $exercise_ids = Request::intArray('exercise_ids'); + + $assignment = VipsAssignment::find($assignment_id); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($assignment); + VipsModule::requireEditPermission($target_assignment); + + if (!$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + $exercise_ref = $assignment->test->getExerciseRef($exercise_id); + $exercise_ref->copyIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde kopiert.', 'Die Aufgaben wurden kopiert.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Dialog for moving a list of exercises to another assignment. + */ + public function move_exercises_dialog_action() + { + $this->assignment_id = Request::int('assignment_id'); + $this->exercise_ids = Request::intArray('exercise_ids'); + $this->courses = VipsModule::getActiveCourses($GLOBALS['user']->id); + } + + /** + * Move a list of exercises to the specified assignment. + */ + public function move_exercises_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $assignment_id = Request::int('assignment_id'); + $target_assignment_id = Request::int('target_assignment_id'); + $exercise_ids = Request::intArray('exercise_ids'); + + $assignment = VipsAssignment::find($assignment_id); + $target_assignment = VipsAssignment::find($target_assignment_id); + + VipsModule::requireEditPermission($assignment); + VipsModule::requireEditPermission($target_assignment); + + if (!$assignment->isLocked() && !$target_assignment->isLocked()) { + foreach ($exercise_ids as $exercise_id) { + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise_ref->moveIntoTest($target_assignment->test_id); + } + + PageLayout::postSuccess(ngettext('Die Aufgabe wurde verschoben.', 'Die Aufgaben wurden verschoben.', count($exercise_ids))); + } + + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * SHEETS/EXAMS + * + * Stores the specification (Grunddaten) of an assignment + * OR add new exercise, edit points/Bewertung (basically everything that can be done on + * page edit_exercise_action()) + */ + public function store_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $db = DBManager::get(); + + $assignment_id = Request::int('assignment_id'); + + if ($assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + } else { + $assignment = new VipsAssignment(); + $assignment->range_id = Context::getId() ?: $GLOBALS['user']->id; + $assignment->range_type = Context::getId() ? 'course' : 'user'; + } + + VipsModule::requireEditPermission($assignment); + + $assignment_name = trim(Request::get('assignment_name')); + $assignment_description = trim(Request::get('assignment_description')); + $assignment_description = Studip\Markup::purifyHtml($assignment_description); + $assignment_notes = trim(Request::get('assignment_notes')); + $assignment_type = Request::option('assignment_type', 'practice'); + $assignment_block = Request::int('assignment_block', 0); + $assignment_block_name = trim(Request::get('assignment_block_name')); + $start_date = trim(Request::get('start_date')); + $start_time = trim(Request::get('start_time')); + $end_date = trim(Request::get('end_date')); + $end_time = trim(Request::get('end_time')); + + $exam_length = Request::int('exam_length'); + $access_code = trim(Request::get('access_code')); + $ip_range = trim(Request::get('ip_range')); + $use_groups = Request::int('use_groups', 0); + $shuffle_answers = Request::int('shuffle_answers', 0); + $shuffle_exercises = Request::int('shuffle_exercises', 0); + $self_assessment = Request::int('self_assessment', 0); + $max_tries = Request::int('max_tries', 0); + $resets = Request::int('resets', 0); + $evaluation_mode = Request::int('evaluation_mode', 0); + $exercise_points = Request::floatArray('exercise_points'); + $selftest_threshold = Request::getArray('threshold'); + $selftest_feedback = Request::getArray('feedback'); + + $start_datetime = DateTime::createFromFormat('d.m.Y H:i', $start_date.' '.$start_time); + $end_datetime = DateTime::createFromFormat('d.m.Y H:i', $end_date.' '.$end_time); + + if ($assignment_name === '') { + $assignment_name = _('Aufgabenblatt'); + } + + if ($start_datetime) { + $start = $start_datetime->format('Y-m-d H:i:s'); + } else { + $start = date('Y-m-d H:00:00'); + PageLayout::postWarning(_('Ungültiger Startzeitpunkt, der Wert wurde nicht übernommen.')); + } + + // unlimited selftest + if ($assignment_type == 'selftest' && $end_date == '' && $end_time == '') { + $end = null; + } else if ($end_datetime) { + $end = $end_datetime->format('Y-m-d H:i:s'); + } else { + $end = date('Y-m-d H:00:00'); + PageLayout::postWarning(_('Ungültiger Endzeitpunkt, der Wert wurde nicht übernommen.')); + } + + if ($end && $end <= $start) { // start is *later* than end! + $end = $start; + PageLayout::postWarning(_('Bitte überprüfen Sie den Start- und den Endzeitpunkt!')); + } + + if ($assignment_block_name != '') { + $block = VipsBlock::create(['name' => $assignment_block_name, 'range_id' => $assignment->range_id]); + } else if ($assignment_block) { + $block = VipsBlock::find($assignment_block); + + if ($block->range_id !== $assignment->range_id) { + $block = null; + } + } else { + $block = null; + } + + foreach ($selftest_threshold as $i => $threshold) { + if ($threshold !== '') { + $feedback[$threshold] = Studip\Markup::purifyHtml($selftest_feedback[$i]); + } + } + + /*** store basic data (Grunddaten) of assignment */ + if ($assignment_id) { + // check whether the exam's start time has been moved + if ($assignment->start != strtotime($start) && time() <= strtotime($start)) { + $assignment->active = 1; + } + + // extend exam duration for already active participants + if ($assignment_type === 'exam' && $assignment->options['duration'] != $exam_length) { + $sql = 'UPDATE etask_assignment_attempts SET end = GREATEST(end + ? * 60, UNIX_TIMESTAMP()) + WHERE assignment_id = ? AND end > UNIX_TIMESTAMP()'; + $stmt = $db->prepare($sql); + $stmt->execute([$exam_length - $assignment->options['duration'], $assignment_id]); + } + + $assignment->test->setData([ + 'title' => $assignment_name, + 'description' => $assignment_description + ]); + $assignment->test->store(); + } else { + $assignment->test = VipsTest::create([ + 'title' => $assignment_name, + 'description' => $assignment_description, + 'user_id' => $GLOBALS['user']->id + ]); + } + + $assignment->setData([ + 'type' => $assignment_type, + 'start' => strtotime($start), + 'end' => $end ? strtotime($end) : null, + 'block_id' => $block ? $block->id : null + ]); + + // update options array + $assignment->options['evaluation_mode'] = $evaluation_mode; + $assignment->options['notes'] = $assignment_notes; + + unset($assignment->options['access_code']); + unset($assignment->options['ip_range']); + unset($assignment->options['shuffle_answers']); + unset($assignment->options['shuffle_exercises']); + unset($assignment->options['self_assessment']); + unset($assignment->options['use_groups']); + unset($assignment->options['max_tries']); + unset($assignment->options['resets']); + unset($assignment->options['feedback']); + + if ($assignment_type === 'exam') { + $assignment->options['duration'] = $exam_length; + + if ($access_code !== '') { + $assignment->options['access_code'] = $access_code; + } + + if ($ip_range !== '') { + $assignment->options['ip_range'] = $ip_range; + } + + $assignment->options['shuffle_answers'] = $shuffle_answers; + + if ($shuffle_exercises === 1) { + $assignment->options['shuffle_exercises'] = $shuffle_exercises; + } + + if ($self_assessment === 1) { + $assignment->options['self_assessment'] = $self_assessment; + } + } + + if ($assignment_type === 'practice') { + $assignment->options['use_groups'] = $use_groups; + } + + if ($assignment_type === 'selftest') { + $assignment->options['max_tries'] = $max_tries; + + if ($resets === 0) { + $assignment->options['resets'] = $resets; + } + + if (isset($feedback)) { + krsort($feedback); + $assignment->options['feedback'] = $feedback; + } + } + + $assignment->store(); + $assignment_id = $assignment->id; + + foreach ($assignment->test->exercise_refs as $exercise_ref) { + $points = $exercise_points[$exercise_ref->task_id]; + $exercise_ref->points = round($points * 2) / 2; + $exercise_ref->store(); + } + + PageLayout::postSuccess(_('Das Aufgabenblatt wurde gespeichert.')); + $this->redirect($this->url_for('vips/sheets/edit_assignment', compact('assignment_id'))); + } + + /** + * Returns the dialog content to create a new exercise. + */ + public function add_exercise_dialog_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgaben'); + + $assignment_id = Request::int('assignment_id'); + + $this->assignment_id = $assignment_id; + $this->exercise_types = Exercise::getExerciseTypes(); + } + + /** + * Returns the dialog content to copy an existing exercise. + */ + public function copy_exercise_dialog_action() + { + $assignment_id = Request::int('assignment_id'); + $search_filter = Request::getArray('search_filter'); + + $sort = Request::option('sort', 'start_time'); + $desc = Request::int('desc', $sort === 'start_time'); + $page = Request::int('page', 1); + $size = 15; + + if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'exercise_type'], ''); + $search_filter['range_type'] = Context::getId() ? 'course' : 'user'; + } + + if ($search_filter['range_type'] === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + $exercises = $this->getAllExercises($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->size = $size; + $this->count = count($exercises); + $this->exercises = array_slice($exercises, $size * ($page - 1), $size); + $this->exercise_types = Exercise::getExerciseTypes(); + $this->assignment_id = $assignment_id; + $this->search_filter = $search_filter; + } + + /** + * Get all matching exercises from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param course_ids list of courses to get exercises from + * @param sort sort exercise list by this property + * @param desc true if sort direction is descending + * @param search_filter the currently active search filter + * + * @return array with data of all matching exercises + */ + public function getAllExercises($course_ids, $sort, $desc, $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $exercise_type = $search_filter['exercise_type']; + + $sql = "SELECT etask_tasks.*, + etask_assignments.id AS assignment_id, + etask_assignments.range_id, + etask_assignments.range_type, + etask_tests.title AS test_title, + seminare.name AS course_name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tasks + JOIN etask_test_tasks ON etask_tasks.id = etask_test_tasks.task_id + JOIN etask_tests ON etask_tests.id = etask_test_tasks.test_id + JOIN etask_assignments USING (test_id) + LEFT JOIN seminare ON etask_assignments.range_id = seminare.seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN ('exam', 'practice', 'selftest') " . + ($search_string ? 'AND (etask_tasks.title LIKE :input OR + etask_tasks.description LIKE :input OR + etask_tests.title LIKE :input OR + seminare.name LIKE :input) ' : '') . + ($exercise_type ? 'AND etask_tasks.type = :exercise_type ' : '') . + "ORDER BY :sort :desc, start_time DESC, seminare.name, + etask_tests.mkdate DESC, etask_test_tasks.position"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':exercise_type', $exercise_type); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * SHEETS/EXAMS + * + * Displays form to edit an existing assignment + * + */ + public function edit_assignment_action() + { + PageLayout::setHelpKeyword('Basis.VipsAufgabenblatt'); + + $assignment_id = Request::int('assignment_id'); + + if ($assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + $test = $assignment->test; + } else { + $test = new VipsTest(); + $test->title = _('Aufgabenblatt'); + + $assignment = new VipsAssignment(); + $assignment->range_id = Context::getId() ?: $GLOBALS['user']->id; + $assignment->range_type = Context::getId() ? 'course' : 'user'; + $assignment->type = 'practice'; + $assignment->start = strtotime(date('Y-m-d H:00:00')); + $assignment->end = strtotime(date('Y-m-d H:00:00')); + } + + VipsModule::requireEditPermission($assignment); + + if (!isset($assignment->options['feedback'])) { + $assignment->options['feedback'] = ['' => '']; + } + + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$assignment->range_id]); + + $this->assignment = $assignment; + $this->assignment_id = $assignment_id; + $this->test = $test; + $this->blocks = $blocks; + $this->locked = $assignment_id && $assignment->isLocked(); + $this->exercises = $test->exercises; + $this->assignment_types = VipsAssignment::getAssignmentTypes(); + $this->exam_rooms = Config::get()->VIPS_EXAM_ROOMS; + + $this->contentbar = $this->create_contentbar($assignment); + + Helpbar::get()->addPlainText('', + _('Sie können hier die Grunddaten des Aufgabenblatts verwalten und Aufgaben hinzufügen, bearbeiten oder löschen.') . ' ' . + _('Alle Daten können später geändert oder ergänzt werden.')); + + $widget = new ActionsWidget(); + + if ($assignment_id && !$this->locked) { + $widget->addLink( + _('Neue Aufgabe erstellen'), + $this->url_for('vips/sheets/add_exercise_dialog', compact('assignment_id')), + Icon::create('add') + )->asDialog('size=auto'); + $widget->addLink( + _('Vorhandene Aufgabe kopieren'), + $this->url_for('vips/sheets/copy_exercise_dialog', compact('assignment_id')), + Icon::create('copy') + )->asDialog('size=big'); + } + + if ($assignment_id) { + if ($assignment->range_type === 'course') { + $widget->addLink( + _('Aufgabenblatt korrigieren'), + $this->url_for('vips/solutions/assignment_solutions', ['assignment_id' => $assignment_id]), + Icon::create('accept') + ); + } + + $widget->addLink( + _('Aufgabenblatt drucken'), + $this->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id]), + Icon::create('print'), + ['target' => '_blank'] + ); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Aufgabenblatt bearbeiten'), + $this->url_for('vips/sheets/edit_assignment', ['assignment_id' => $assignment_id]) + )->setActive(); + $widget->addLink( + _('Studierendensicht (Vorschau)'), + $this->url_for('vips/sheets/show_assignment', ['assignment_id' => $assignment_id]) + ); + Sidebar::get()->addWidget($widget); + + $widget = new ExportWidget(); + $widget->addLink( + _('Aufgabenblatt exportieren'), + $this->url_for('vips/sheets/export_xml', ['assignment_id' => $assignment_id]), + Icon::create('export') + ); + } + + Sidebar::get()->addWidget($widget); + } + + /** + * Show preview of an existing exercise (using print view for now). + */ + public function preview_exercise_action() + { + $exercise_id = Request::int('exercise_id'); + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment, $exercise_id); + + // fetch exercise info + $exercise_ref = VipsExerciseRef::find([$assignment->test_id, $exercise_id]); + $exercise = $exercise_ref->exercise; + + $this->assignment = $assignment; + $this->exercise = $exercise; + $this->exercise_position = $exercise_ref->position; + $this->max_points = $exercise_ref->points; + $this->solution = new VipsSolution(); + $this->show_solution = false; + $this->print_correction = false; + $this->user_id = null; + + $this->render_template('vips/exercises/print_exercise'); + } + + /** + * Copy the selected assignments into the current course. + */ + public function copy_assignment_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + $assignment_id = Request::int('assignment_id'); + $assignment_ids = $assignment_id ? [$assignment_id] : Request::intArray('assignment_ids'); + + foreach ($assignment_ids as $assignment_id) { + $assignment = VipsAssignment::find($assignment_id); + VipsModule::requireEditPermission($assignment); + + if ($course_id) { + $assignment->copyIntoCourse($course_id); + } else { + $assignment->copyIntoCourse($GLOBALS['user']->id, 'user'); + } + } + + PageLayout::postSuccess(ngettext('Das Aufgabenblatt wurde kopiert.', 'Die Aufgabenblätter wurden kopiert.', count($assignment_ids))); + + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Imports a test from a text file. + */ + public function import_test_action() + { + CSRFProtection::verifyUnsafeRequest(); + + $course_id = Context::getId(); + + if ($course_id) { + VipsModule::requireStatus('tutor', $course_id); + } + + if ($_FILES['upload']['name'][0] == '') { + PageLayout::postError(_('Sie müssen eine Datei zum Importieren auswählen.')); + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + return; + } + + $num_assignments = 0; + $num_exercises = 0; + + for ($i = 0; $i < count($_FILES['upload']['name']); ++$i) { + if (!is_uploaded_file($_FILES['upload']['tmp_name'][$i])) { + $message = sprintf(_('Es trat ein Fehler beim Hochladen der Datei „%s“ auf.'), htmlReady($_FILES['upload']['name'][$i])); + PageLayout::postError($message); + continue; + } + + $text = file_get_contents($_FILES['upload']['tmp_name'][$i]); + + if (str_contains($text, '<?xml')) { + $assignment = VipsAssignment::importXML($text, $GLOBALS['user']->id, $course_id); + } else { + // convert from windows-1252 if legacy text format + $text = mb_decode_numericentity(mb_convert_encoding($text, 'UTF-8', 'WINDOWS-1252'), [0x100, 0xffff, 0, 0xffff], 'UTF-8'); + $test_title = trim(basename($_FILES['upload']['name'][$i], '.txt')); + $assignment = VipsAssignment::importText($test_title, $text, $GLOBALS['user']->id, $course_id); + } + + $num_assignments += 1; + $num_exercises += count($assignment->test->exercise_refs); + } + + if ($num_assignments == 1) { + $message = sprintf(ngettext('Das Aufgabenblatt „%s“ mit %d Aufgabe wurde hinzugefügt.', + 'Das Aufgabenblatt „%s“ mit %d Aufgaben wurde hinzugefügt.', $num_exercises), + htmlReady($assignment->test->title), $num_exercises); + PageLayout::postSuccess($message); + } else if ($num_assignments > 1) { + $message = sprintf(_('%1$d Aufgabenblätter mit insgesamt %2$d Aufgaben wurden hinzugefügt.'), $num_assignments, $num_exercises); + PageLayout::postSuccess($message); + } + + $this->redirect($course_id ? 'vips/sheets' : 'vips/pool/assignments'); + } + + /** + * Creates html print view of a sheet/exam (new window) specified by id + */ + public function print_assignments_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireViewPermission($assignment); + + $user_ids = Request::optionArray('user_ids'); + $print_files = Request::int('print_files'); + $print_correction = Request::int('print_correction'); + $print_sample_solution = Request::int('print_sample_solution'); + $print_student_ids = false; + $assignment_data = []; + + if (!$assignment->checkEditPermission()) { + $user_ids = [$GLOBALS['user']->id]; + $released = $assignment->releaseStatus($user_ids[0]); + $print_correction = $released >= VipsAssignment::RELEASE_STATUS_CORRECTIONS; + $print_sample_solution = $released == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS; + + if ($assignment->type !== 'exam' && $assignment->checkAccess($user_ids[0])) { + $assignment->recordAssignmentAttempt($user_ids[0]); + } else if ($released < VipsAssignment::RELEASE_STATUS_CORRECTIONS) { + PageLayout::postError(_('Kein Zugriff möglich!')); + $this->redirect('vips/sheets/list_assignments_stud'); + return; + } + } + + if ($assignment->range_type === 'course') { + foreach ($assignment->course->getMembersWithStatus('dozent') as $member) { + $lecturers[] = $member->getUserFullname(); + } + + $sem_class = $assignment->course->getSemClass(); + $print_student_ids = !$sem_class['studygroup_mode']; + } + + if ($user_ids) { + foreach ($user_ids as $user_id) { + $group = $assignment->getUserGroup($user_id); + $students = $stud_ids = []; + + if ($group) { + $name = $group->name; + $members = $assignment->getGroupMembers($group); + + usort($members, function($a, $b) { + return strcoll($a->user->getFullName('no_title_rev'), $b->user->getFullName('no_title_rev')); + }); + + foreach ($members as $member) { + $students[] = $member->user->getFullName('no_title'); + $stud_ids[] = $member->user->matriculation_number ?: _('(keine Matrikelnummer)'); + } + } else { + $user = User::find($user_id); + $name = $user->getFullName('no_title_rev'); + $students[] = $user->getFullName('no_title'); + $stud_ids[] = $user->matriculation_number ?: _('(keine Matrikelnummer)'); + } + + $assignment_data[] = [ + 'user_id' => $user_id, + 'students' => $students, + 'stud_ids' => $stud_ids + ]; + } + } else { + $assignment_data[] = [ + 'user_id' => null + ]; + } + + if (count($user_ids) === 1) { + Config::get()->UNI_NAME_CLEAN = $name; + } + + PageLayout::setTitle($assignment->test->title); + $this->set_layout('vips/sheets/print_layout'); + + $this->assignment = $assignment; + $this->user_ids = $user_ids; + $this->lecturers = $lecturers; + $this->print_files = $print_files; + $this->print_correction = $print_correction; + $this->print_sample_solution = $print_sample_solution; + $this->print_student_ids = $print_student_ids; + $this->assignment_data = $assignment_data; + } + + /** + * SHEETS/EXAMS + * + * Main page of sheets/exams. + * Lists all the assignments (sheets or exams) in the course, grouped by "not yet started", + * "running" and "finished". + */ + public function list_assignments_action() + { + $course_id = Context::getId(); + VipsModule::requireStatus('tutor', $course_id); + + $sort = Request::option('sort', 'start'); + $desc = Request::int('desc'); + $group = isset($_SESSION['group_assignments']) ? $_SESSION['group_assignments'] : 0; + $group = Request::int('group', $group); + + $_SESSION['group_assignments'] = $group; + $running = false; + + ###################################### + # get assignments in this course # + ###################################### + + $assignments = VipsAssignment::findByRangeId($course_id); + $blocks = VipsBlock::findBySQL('range_id = ? ORDER BY name', [$course_id]); + $blocks[] = VipsBlock::build(['name' => _('Aufgabenblätter ohne Blockzuordnung')]); + + usort($assignments, function($a, $b) use ($sort) { + if ($sort === 'title') { + return strcoll($a->test->title, $b->test->title); + } else if ($sort === 'type') { + return strcmp($a->type, $b->type); + } else if ($sort === 'start') { + return strcmp($a->start, $b->start); + } else { + return strcmp($a->end ?: '~', $b->end ?: '~'); + } + }); + + if ($desc) { + $assignments = array_reverse($assignments); + } + + $plugin_manager = PluginManager::getInstance(); + $courseware = $plugin_manager->getPluginInfo('CoursewareModule'); + $courseware_active = $courseware && $plugin_manager->isPluginActivated($courseware['id'], $course_id); + + if ($group == 2 && $courseware_active) { + $elements = Courseware\StructuralElement::findBySQL('range_id = ?', [$course_id]); + $unassigned = array_column($assignments, 'id'); + + foreach ($elements as $element) { + $assigned = $this->courseware_assignments($element); + $unassigned = array_diff($unassigned, $assigned); + + $assignment_data[] = [ + 'title' => $element->title, + 'assignments' => array_filter($assignments, function($assignment) use ($assigned) { + return in_array($assignment->id, $assigned); + }) + ]; + } + + $assignment_data[] = [ + 'title' => _('Aufgabenblätter ohne Courseware-Einbindung'), + 'assignments' => array_filter($assignments, function($assignment) use ($unassigned) { + return in_array($assignment->id, $unassigned); + }) + ]; + } else if ($group == 1) { + foreach ($blocks as $block) { + $assignment_data[$block->id] = [ + 'title' => $block->name, + 'block' => $block, + 'assignments' => [] + ]; + } + + foreach ($assignments as $assignment) { + $assignment_data[$assignment->block_id]['assignments'][] = $assignment; + } + } else { + $group = 0; + $assignment_data = [ + [ + 'title' => _('Noch nicht gestartete Aufgabenblätter'), + 'assignments' => [] + ], [ + 'title' => _('Laufende Aufgabenblätter'), + 'assignments' => [] + ], [ + 'title' => _('Beendete Aufgabenblätter'), + 'assignments' => [] + ] + ]; + + foreach ($assignments as $assignment) { + if ($assignment->isFinished()) { + $assignment_data[2]['assignments'][] = $assignment; + } else if ($assignment->isRunning()) { + $assignment_data[1]['assignments'][] = $assignment; + } else { + $assignment_data[0]['assignments'][] = $assignment; + } + } + } + + foreach ($assignments as $assignment) { + if ($assignment->isRunning()) { + $running = true; + } + } + + $this->assignment_data = $assignment_data; + $this->num_assignments = count($assignments); + $this->sort = $sort; + $this->desc = $desc; + $this->group = $group; + $this->blocks = $blocks; + + Helpbar::get()->addPlainText('', + _('Hier können Übungen, Tests und Klausuren online vorbereitet und durchgeführt werden. Sie erhalten ' . + 'dabei auch eine Übersicht über die Lösungen bzw. Antworten der Studierenden.') . "\n\n" . + _('Auf dieser Seite können Sie Aufgabenblätter in Ihrem Kurs anlegen und verwalten.')); + + $widget = new ActionsWidget(); + $widget->addLink( + _('Aufgabenblatt erstellen'), + $this->url_for('vips/sheets/edit_assignment'), + Icon::create('add') + ); + $widget->addLink( + _('Aufgabenblatt kopieren'), + $this->url_for('vips/sheets/copy_assignment_dialog'), + Icon::create('copy') + )->asDialog('size=1200x800'); + $widget->addLink( + _('Aufgabenblatt importieren'), + $this->url_for('vips/sheets/import_assignment_dialog'), + Icon::create('import') + )->asDialog('size=auto'); + $widget->addLink( + _('Neuen Block erstellen'), + $this->url_for('vips/admin/edit_block'), + Icon::create('add') + )->asDialog('size=auto'); + Sidebar::get()->addWidget($widget); + + $widget = new ViewsWidget(); + $widget->addLink( + _('Gruppiert nach Status'), + $this->url_for('vips/sheets', ['group' => 0]) + )->setActive($group == 0); + $widget->addLink( + _('Gruppiert nach Blöcken'), + $this->url_for('vips/sheets', ['group' => 1]) + )->setActive($group == 1); + + if ($courseware_active) { + $widget->addLink( + _('Verwendung in Courseware'), + $this->url_for('vips/sheets', ['group' => 2]) + )->setActive($group == 2); + } + + Sidebar::get()->addWidget($widget); + } + + /** + * Collect all assignment_ids used in the given Courseware element. + */ + private function courseware_assignments($element) + { + $result = []; + + foreach ($element->containers as $container) { + foreach ($container->blocks as $block) { + if ($block->block_type === 'test') { + $payload = json_decode($block->payload, true); + + if ($payload['assignment']) { + $result[] = $payload['assignment']; + } + } + } + } + + return $result; + } + + /** + * Returns the dialog content to import an assignment from text file. + */ + public function import_assignment_dialog_action() + { + } + + /** + * Returns the dialog content to copy available assignments. + */ + public function copy_assignment_dialog_action() + { + $search_filter = Request::getArray('search_filter'); + + $sort = Request::option('sort', 'start_time'); + $desc = Request::int('desc', $sort === 'start_time'); + $page = Request::int('page', 1); + $size = 15; + + if (empty($search_filter) || Request::submitted('reset_search')) { + $search_filter = array_fill_keys(['search_string', 'assignment_type'], ''); + $search_filter['range_type'] = Context::getId() ? 'course' : 'user'; + } + + if ($search_filter['range_type'] === 'course') { + $course_ids = array_column(VipsModule::getActiveCourses($GLOBALS['user']->id), 'id'); + } else { + $course_ids = [$GLOBALS['user']->id]; + } + + $assignments = $this->getAllAssignments($course_ids, $sort, $desc, $search_filter); + + $this->sort = $sort; + $this->desc = $desc; + $this->page = $page; + $this->size = $size; + $this->count = count($assignments); + $this->assignments = array_slice($assignments, $size * ($page - 1), $size); + $this->assignment_types = VipsAssignment::getAssignmentTypes(); + $this->search_filter = $search_filter; + } + + /** + * Get all matching assignments from a list of courses in given order. + * If $search_filter is not empty, search filters are applied. + * + * @param array $course_ids list of courses to get assignments from + * @param string $sort sort assignment list by this property + * @param bool $desc true if sort direction is descending + * @param array $search_filter the currently active search filter + * + * @return array with data of all matching assignments + */ + public function getAllAssignments(array $course_ids, string $sort, bool $desc, array $search_filter) + { + $db = DBManager::get(); + + // check if some filters are active + $search_string = $search_filter['search_string']; + $assignment_type = $search_filter['assignment_type']; + $types = $assignment_type ? [$assignment_type] : ['exam', 'practice', 'selftest']; + + $sql = "SELECT etask_assignments.*, + etask_tests.title AS test_title, + seminare.name AS course_name, + (SELECT MIN(beginn) FROM semester_data + JOIN semester_courses USING(semester_id) + WHERE course_id = Seminar_id) AS start_time + FROM etask_tests + JOIN etask_assignments ON etask_tests.id = etask_assignments.test_id + LEFT JOIN seminare ON etask_assignments.range_id = seminare.seminar_id + WHERE etask_assignments.range_id IN (:course_ids) + AND etask_assignments.type IN (:types) " . + ($search_string ? 'AND (etask_tests.title LIKE :input OR + etask_tests.description LIKE :input OR + seminare.name LIKE :input) ' : '') . + "ORDER BY :sort :desc, start_time DESC, seminare.name, + etask_tests.mkdate DESC"; + + $stmt = $db->prepare($sql); + $stmt->bindValue(':course_ids', $course_ids); + $stmt->bindValue(':input', '%' . $search_string . '%'); + $stmt->bindValue(':types', $types); + $stmt->bindValue(':sort', $sort, StudipPDO::PARAM_COLUMN); + $stmt->bindValue(':desc', $desc ? 'DESC' : 'ASC', StudipPDO::PARAM_COLUMN); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Exports all exercises in this assignment in Vips XML format. + */ + public function export_xml_action() + { + $assignment_id = Request::int('assignment_id'); + $assignment = VipsAssignment::find($assignment_id); + + VipsModule::requireEditPermission($assignment); + + $this->set_content_type('text/xml; charset=UTF-8'); + header('Content-Disposition: attachment; ' . encode_header_parameter('filename', $assignment->test->title.'.xml')); + + $this->render_text($assignment->exportXML()); + } + + public function relay_action($action) + { + $params = func_get_args(); + $params[0] = $this; + $exercise_id = Request::int('exercise_id'); + $exercise = Exercise::find($exercise_id); + $action = $action . '_action'; + + $this->exercise = $exercise; + + if (method_exists($exercise, $action)) { + call_user_func_array([$exercise, $action], $params); + } else { + throw new InvalidArgumentException(get_class($exercise) . '::' . $action); + } + } + + /** + * Create a ContentBar for this assignment (if no exercise is specified) + * or for the given exercise on the assignment. + */ + public function create_contentbar( + VipsAssignment $assignment, + ?int $exercise_id = null, + string $view = 'edit', + ?string $solver_id = null + ) { + $toc = new TOCItem($assignment->test->title); + $toc->setURL($this->url_for("vips/sheets/{$view}_assignment", ['assignment_id' => $assignment->id])); + $toc->setActive($exercise_id === null); + + if ($view === 'edit') { + $exercise_refs = $assignment->test->exercise_refs; + } else { + $exercise_refs = $assignment->getExerciseRefs($solver_id); + } + + foreach ($exercise_refs as $i => $item) { + $child = new TOCItem(sprintf('%d. %s', $i + 1, $item->exercise->title)); + $child->setURL($this->url_for( + "vips/sheets/{$view}_exercise", + ['assignment_id' => $assignment->id, 'exercise_id' => $item->task_id, 'solver_id' => $solver_id] + )); + + $child->setActive($item->task_id == $exercise_id); + $toc->children[] = $child; + } + + foreach ($toc->children as $i => $item) { + if ($item->isActive()) { + $icons = $this->get_template_factory()->open('vips/sheets/content_bar_icons'); + + if ($i > 0) { + $icons->prev_exercise_url = $toc->children[$i - 1]->getURL(); + } + + if ($i < count($toc->children) - 1) { + $icons->next_exercise_url = $toc->children[$i + 1]->getURL(); + } + } + } + + return Studip\VueApp::create('ContentBar')->withProps([ + 'isContentBar' => true, + 'toc' => $toc + ])->withComponent( + 'ContentBarBreadcrumbs' + )->withSlot( + 'breadcrumb-list', sprintf("<content-bar-breadcrumbs :toc='%s'/>", json_encode($toc)) + )->withSlot( + 'buttons-left', $icons ?? '' + ); + } + + /** + * Return the appropriate CSS class for sortable column (if any). + * + * @param boolean $sort sort by this column + * @param boolean $desc set sort direction + */ + public function sort_class(bool $sort, ?bool $desc): string + { + return $sort ? ($desc ? 'sortdesc' : 'sortasc') : ''; + } + + /** + * Render a generic page chooser selector. The first occurence of '%d' + * in the URL is replaced with the selected page number. + * + * @param string $url URL for one of the pages + * @param string $count total number of entries + * @param string $page current page to display + * @param string|null $dialog Optional dialog attribute content + * @param int|null $page_size page size (defaults to system default) + * @return mixed + */ + public function page_chooser(string $url, string $count, string $page, ?string $dialog = null, ?int $page_size = null) + { + $template = $GLOBALS['template_factory']->open('shared/pagechooser'); + $template->dialog = $dialog; + $template->num_postings = $count; + $template->page = $page; + $template->perPage = $page_size ?: Config::get()->ENTRIES_PER_PAGE; + $template->pagelink = str_replace('%%25d', '%d', str_replace('%', '%%', $url)); + + return $template->render(); + } +} diff --git a/app/controllers/vips/solutions.php b/app/controllers/vips/solutions.php new file mode 100644 index 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;"> <?= htmlReady($response[$blank]) ?> </span><!-- + <? if ($print_correction): ?> + <? if ($results[$blank]['points'] == 1): ?> + --><?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('richtig')]) ?><!-- + <? elseif ($results[$blank]['points'] == 0.5): ?> + --><?= Icon::create('decline', Icon::ROLE_STATUS_YELLOW)->asImg(['title' => _('fast richtig')]) ?><!-- + <? else: ?> + --><?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('falsch')]) ?><!-- + <? endif ?> + <? endif ?> + <? elseif ($exercise->isSelect($blank)): ?> + <? foreach ($exercise->task['answers'][$blank] as $index => $option) : ?> + --><?= $index ? ' | ' : '' ?><!-- + --><?= Assets::img('choice_unchecked.svg', ['style' => 'vertical-align: text-bottom;']) ?> <!-- + --><span class="math-tex" style="border-bottom: 1px dotted black;"><?= htmlReady($option['text']) ?></span><!-- + <? endforeach ?> + <? else: ?> + --><?= str_repeat('_', $exercise->getInputWidth($blank)) ?><!-- + <? endif ?> + <? if ($show_solution && (empty($results) || $results[$blank]['points'] < 1) && $exercise->correctAnswers($blank)): ?> + --><span class="correct_item math-tex"><?= htmlReady(implode(' | ', $exercise->correctAnswers($blank))) ?></span><!-- + <? endif ?> + <? endif ?> + <? endforeach ?> + --> +</div> + +<? if ($exercise->interactionType() === 'drag'): ?> + <div class="label-text"> + <? if ($print_correction): ?> + <?= _('Nicht zugeordnete Antworten:') ?> + <? else: ?> + <?= _('Antwortmöglichkeiten:') ?> + <? endif ?> + </div> + + <ol> + <? foreach ($exercise->availableAnswers($solution) as $item): ?> + <li> + <span class="math-tex"><?= htmlReady($item) ?></span> + </li> + <? endforeach ?> + </ol> +<? endif ?> + +<?= $this->render_partial('exercises/evaluation_mode_info', ['evaluation_mode' => false]) ?> diff --git a/app/views/vips/exercises/ClozeTask/solve.php b/app/views/vips/exercises/ClozeTask/solve.php new file mode 100644 index 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=""> </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"> + <?= _('… 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): ?> + – + <? elseif ($assignment_attempt->end < time()): ?> + <?= _('beendet') ?> + <? else: ?> + <?= _('angefangen') ?> + <? endif ?> + <? elseif ($assignment->isFinished($user_id)): ?> + <?= _('beendet') ?> + <? else: ?> + <? $num_solutions = $assignment->countSolutions($user_id) ?> + <? if ($num_solutions == 0): ?> + – + <? elseif ($num_solutions == count($assignment->test->exercise_refs)): ?> + <?= _('bearbeitet') ?> + <? else: ?> + <?= _('angefangen') ?> + <? endif ?> + <? endif ?> + </td> + <td class="actions"> + <? if ($assignment->active && $assignment->type !== 'exam'): ?> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment->id]), + _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + </table> +<? endforeach ?> diff --git a/app/views/vips/sheets/list_exercises.php b/app/views/vips/sheets/list_exercises.php new file mode 100644 index 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) ?> – + <?= 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 : ?> + – + <? endif ?> + </td> + + <? /* progress */ ?> + <td style="text-align: center;"> + <? if ($overall_max_points != 0) : ?> + <? $value = round($progress / $overall_max_points * 100) ?> + <progress class="assignment" value="<?= $value ?>" max="100" title="<?= $value ?> %"><?= $value ?> %</progress> + <? else : ?> + – + <? endif ?> + </td> + + <? /* uncorrected solutions */ ?> + <td style="text-align: center;"> + <? if ($uncorrected_solutions > 0) : ?> + <?= $uncorrected_solutions ?> + <? else : ?> + – + <? endif ?> + </td> + + <? /* unanswered exercises */ ?> + <td style="text-align: center;"> + <? if ($unanswered_exercises > 0) : ?> + <?= $unanswered_exercises ?> + <? else : ?> + – + <? endif ?> + </td> + + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? if ($assignment->type === 'exam' && $view !== 'pending') : ?> + <? if ($assignment->isRunning()) : ?> + <? $menu->addLink($controller->url_for('vips/solutions/edit_assignment_attempt', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + _('Abgabezeitpunkt bearbeiten'), Icon::create('edit'), ['data-dialog' => 'size=auto'] + ) ?> + <? $menu->addButton('reset', _('Teilnahme und Lösungen zurücksetzen'), Icon::create('refresh'), [ + 'form' => 'post_form', + 'formaction' => $controller->url_for('vips/solutions/delete_solutions', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + 'data-confirm' => _('Achtung: Wenn Sie die Teilnahme zurücksetzen, werden alle Lösungen der teilnehmenden Person archiviert!') + ]) ?> + <? endif ?> + <? $menu->addLink($controller->url_for('vips/solutions/show_assignment_log', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]), + _('Abgabeprotokoll anzeigen'), Icon::create('log'), ['data-dialog' => 'size=auto'] + ) ?> + <? endif ?> + <? if ($uploaded_files > 0): ?> + <? $menu->addLink($controller->url_for('vips/solutions/download_uploads', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id']]), + _('Abgegebene Dateien herunterladen'), Icon::create('download') + ) ?> + <? endif ?> + <? $menu->addLink($controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $assignment_id, 'user_ids[]' => $solver['user_id'], 'print_files' => 1, 'print_correction' => !$view]), + _('Aufgabenblatt drucken'), Icon::create('print'), ['target' => '_blank'] + ) ?> + <? if ($solver['type'] == 'single') : ?> + <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $solver['username']]), + sprintf(_('Nachricht an „%s“ schreiben'), $solver['name']), Icon::create('mail'), ['data-dialog' => ''] + ) ?> + <? elseif ($solver['type'] == 'group') : ?> + <? $receivers = array_column($solver['members'], 'username') ?> + <? $menu->addLink(URLHelper::getURL('dispatch.php/messages/write', ['rec_uname' => $receivers]), + _('Nachricht an die Gruppe schreiben'), Icon::create('mail'), ['data-dialog' => ''] + ) ?> + <? if ($assignment->isFinished()) : ?> + <? $menu->addLink($controller->url_for('vips/solutions/edit_group_dialog', ['assignment_id' => $assignment_id, 'solver_id' => $solver['user_id'], 'view' => $view]), + _('Personen aus der Gruppe entfernen'), Icon::create('community'), ['data-dialog' => 'size=auto'] + ) ?> + <? endif ?> + <? endif ?> + <?= $menu->render() ?> + </td> + </tr> + + <tr class="nohover"> + <td colspan="2"></td> + <td colspan="7"> + <table class="smaller" style="width: 100%;"> + <tr> + <? $col_count = 0; ?> + <? foreach ($exercises as $exercise) : ?> + <td class="solution-col-5" style="padding: 2px;"> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise['id'], 'solver_id' => $solver['user_id'], 'view' => $view]) ?>"> + <? if (!isset($solutions[$solver['id']][$exercise['id']])) : ?> + <? $class = 'solution-none'; ?> + <? elseif (!$solutions[$solver['id']][$exercise['id']]['corrected']) : ?> + <? $class = 'solution-uncorrected'; ?> + <? elseif (!isset($solutions[$solver['id']][$exercise['id']]['grader_id'])) : ?> + <? $class = 'solution-autocorrected'; ?> + <? else : ?> + <? $class = 'solution-corrected'; ?> + <? endif ?> + <span class="<?= $class ?>"> + <?= $exercise['position'] ?>. + <?= htmlReady($exercise['title']) ?> + </span> + </a> + <br> + + <? /* reached / max points */ ?> + <? $max_points = $exercises[$exercise['id']]['points'] ?> + <? if (isset($solutions[$solver['id']][$exercise['id']])) : ?> + <? $points = $solutions[$solver['id']][$exercise['id']]['points'] ?> + <? $title = sprintf('Punkte: %g von %g', $points, $max_points) ?> + <? if ($points > $max_points || $points < 0) : ?> + <span style="color: red;" title="<?= htmlReady($title) ?>"> + (<?= sprintf('%g/%g', $points, $max_points) ?>) + </span> + <? else : ?> + <span title="<?= htmlReady($title) ?>"> + (<?= sprintf('%g/%g', $points, $max_points) ?>) + </span> + <? endif ?> + <? else : ?> + <span class="solution-none"> + (<?= sprintf('%g/%g', 0, $max_points) ?>) + </span> + <? endif ?> + </td> + <? if (++$col_count % 5 == 0): ?> + </tr> + <tr> + <? endif ?> + <? endforeach ?> + </tr> + </table> + </td> + </tr> + <? endforeach ?> + </tbody> + + <? if (count($solvers)): ?> + <tfoot> + <tr> + <td colspan="9"> + <?= Studip\Button::create(_('Drucken'), 'print', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/sheets/print_assignments', ['print_files' => 1, 'print_correction' => !$view]), + 'formmethod' => 'post', + 'formtarget' => '_blank' + ]) ?> + <?= Studip\Button::create(_('Nachricht schreiben'), 'message', [ + 'class' => 'batch_action', + 'formaction' => $controller->url_for('vips/solutions/write_message'), + 'formmethod' => 'post', + 'data-dialog' => '' + ]) ?> + </td> + </tr> + </tfoot> + <? endif ?> + </table> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/assignments.php b/app/views/vips/solutions/assignments.php new file mode 100644 index 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'])): ?> + – + <? elseif ($ass['uncorrected_solutions'] == 0): ?> + <?= Icon::create('accept', Icon::ROLE_STATUS_GREEN)->asImg(['title' => _('ja')]) ?> + <? else : ?> + <?= Icon::create('decline', Icon::ROLE_STATUS_RED)->asImg(['title' => _('nein')]) ?> + <? endif ?> + </td> + + <td style="text-align: center;"> + <? if ($ass['released'] == VipsAssignment::RELEASE_STATUS_POINTS): ?> + <?= _('Punkte') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_COMMENTS): ?> + <?= _('Kommentare') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?> + <?= _('Korrektur') ?> + <? elseif ($ass['released'] == VipsAssignment::RELEASE_STATUS_SAMPLE_SOLUTIONS): ?> + <?= _('Lösungen') ?> + <? else : ?> + – + <? endif ?> + </td> + <td style="text-align: right;"> + <?= sprintf('%g', $ass['max_points']) ?> + </td> + <td style="text-align: right;"> + <? if ($ass['assignment']->type !== 'selftest' && $block->weight === null): ?> + <input type="text" class="percent_input" name="assignment_weight[<?= $ass['assignment']->id ?>]" + value="<?= $use_weighting ? sprintf('%g', $ass['assignment']->weight) : '' ?>"> % + <? endif ?> + </td> + <td class="actions"> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink( + $controller->url_for('vips/solutions/update_released_dialog', ['assignment_ids[]' => $ass['assignment']->id]), + _('Freigabe ändern'), + Icon::create('lock-locked'), + ['data-dialog' => 'size=auto'] + ) ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/edit_assignment', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt bearbeiten'), + Icon::create('edit') + ) ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt drucken'), + Icon::create('print'), + ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + </td> + </tr> + <? endforeach ?> + <? endif ?> + </tbody> + <? endif ?> + <? endforeach ?> + + <tfoot> + <tr> + <td colspan="6"> + <?= Studip\Button::create(_('Freigabe ändern'), 'change_released', [ + 'class' => 'batch_action', + 'formaction' => $controller->update_released_dialogURL(), + 'data-dialog' => 'size=auto' + ]) ?> + </td> + <td style="padding-right: 5px; text-align: right;"> + <?= sprintf('%g', $sum_max_points) ?> + </td> + <td colspan="2" style="text-align: center;"> + <?= Studip\Button::create(_('Speichern'), 'store_weight') ?> + </td> + </tr> + </tfoot> + </table> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/assignments_list_student.php b/app/views/vips/solutions/assignments_list_student.php new file mode 100644 index 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 : ?> + – + <? endif ?> + </td> + <td class="actions"> + <? if ($ass['released'] >= VipsAssignment::RELEASE_STATUS_CORRECTIONS): ?> + <? $menu = ActionMenu::get() ?> + <? $menu->addLink( + $controller->url_for('vips/sheets/print_assignments', ['assignment_id' => $ass['assignment']->id]), + _('Aufgabenblatt drucken'), + Icon::create('print'), + ['target' => '_blank'] + ) ?> + <?= $menu->render() ?> + <? endif ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + <? endforeach ?> + + <tfoot> + <tr> + <td colspan="3"></td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%g', $sum_reached_points) ?> + </td> + <td style="padding: 5px; text-align: center;"> + / + </td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%g', $sum_max_points) ?> + </td> + <td style="padding: 5px; text-align: right;"> + <? if ($sum_max_points != 0) : ?> + <?= sprintf('%.1f %%', round(100 * $sum_reached_points / $sum_max_points, 1)) ?> + <? else : ?> + – + <? endif ?> + </td> + <td> + </td> + </tr> + </tfoot> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/autocorrect_dialog.php b/app/views/vips/solutions/autocorrect_dialog.php new file mode 100644 index 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> + + / + + <? /* previous solver */ ?> + <? if (isset($prev_solver)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $prev_solver['user_id'], 'view' => $view]) ?>"> + <?= Icon::create('arr_1left')->asImg(['title' => _('Voriger Teilnehmer / vorige Teilnehmerin')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der vorhergehenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?> + <? endif ?> + + <? /* overview */ ?> + <a href="<?= $controller->assignment_solutions(['assignment_id' => $assignment_id, 'expand' => $solver_or_group_id, 'view' => $view]) ?>#row_<?= $solver_or_group_id ?>"> + <?= htmlReady($solver_name) ?> + </a> + + <? /* next solver */ ?> + <? if (isset($next_solver)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $exercise_id, 'solver_id' => $next_solver['user_id'], 'view' => $view]) ?>"> + <?= Icon::create('arr_1right')->asImg(['title' => _('Nächster Teilnehmer / nächste Teilnehmerin')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Keiner der nachfolgenden Teilnehmenden hat diese Aufgabe bearbeitet')]) ?> + <? endif ?> + + / + + <? /* previous exercise */ ?> + <? if (isset($prev_exercise)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $prev_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>"> + <?= Icon::create('arr_1left')->asImg(['title' => _('Vorige Aufgabe')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1left', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der vorhergehenden Aufgaben bearbeitet')]) ?> + <? endif ?> + + <? /* exercise name */ ?> + <?= htmlReady($exercise->title) ?> + + <? /* next exercise */ ?> + <? if (isset($next_exercise)): ?> + <a href="<?= $controller->edit_solution(['assignment_id' => $assignment_id, 'exercise_id' => $next_exercise['id'], 'solver_id' => $solver_id, 'view' => $view]) ?>"> + <?= Icon::create('arr_1right')->asImg(['title' => _('Nächste Aufgabe')]) ?> + </a> + <? else: ?> + <?= Icon::create('arr_1right', Icon::ROLE_INACTIVE)->asImg(['title' => _('Die teilnehmende Person hat keine der nachfolgenden Aufgaben bearbeitet')]) ?> + <? endif ?> +</div> + +<form class="default width-1200" action="<?= $controller->store_correction() ?>" data-secure method="POST" enctype="multipart/form-data"> + <?= CSRFProtection::tokenTag() ?> + <input type="hidden" name="solution_id" value="<?= $solution->id ?>"> + <input type="hidden" name="exercise_id" value="<?= $exercise_id ?>"> + <input type="hidden" name="assignment_id" value="<?= $assignment_id ?>"> + <input type="hidden" name="solver_id" value="<?= htmlReady($solver_id) ?>"> + <input type="hidden" name="view" value="<?= htmlReady($view) ?>"> + <input type="hidden" name="max_points" value="<?= $max_points ?>"> + + <?= Studip\Button::createAccept(_('Speichern'), 'store_solution', ['style' => 'display: none;']) ?> + + <?= $this->render_partial('vips/exercises/correct_exercise') ?> + + <fieldset> + <legend> + <?= sprintf(_('Bewertung der Lösung von „%s“'), htmlReady($solver_name)) ?> + <div style="float: right;"> + <? if (isset($solution->grader_id)): ?> + <?= _('Manuell korrigiert') ?> + <? elseif ($solution->state): ?> + <?= _('Automatisch korrigiert') ?> + <? elseif ($solution->id): ?> + <?= _('Unkorrigiert') ?> + <? else: ?> + <?= _('Nicht abgegeben') ?> + <? endif ?> + </div> + </legend> + + <? if ($solution->isArchived()): ?> + <? if ($solution->feedback) : ?> + <div class="label-text"> + <?= _('Anmerkungen zur Lösung') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <?= $this->render_partial('vips/solutions/feedback_files_table') ?> + + <div class="description"> + <?= sprintf(_('Vergebene Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> + <? else: ?> + <label> + <?= _('Anmerkungen zur Lösung') ?> + <textarea name="feedback" class="character_input size-l wysiwyg"><?= wysiwygReady($solution->feedback) ?></textarea> + </label> + + <table class="default"> + <? if ($solution->feedback_folder && count($solution->feedback_folder->file_refs)): ?> + <thead> + <tr> + <th style="width: 50%;"> + <?= _('Dateien zur Korrektur') ?> + </th> + <th style="width: 10%;"> + <?= _('Größe') ?> + </th> + <th style="width: 20%;"> + <?= _('Autor/-in') ?> + </th> + <th style="width: 15%;"> + <?= _('Datum') ?> + </th> + <th class="actions"> + <?= _('Aktionen') ?> + </th> + </tr> + </thead> + + <tbody class="dynamic_list"> + <? foreach ($solution->feedback_folder->file_refs as $file_ref): ?> + <tr class="dynamic_row"> + <td> + <input type="hidden" name="file_ids[]" value="<?= htmlReady($file_ref->id) ?>"> + <a href="<?= htmlReady($file_ref->getDownloadURL()) ?>"> + <?= Icon::create('file')->asImg(['title' => _('Datei herunterladen')]) ?> + <?= htmlReady($file_ref->name) ?> + </a> + </td> + <td> + <?= relsize($file_ref->file->size) ?> + </td> + <td> + <?= htmlReady(get_fullname($file_ref->file->user_id, 'no_title')) ?> + </td> + <td> + <?= date('d.m.Y, H:i', $file_ref->file->mkdate) ?> + </td> + <td class="actions"> + <?= Icon::create('trash')->asInput(['class' => 'delete_dynamic_row', 'title' => _('Datei löschen')]) ?> + </td> + </tr> + <? endforeach ?> + </tbody> + <? endif ?> + + <tfoot> + <tr> + <td colspan="5"> + <?= Studip\Button::create(_('Dateien zur Korrektur hochladen'), '', ['class' => 'vips_file_upload', 'data-label' => _('%d Dateien ausgewählt')]) ?> + <span class="file_upload_hint" style="display: none;"><?= _('Klicken Sie auf „Speichern“, um die gewählten Dateien hochzuladen.') ?></span> + <?= tooltipIcon(sprintf(_('max. %g MB pro Datei'), FileManager::getUploadTypeConfig($assignment->range_id)['file_size'] / 1048576)) ?> + <input class="file_upload attach" style="display: none;" type="file" name="upload[]" multiple> + </td> + </tr> + </tfoot> + </table> + + <? if ($solution->feedback != '' && !Studip\Markup::editorEnabled()): ?> + <div class="label-text"> + <?= _('Textvorschau') ?> + </div> + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <label> + <span class="required"><?= sprintf(_('Vergebene Punkte (von %g)'), $max_points) ?></span> + <input name="reached_points" type="text" class="size-s" pattern="-?[0-9,.]+" data-message="<?= _('Bitte geben Sie eine Zahl ein') ?>" + value="<?= isset($solution->points) ? sprintf('%g', $solution->points) : '' ?>" required> + </label> + <? endif ?> + </fieldset> + + <footer> + <? if ($solution->isArchived()): ?> + <?= Studip\Button::create(_('Als aktuelle Lösung speichern'), 'restore_solution', ['formaction' => $controller->url_for('vips/solutions/restore_solution')]) ?> + <? else: ?> + <?= Studip\Button::createAccept(_('Speichern'), 'store_solution') ?> + <? endif ?> + + <label style="float: right; margin-top: 0.5ex;"> + <input type="checkbox" name="corrected" value="1" <?= !$solution->grader_id || $solution->state ? 'checked' : ''?>> + <?= _('Lösung als korrigiert markieren') ?> + </label> + </footer> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/feedback_files.php b/app/views/vips/solutions/feedback_files.php new file mode 100644 index 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 : ?> + – + <? endif ?> + <? else : ?> + <? if (isset($p['items'][$category][$item['id']]['percent'])) : ?> + <?= sprintf('%.1f %%', $p['items'][$category][$item['id']]['percent']) ?> + <? else : ?> + – + <? endif ?> + <? endif ?> + </td> + <? endforeach ?> + <? endforeach ?> + + <td style="text-align: right; white-space: nowrap;"> + <? if ($display == 'points') : ?> + <? if (isset($p['overall']['points'])): ?> + <?= sprintf('%.1f', $p['overall']['points']) ?> + <? else: ?> + – + <? endif ?> + <? else : ?> + <? if (isset($p['overall']['weighting'])): ?> + <?= sprintf('%.1f %%', $p['overall']['weighting']) ?> + <? else: ?> + – + <? endif ?> + <? endif ?> + </td> + + <? if ($display == 'weighting' && $has_grades) : ?> + <td style="text-align: right;"> + <?= htmlReady($p['grade']) ?> + </td> + <? endif ?> + </tr> + <? endforeach ?> + </tbody> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/show_assignment_log.php b/app/views/vips/solutions/show_assignment_log.php new file mode 100644 index 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 : ?> + – + <? endif ?> + </td> + + <td style="text-align: center;"> + / + </td> + + <td style="text-align: right;"> + <?= sprintf('%g', $item['points']) ?> + </td> + + <td style="text-align: right;"> + <? if (isset($me['items'][$category][$item['id']]['percent'])) : ?> + <?= sprintf('%.1f %%', $me['items'][$category][$item['id']]['percent']) ?> + <? else : ?> + – + <? endif ?> + </td> + + <? if ($use_weighting) : ?> + <td style="text-align: right;"> + <?= sprintf('%.1f %%', $item['weighting']) ?> + </td> + <? endif ?> + </tr> + <? endif ?> + <? endforeach ?> + <? endforeach ?> + </tbody> + + <tfoot> + <tr> + <td colspan="4" style="padding: 5px;"> + <?= _('Prozent, gesamt') ?> + </td> + <td style="padding: 5px; text-align: right;"> + <?= sprintf('%.1f %%', $me['overall']['weighting']) ?> + </td> + <? if ($use_weighting) : ?> + <td></td> + <? endif ?> + </tr> + + <tr style="font-weight: bold;"> + <td colspan="<?= $use_weighting ? 6 : 5 ?>" style="text-align: center;"> + <?= _('Note:') ?> + <?= htmlReady($me['grade']) ?> + <? if ($me['grade_comment'] != '') : ?> + (<?= htmlReady($me['grade_comment']) ?>) + <? endif ?> + </td> + </tr> + </tfoot> + + <? endforeach ?> +</table> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/app/views/vips/solutions/update_released_dialog.php b/app/views/vips/solutions/update_released_dialog.php new file mode 100644 index 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]) ?>"> + • <?= htmlReady($assignment->test->title) ?> • + </a> + </div><!-- + --><div style="display: inline-block; text-align: right; width: 20%;"> + <? if (isset($next_exercise_id)) : ?> + <a href="<?= $controller->view_solution(['assignment_id' => $assignment->id, 'exercise_id' => $next_exercise_id]) ?>"> + <?= _('Nächste Aufgabe') ?> + <?= Icon::create('arr_1right') ?> + </a> + <? endif ?> + </div> +</div> + +<form class="default width-1200"> + <?= $this->render_partial('vips/exercises/correct_exercise') ?> + + <fieldset> + <legend> + <?= sprintf(_('Bewertung der Aufgabe „%s“'), htmlReady($exercise->title)) ?> + <div style="float: right;"> + <? if ($solution->state): ?> + <?= _('Korrigiert') ?> + <? elseif ($solution->id): ?> + <?= _('Unkorrigiert') ?> + <? else: ?> + <?= _('Nicht abgegeben') ?> + <? endif ?> + </div> + </legend> + + <? if ($solution->feedback != '') : ?> + <div class="label-text"> + <?= _('Anmerkung des Korrektors') ?> + + <? if (isset($solution->grader_id) && $assignment->type === 'practice') : ?> + <? $corrector_full_name = get_fullname($solution->grader_id); ?> + (<a href="<?= URLHelper::getLink('dispatch.php/messages/write', ['rec_uname' => get_username($solution->grader_id)]) ?>" + title="<?= htmlReady(sprintf(_('Nachricht an „%s“ schreiben'), $corrector_full_name)) ?>" data-dialog><?= htmlReady($corrector_full_name) ?></a>) + <? endif ?> + </div> + + <div class="vips_output"> + <?= formatReady($solution->feedback) ?> + </div> + <? endif ?> + + <?= $this->render_partial('vips/solutions/feedback_files_table') ?> + + <div class="description"> + <?= sprintf(_('Erreichte Punkte: %g von %g'), $solution->points, $max_points) ?> + </div> + </fieldset> +</form> + +<? setlocale(LC_NUMERIC, 'C') ?> diff --git a/composer.json b/composer.json index 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 „%s“ mehrfach eingetragen.'), + $alternative['index'] + 1, htmlReady($alternative['text'])); + PageLayout::postWarning($message); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate($view, $solution, $assignment, $user_id): \Flexi\Template + { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if ($solution && $solution->id) { + $template->results = $this->evaluateItems($solution); + } + + return $template; + } + + /** + * Return the interaction type of this task (input, select or drag). + */ + public function interactionType(): string + { + return $this->task['layout'] ?? 'input'; + } + + /** + * Check if selection should be offered for the given item. + */ + public function isSelect(string $item, bool $use_default = true): bool + { + if ($use_default && $this->interactionType() === 'select') { + return true; + } + + if (isset($this->task['select'])) { + return in_array($item, $this->task['select']); + } + + return false; + } + + /** + * Returns all currently unassigned answers for the given solution. + */ + public function availableAnswers(?VipsSolution $solution): array + { + $answers = []; + $response = $solution->response ?? []; + + foreach ($this->task['answers'] as $answer) { + foreach ($answer as $option) { + $i = array_search($option['text'], $response); + + if ($i !== false) { + unset($response[$i]); + } else if ($option['text'] !== '') { + $answers[] = $option['text']; + } + } + } + + sort($answers, SORT_LOCALE_STRING); + return $answers; + } + + /** + * Returns all the correct answers for an item in an array. + */ + public function correctAnswers($item): array + { + $answers = []; + + foreach ($this->task['answers'][$item] as $answer) { + if ($answer['score'] == 1) { + $answers[] = $answer['text']; + } + } + + return $answers; + } + + /** + * Calculate the optimal input field size for text exercises. + * + * @param int $item item number + * @return int length of input field in characters + */ + public function getInputWidth($item): int + { + if (isset($this->task['input_width'])) { + return 5 << $this->task['input_width']; + } + + $max = 0; + + foreach ($this->task['answers'][$item] as $option) { + $length = mb_strlen($option['text']); + + if ($length > $max) { + $max = $length; + } + } + + $length = $max ? min(max($max, 6), 48) : 12; + + // possible sizes: 5, 10, 20, 40 + return 5 << ceil(log($length / 6) / log(2)); + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $ignorecase = isset($this->task['compare']) && $this->task['compare'] === 'ignorecase'; + $numeric = isset($this->task['compare']) && $this->task['compare'] === 'numeric'; + + foreach ($this->task['answers'] as $blank => $answer) { + $student_answer = $this->normalizeText($response[$blank] ?? '', $ignorecase); + $options = ['' => 0]; + $points = 0; + $safe = $this->interactionType() !== 'input'; + + foreach ($answer as $option) { // different answer options + if ($numeric && $student_answer !== '') { + $correct_unit = $student_unit = null; + $correct = $this->normalizeFloat($option['text'], $correct_unit); + $student = $this->normalizeFloat($response[$blank], $student_unit); + + if ($correct_unit === $student_unit) { + if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) { + $options[$student_answer] = max($option['score'], $options[$student_answer]); + } else { + $safe = true; + } + } + } else { + $content = $this->normalizeText($option['text'], $ignorecase); + $options[$content] = $option['score']; + } + } + + if (isset($options[$student_answer])) { + $points = $options[$student_answer]; + $safe = true; + } + + $result[] = ['points' => $points, 'safe' => $safe]; + } + + return $result; + } + + + + ####################################### + # # + # h e l p e r f u n c t i o n s # + # # + ####################################### + + + + /** + * Returns the exercise for the lecturer. Clozes are represented by square brackets. + */ + public function getClozeText(): string + { + $is_html = Studip\Markup::isHtml($this->task['text']); + $result = ''; + + foreach (explode('[[]]', $this->task['text']) as $blank => $text) { + $result .= $text; + + if (isset($this->task['answers'][$blank])) { // blank + $answers = []; + $select = $this->isSelect($blank, false) ? ':' : ''; + + foreach ($this->task['answers'][$blank] as $answer) { + $answer_text = $answer['text']; + + if (preg_match('/^$|^[":*~ ]|\||\]\]|[] ]$/', $answer_text)) { + $answer_text = '"' . $answer_text . '"'; + } + + if ($answer['score'] == 0) { + $answers[] = '*' . $answer_text; + } else if ($answer['score'] == 0.5) { + $answers[] = '~' . $answer_text; + } else { + $answers[] = $answer_text; + } + } + + $blank = '[[' . $select . implode('|', $answers) . ']]'; + + if ($is_html) { + $blank = htmlReady($blank); + } + + $result .= $blank; + } + } + + return $result; + } + + + + /** + * Converts plain text ("foo bar [blank] text...") to array. + */ + public function parseClozeText(string $question): void + { + $is_html = Studip\Markup::isHtml($question); + $question = Studip\Markup::purifyHtml($question); + $this->task['text'] = ''; + + // $question_array contains text elements and blanks (surrounded by [[ and ]]). + $parts = preg_split('/(\[\[(?:".*?"|.)*?\]\])/s', $question, -1, PREG_SPLIT_DELIM_CAPTURE); + $select = null; + + foreach ($parts as $part) { + if (preg_match('/^\[\[(.*)\]\]$/s', $part, $matches)) { + $part = preg_replace("/[\t\n\r\xA0]/", ' ', $matches[1]); + $answers = []; + + if ($is_html) { + $part = Studip\Markup::markAsHtml($part); + $part = Studip\Markup::removeHtml($part); + } + + if ($part[0] === ':') { + $select[] = $this->itemCount(); + $part = substr($part, 1); + } + + if ($part !== '') { + preg_match_all('/((?:".*?"|[^|])*)\|/', $part . '|', $matches); + + foreach ($matches[1] as $answer) { + $answer = trim($answer); + $points = 1; + + if ($answer !== '') { + if ($answer[0] === '*') { + $points = 0; + $answer = substr($answer, 1); + } else if ($answer[0] === '~') { + $points = 0.5; + $answer = substr($answer, 1); + } + } + + if (preg_match('/^"(.*)"$/', $answer, $matches)) { + $answer = $matches[1]; + } + + $answers[] = ['text' => $answer, 'score' => $points]; + } + } + + $this->task['answers'][] = $answers; + $this->task['text'] .= '[[]]'; + } else { + $this->task['text'] .= $part; + } + } + + $this->task['select'] = $select; + } + + /** + * Searches in each cloze if an answer alternative is given repatedly. + * + * @return array Either an empty array or an array of arrays, each containing the + * elements 'index' (index of the cloze where the duplicate + * entry was found) and 'text' (text of the duplicate entry). + */ + private function findDuplicateAlternatives(): array + { + $duplicate_alternatives = []; + + foreach ($this->task['answers'] as $index => $answers) { + $alternatives = []; + + foreach ($answers as $answer) { + if (in_array($answer['text'], $alternatives, true)) { + $duplicate_alternatives[] = [ + 'index' => $index, + 'text' => $answer['text'] + ]; + } + + $alternatives[] = $answer['text']; + } + } + + return $duplicate_alternatives; + } +} diff --git a/lib/models/vips/DummyExercise.php b/lib/models/vips/DummyExercise.php new file mode 100644 index 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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	aL9{Rn9Ve;4H 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 R1qKEN22WQ%mvv4FO#qDz5{>`> literal 0 HcmV?d00001 diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_2.png new file mode 100644 index 0000000000000000000000000000000000000000..54322a1a5a7fcbb8e4307d9c72f39d55695202ac GIT binary patch literal 45908 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 literal 0 HcmV?d00001 diff --git a/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png b/public/assets/images/plus/screenshots/Vips/Vips_preview_3.png new file mode 100644 index 0000000000000000000000000000000000000000..7b1c2b2b84c4f2680c923ea8d828ee4a82f77dd0 GIT binary patch literal 56688 zcmeAS@N?(olHy`uVBq!ia0y~yU}a!nU`pa(W?*1gzi_@30|V3F0G|+71_lOBUO9e# z5dk57eo<3iaeEOFAAT_}aY->=Np~q}d0BaBSvfsfMLT6xMI}u)4GmjeO+5>BB_mB$ zZC%Z96(wt3b1lPgO*1naBYg)GH8&$|2NP`zb2SfRMKf~~CsRdzD`O?AWLwh+dD|Qr zhhkO7{7?&XUmFv92ipJ}Jtv#Y20eW*J1ZsE>KJRoFspbEXNw9`Ju7!RH&>$+TTOM( z+DJ#uU}r5guNEI?|2PNRP-mNPXHzpD??6{acMlg$zkVa%+EOP2)4;|QFQW(_k8qFb zcyHSnALmR@d;O3ZMV=<<KK6C)nt9%4_Ti!aA$AD?#z8^;5rMAGL4B^_emQ=60b%YI zksXm?CWQgoIe`|5!M^E1E(Xc#oD+MNc)D68FG)$TNKCYA3b&gXXi^XzS{-Fw678dx zv86oPX?mb@U8GZHTx5J=Tx3dkc6@M8xI<E!zfa~A>zpm6Db6M7mUE&lS43-<rqoVJ zw6`zX9Fy1Cm1aLF)jOr2s5vvHH{HK0)1y5ru(;5#DlaL!I61mxc3x3leo36nER&47 zMSF8i78XZt&i7qh>NmSQY<guvZGCM=ZNZePtlGx>)~2`#^$Bxo{Tf;_PZy>YwXbY$ zZQfate4!-jSY6Ea*0^Qel@lhHuj$TjoV;nmr2bhGnkG+boi?SYdsauw!U>Pta|$<0 zJ?PH8-k&>h(d29W6^j<uotU0mxr1l-thz-DC(U0pZ_47m8y9qKn73xh(yld&RxDgG zaov(>&HFf4u4qd=(6E2$w3Vw@yqsUVX?@?$HFLMF*|ufF+}&$-o?YF$Z1c%2o2MVz zFnRKkvV)svZ`r>2@Rr33&icIG+`D=I<-Pm&oZ7ee?A|q3_pE)ed)l2n>;Lc7Id)|I zwS%jsUtM+Qz^eTxu3tXB`q;^%TW?hCyEXB{$yN8yZFzBK^Zzpm56<s<c6r(5YnRSl zfA-?q-b=S0{lC@!_U_RScX!`-wf*jsH;<n_`u}9z^EdB4e7*hg%jb{ZpZ)*-^83$k zzkhxF{`=qm|Nm!iuqp;+fC7)mVg?3oVGw3ym^DX&fq{X&#M9T6{TUZKkFL3wa>-%_ z1_cIB7srr_TW|K(<_L%1`Y(I(zf0E?d%4umzwhpv?YCxiSz!J}*LmWDFb^5i-Mmlq za%P)8Vbnipv&3)8y6j17_RQ8fa;H>F|J-S{rE7(SHZZ^4=Be2t5xODNS+FZobVcxs zSB^jY?tl67=&SsVqS#8?s&%jD7(bTW-@>4%`lN=rZuZABGf$pTzyCVsxvKY*M~@yw zEjjgQc|&)1H}iyU>XEM*SsvVf=ePVr;>}H}-dxi}bfQ0bFz#vj&nap%uVQz}%}t>S zj+Zux=(f%eT~#U)wX;aq=FtDGT)7LX5)Zdc_0K!8MEp|bbM5tezWnJ6pY-L=<R{8? zwFQNR4CjIl*k9Px<j(aVkoADO#{X%jT7oa+-kfB@5Hn4%K|4I?$I2hu!w&A>nt5Wn zWW$O5(}R9~|8p+ff??+l3x@XxSj7zfU0mM8ax=_s9)on&)ga0I7gE!ger#AhZ7I9L z2mY;Pxm^FR?PHizZM63PWQG}C2V_s|Px<q<=2PXju6X&F82;8rAAj>62wrZ$5ci4W zsDhM3XHLKUq9^x*vKUwsMS1HlOC4C6$@}0|sP^==zWfKaN^|@VJ9l7z2t(7^+dnUz zOZm6=%aR_Ky1P1Mu0J&^EJO;Az2|I*I9V>*y2ShZ{{5a0^{aNN8r>ASTCc_2@yFZU zewSEj!%2pitwmRF`LGFSY&Wz0ZzSd5aG!hSpXz^}FBk8XY54v@{Cc+77PI}2xUT%1 z6sFmDf5|yHkI$*IC)>2LJayWyp!>CDi{i!P;QHhI^H)ACS)M2PEpn00Rh~nSs#+)P zx!v>W1*e+rIct0Gmt8X^$ofsVslA_<tMI6&kL^8Ht^InhrufIkaGcq*Ej%FBt#G^9 z&n>rYG8r|Ns6WtQtoS-R+Wt@Xnh%fHM#snhy}i=p&+pl1?3Xf5Ids2;F-BGLKx)d_ zl7IbD2UHl=#cm6#n#yxv>*j~?LJZ|gc<Z0OvVS7a{^0KC^UFTBHRUN58XlW_a(Y>( zxfE|DOTN@M4#AqD*+)wlzOX)?r@^=&zVKuFoQXFXE=c!Rv^>b!Ghfd9Ro{80h9+Ci z2D$fl7%oWm{}6i4&alFwbLH{f4x*oq{r$(o;A1;6ZSyPs`Tp}CT{LD4D7dG~u)gl| z;;N-L_~X?TC+s+++CSlyJ=+1*RUg8)l}%z)*;?-%WUKHn{uW0?s@gxnKe-i}%>Ruf z91h&?o4M-$w|7q!Kg><`-=}V?D(JOIG9viV()vrmbA4jk#S3Fz`*J&MW8WdT=bZIY zZ_Z!rd(+LnFSUD{ciGC}P$etF+hSISKiUc53<YHu<8m0|9kUpg%{j3^AShNpGpD?Y zp)1d^;Jzrs`g>mGcJ+VUIvt+eUm1MnpC-?OO<%c>&gOBLW)wF`tYP)EWlRNb|JL5! z@ag3Av?ueK8l+eJpS|%{zW#dVJ>C1CRvkR{E^u#j-7y6{6&KgF@g~!!OyStqV77oE zfhR$H180@O<ojzjvq}U{n0G7V&f3en7jHPQy7U+8{5bAHo|<K)LhLUtPJJ==qmcd^ zGnWq!F1~&{<zU_3YkAE^6<@8YIr6mk$1P`A5Z+@^YPBKYL+a*puZ>?P>{eVZw)1tN zwf6=OD@XQA!VGLBEswYSu#;7te*En+_XiuRS?qU;M_$Zn3VZJx**lkg<(?wJhrxH& z2^2m)yXJGZ+4|mlPgolKHbvTSSF{ueDf{j96WH@ZuTZ39GK*rF<9>yv<g!Ie<LzWr zWV~ZO-12%5@W^lPJy~^ip|A@xRl7N!cl^1SpniF=Zlm^@^lRKrezzv@Zd?65dF_0^ z_j`32^SKX1&ph@2@paEX)}b4gEJ!$M)XG>BbJ9MPE9It;W|-gM{aZ8zI_tBIKjyz} zyD9CUa^KrH<p0~Jo)dNp#w4w2Q`5V8#Uj^>;ZbUSU;7+IR)&p-&OdJVFcxYzby5`D z@Z+Ob+j?f^yNmW8dB*>f)!$~(=PpNo6|U|Jj7mn2nrwyGLl^{@Zgog>Tz+5uSaFYO ziQ*31M}<3#nS>aW^I}cX%G?(RY}@gmuYJq63Xyj*^UbB0{#0z)H$$G`$>qtvG+7kt zbnb3_w!_CPp{Ru^pd@MOl3*<<%O_K=sxWN&<mEbLPha#D#ucrf4>Cx6pUSdi^8p5q z^^eN<_j${(-c?y`=aFA3XTz{b*th=YqT6C!zm+d7tjPIT;b4_+XSibVbytypWxuu- z{!9_7m(gsruU8GsbT+Fsc-Ol6<eNU%v-@rfelTO$^K0>pgZ_t)Z;!BR`M|&V0IL>r z!~%}`$vg>7^_xu_B!9Xyeor~}?EK511J-dfcYZw`H;;ASZf~hN$A2>!B?3b3sxx$4 z`t>-fJVfv>Yiojxxwxj{b<<wg$TKgtYBES1Sf#+?6!77ws^7&@Pm3k|Cypw1ia*F_ zSbVZWKq=mB{()`W35q90pDg4#ka5T(;bZD1Ptz$32X@?)Nxvu|;>+uhX)4*UWlxac z(gwSE3=5(svM_q<%w^?lI48lp;FU&A(Fr|{d8~z7Hwd4t`}yV6M82b|`8}TZta-ky z{fX)3_bSW=3wk&6UcA5a-1Fm;A8)=d&9Glk^iQ{@+{@MS>x16A|B+`+Xk>9%`LTEt zqsouyCb5rxJP&;SMDI>~JK4D8zXC@{s)In8{@w{^_KPq1P`>`m{=IWD%a^S1)s9Vk z>6~D==(GBD#-!T#BgvZ&@(OI={x+dR+Wn5%k6FA+`h~ttWN&FXpLuyXtHKQ1wbtj9 zRGq~4O74H}e?@2Nk4I+FJ(oO1{&+DQ+gmpK+}U^D>zNv?_J(|S>(ZZmb{fM1iAz?t zMHjCyY+1^_AjE|$ti-~I!M1ZhlU%He9XC%y;NF`&?ws#K+!YxEO0sh=wlX{D|9-l8 zmF>%bM{>3^YKzXzv9hQ#wpD*7v^Grp#uWyZs976dwZytU`7+aXpO8)Vw`#7&{hsmu zGZh}}UpH@&-UOzAllza$$4|PnD^+vhZ6l5;#*zn8O<3n#VN1JtD%JRN{?j&I=`?A^ z<SDbaeLc<X`HkP>#LTkm;pLtlUQHSd65DrPR&`X0%8yaW=<#Zd@!=N~y2E$SMahY2 z!Ev!u4;Va6mxS-zd4aKN@~UfwEXSnmUd?UtVz_ljP%=V`;n+;~)T7^*Gb^lFHoe=8 z?`FL;gO}@+gR{gM3ayVcz0omXsCrxY&979%h(Y{}s*}IT@2BdP4n=M+w=O<W%+ji{ z@;=8ZMvvE1magXTkB@oH{o<V^XOXw$zke04XRi<3E@U~e!lx&8>ZABI%sLTjj0UQI zrrth~Q2(&W$F$*+*U3Mpr~Er`OkX<e?2-MvCj|@IK7?oeU9_RT+LGbD;_URxmCbdR zc#dWEAH6HmAU8?5^F(RG8cCl8EWEw;e2i<N`#4K?SuArn$nf(DL(2#KcS{()%vh|* z%HZ4(5%pTQp<0Eb?A&_(F2Vd5R)>fe{mt!NlW!SIHhi4aAaPNfAwsBkgVg~IZ|TdA z+HY*T%Fyy7UQcS1$)rtE*8D-0OO=$~zP!(xbLpY*6$TcwT}w9~V31%DUs9axu*RyZ zmCNDVY4N$+ILrzro%8c>@%Qjsa!$PacgAjwtQQ-KX2ga(5muigxH<mTgtKC-|2ssN ze5`Odr+MGict*a;zr@n}q0c@wl!x#Cr}&VOlkw)HrY5Ge*K(dK|4rsmczS=*88(Bh z;&*x;zUO%vzm$2(kL|{_KfG7}FI(|ep8bKX;05l7U2;qohD?Eq7g*liT@k><bs{oh zrDDmM%x%><|5ik=pDw-K^~C+`9h1Dzcrx(3$n|^1;9RxVozua38DD0q;GRzh#A==i z?N#S&kO=qs8QjYJ<<;A~a;ApBy$OB63|)$s{>>1bpL2UIlfw~F?O87_Hwk;j_0DCV z85Sh?@kGJl8SJLjxt<Iy`}yk1#3UFDm8CBIoErI{l?N0mi}U85lbgtQH2?DB`9?)b z%w}u*V#EVyxi|YizU11<@a5qBr8|zR<(_!9G1E|$Pu)G6TaBS&Iq$;#VpD$vZ?F9M z%YCQY2Y1eTo`xkKy^CIpUt=rK^sPIS!7yEoxq$6oqWpw2FYlYM894vY-dg8*2oxg0 z7xLl`)Si?(v*`E)CdSy7_}EE%wrNPbzn!x0{SNIrSL;35KE%f_FZpAky1lP+VvGT^ z!dq7MhwmqC`FnfC-^qXC+u{mV8-2*Xx@yY1{A|x($zgvcZ<enPK3=Ez@O@v&pT`V) zzI)sdd!C+{C%WO~*KZ2;{GHuAO6;4Tn_a&t%infDiy`?DfB5CEe{H{Rjp6^XmBpcJ zYshc)PO1G(J%7A=nkFzLG}W)Z)Uto|yqKNezI}Y(=JNJF&&~TGe+n6j3T>a9VmYv0 zwfVdAvWtRGOrI#sc<}b4`=tZ9nor{!o9ZQb7fh-T`c?k+R?5HJ2=7(@(<c5?_;dTi zA-`MS&3%9TZo2jF?cLLiCys7C!T5vOuwmnm{&3^o|2-@!uRHFmF#O<X+y64keDUAO z3^ODQ)O;7Ya2}Z2Q@?uBA9>FD>+52FK3T=Pc{RfwhHw18_buhG51zDNrXeP#TdE;` z^0PXXldI3v%?aXZEq!%mtqS*!mQ?#o6L??$<H}=qs9bU7%7<`Phc5e|zuU_WzcD(H z@!#m{-@CI9{sVbSH;Cci0V9R`bAop4TOD6NSAvr}@Q3o|hBaanZtQO=Ntyg{`3lB4 z2N{-y{a^ZtfA#-s>+WtyGz)D|IX-dW`}|`crZZ3I&YsBhA<O4enbH4s_ZB^7+%$26 z$Lsy;B^mMq8Jhk^PwJ~b@^9m_`XJ>OCXW+zLwi|tzyDKsmmiw-V|UTpw+ttE6mHlH z{d*V6vTM!9`{%;b4#~4Ch+kjC^1yu4*2$BVI{g&wH~h<Qut^iyXlk;O*@yjrs?Hx@ zuf{ve*a~JY`qj^RV717@_ovjuH%<)T6f}Nj*YS6<d)>X+%Rb)c`SF<H$)l(#411=g zF!<Ltzm0WSd9MG<KL>_Ms}9W&VNRAlF!QIow4->+Dn_3#Opz;}-PhXs_wXzGmv7Io z3B0}Ux21m4_1mk@)xDWJu^t@Yf(<-n+b7N1BXD%-GQ$QN&5(UZ8D=Cgr2f*(X5da+ z!MMazAoQ2>$^BcqK9s*bV=0jJ-{AMbUv+N+KJ?$%FUtu{1V<S?Rx@y36A|1{72>e+ zVfP#kvHA%NGiKIvGVgd|#wKtmUiAMpFTnz?|9Qy_PjA`JdgbxJZ&UsHJx0d={~cJ* zueZ*X-C^1tfyd#g%qyfsLoa@t86tM5`jgsC2CcXE-v%8xox;3g&A;F<MzurnVSmea zt2K1kKatO5Uh(8UcT0VBe)|7^AK3XE^wzm@9LT@$PjeMRaQo5eyY|<vJ=3JJ%qb}R z-&r-*IR*`De&tUM;{3f`OYq;ixi=Z!9l5n%cFVW?cB8u=|9sZhESw=(pUEKc;L)R~ z2fK6`mL4#>sOz%U<$3>VrL7I&na&xkH@T*GrPQatVs&Sjab*A2FB2c?3;w&ejq?nj z=AXL@pWXLM`~PCMtnovASq9~pm>BM;?d%WC|10g9Dj}g3xm}1sWGX|<B))nhCT^o` zjf{T;nRnzoJr#QLoBu(3v48jM7$<G0x4y#mVd-<v>H{}#*n_-ul;gmZPQ!Fghnoy~ zo(t!15{~($bs*G2q;A)T@~f+OPu}dmE$Q&!zTd5{(?kES-2LxeZE6`fojhWgQ_2yu zVBH^XH;Ml?i|z?u)MS{EsBq=WA8+Y@>*g+GGf4bj_C_JhKTYAre%bBWzm^{WB~@|u z2X7`TwB&61TijK@L|^G3ll`7JqsuWfBpBAm>=gO9y(Dwd&-g?C)&{lg&o)ol*0AI) zx4;M0ClC7e>M$KMYj|~Qf8>LH(4YjfL9FqN;Fq%xu2_73$9$W=fhA%(mrs{AWJ){g z$8IgT`S`TQZgDg14`;sVPi9oHwHK(@PZZ%P>tE{dZQrKvl~e!D=KTlh{u`UM*tPO5 zOzv#EuxgsVLjD{T=RY4)U&S5B<W*?d@3Cj;ng5(YZ||=X`EBkjs_^(ecgFv+cRmsa zQhskQFZ%<^G>;g3E{pmxEm)-;Q}pR^{~~F*=|}fQ@J4KS(qFjOUhZoX;}-VEg4<6q z=q>&ey{ja1(qCoeMAh5-eR3H*KQ-UlFRR78<8$5{NB}8hOX;p+cb>3{;bsDd{D#Pf zSGrgp9@PC;$jQ1u;^Uu-+EvqDJeJ#hIegI!_8keSjN2Gvc6pt&=d8~>bbkwj%1QpT z{~8Rc%s+%~?YBM-HS%l2s)rj?R<<AZVqD;UCD};gP&}7|wabBjCydH%G@0Ud7M)y` zs`4vT#bLv@{Lot;%yad3h?l)v@<aJs{|A0X4ORPhZ1pk?FP~hl{^9u`Dup40uS4sx zex<_|5r$VT{*7`v^I05h87}C)I#akm_N@IazJQbVTk2O|O0(Bsm@KKGZl56(VboxK z;()&Od%pVZtPA4xe_Sot@S0`r-Q`j4>Ha@?D>iKO@4PAN^ZR!pSKcR?OPs58UGFeC z`+MYf-}rK{!+(RTlH=91OK<fwUR-{Aysh=ZZs*PWi)`|OY>e-IeEU>9C*^fqnadUv zNj~f5Jx!m>RzF~Q9W~p%P{E0Fj&g01y_|0QH)kgOwI!!7p1wZwU6NPKytwx<mJTw1 zT6sF_y%Z}w{_tO7-f%#kQG5mSo5~}ptSk+u=Pat5X{&Lnw85LZq2Al`Z9G$h$<N(g zH}<Dy2Tzq@*#Cj)WaWu({$>ps|KoN{o>pJZ<e-12(u<*OPSXP8Y}Tu~uSH81hA_OE z<2O6(!q3aei&b2%8@XR%HdtZmekO{|Vf`wemu1rh8_br+YR^dbcd(5MXWU-@jbX`h zCsv2`59Sv$+?s24HIkiyZOVS><q6N<R7i+i;^t&H^mMg%@lI{V6`Rj;GMxNzzTSGO z&dSOM{qjpn8@xS&4|K@yZd765Snp6ZKVum~P0G_rOMQy?QgnOk!WaIHo_g%RD&q%M zj(S6eX>adqTYLBmRMfrvxqEy4cIE}q7hZ;#8`m^UJG)v`q)zz>?~=lXAJv>UFaP_d zBxwA1U*v~0h6ELnL-k+8--bC`oPB4+Bw!}4BbX+8jh)5#rMKK3W`;>hs~KMG<7fEt zc;|(c3=$uIPDxii@$jR;3B4Oa3|}T_oHS-;a6UAL!GQU{(B-5chPnx_7^b^6FA~0e z;DW%9e(!0zoNS!mOO!gBxWnw6(wGw&j;J+wXTFu+TCaV1$Nnd71~2|;{8Rbu&a2>C z_h#<Sm#5|b6<lZda-+%Y=~)@A#3M}2;!5Aw%G=2NE1&dm?;OG9=aw@l{ImS*@~14e zQ__y_MoCn3!`;tSPW-tVPp)xBJbflsW489ZuEkqbt0mK-<D29k>qqs^{`P~#T&Jk* z#5*N9+cj=CrM4VAzGLr#;MH3X)}67MA16L#{Q?dnn<q!^wtT26%r2GNA9<l#xSH|Z zEY=%q-Dj=2=eyp3pSPg-%i#sW{r8x;GZQr#7M9p?Ib>#U+4Je;M^3r)&e^+6e*P}0 zFQ`d7_$Gxx<-YKq%zYk=FWeb^J<NE^$aO%7@f_QYoC)&g-02b84<sj?7rpK@+5N+g zPzDyIU82)jejoLkzMNg#{lTUWv%E{XR?V|!Utpu2CiFidXvRl<UIy+xNhOj|j$8*8 zeD43Zqju)y|L%Luqnv((Pk12T*)V<4-^GUyEZJPY^02~+Mfc}&GF;lGe`5dM^6ve& z)g8}xG5m^n(WUZbHN%U~6*g8o_RripnSo>bF5RTdmUnkeQfN5wcw?pL)cOd~4DmT~ z)-@+}9ro}rh`h;*d3N#E-KraM{}$C&?O<WDW4w{tmjB@0o)4?bFWoY~A+l~2`;BQk zY$IYeFVJ0h?6TSp<~sEpCWoAMeQU;s555rxUpne|RZlFFZkciC`DaT>hP^r!d<PFQ zCOLC8WUdr#J9I-)g<pu_RI1w3(3mYzT-K@#Hxqxg3T=-*&G6#ufvxeA-gP%v#UF6z zk7HMllApvNap_?7+6R0O)0kB^n95pazlkZ--SS5|rtDn``-(raPt<L^^i;BhJ8k{+ z1sB$?VA<)+u<2*N(Fgx^jDH?9o0K*Pe5>+Q&fsz=H;`j`y!^B4;yPo_`spiwZZFY1 zEH80z%eVeihe_7^_D=Aq^Z0XH({=xs|92m}=VN>^RoQn(btr?%nda|~@h+?mE2r`Q z+tA7q8z889YvLkD`$+%w&HNkO?)RFnZNIzI?uNoo@ASot3w$#zY&-2|mhr@X-N9{= zU8b?gcFGD@ws@&Usm<P+0!j;&#k{(<73zP=%Ph3{!u5Ho$|N4ey=DtoW^7?$;CO#V zas8q*TlSplzNUZm^2z(@zn#BaKIHka-~6t)r%=m*3AwkF885U)7diFxM{j=k?V|dr z=Uv%}nk$;CT^JpVuQASY?(#mQwtvBN^`9zBj@th?=Ms8;E~7#1pWg>{XV!0@@!$J3 z!^?;tB_?a^yQ)I<Et39={BdU9QO10lDScagmz2ZK`|FK<-JRG`7k{%_{jS}!1B`B$ ztR0-D1{BFElr|{#`?i~=nk_u?ZmRa%7KKIJP3I;F?bz!)#r=t(xy+|qC8{s}U12z~ z%kCJPt@l<d&F^0r_%~_JUUb6j;7eC`mjg}tdwCknPRVELf6AN6Aj0C>HEV;b<RLd9 z2A-Q2*wnU7V{(XAVQt8%x7w2?T-w0Wnz@f-#penZ*)^+|E<dfyXrM8pnQ^a~i<$Q} zw$&_8i;r&IAf%9XP@2O+Q6w;>QE^jfywH#CsrvqHE>)2~-U&6ld~|>P(tU+N41XNm z81yE(J8$)_<k$QiIAMXjX~U+!(K8SJ+bDHltLC@-=YCuNDqnoBy+rcN-N*0w7&#bI z+N>OY++NDNOvTYdR^d<i?>iy_O+I|qVa-1;?7z6gS%_h7ldMjf8-p8Do~E_i3Ma-F z3ugy0T@Y3j%wl8ks`lh|n9aT@?&gMF%94y7?wMgZZ`Uz;{BK*rzQDd|q0P1QM~<fy z4c741bJvt?j^aDOn#ZuzDr5N~J_8|7ZilkUN&X59To+D1I>%6<75u0A>uY(Ty-msC z_7i?gXKeW7&`=-3wBbQv=nwD3@hAV;_I=C0ddp`K+lN-!HILah)?1gWJ<ivcW_$SV zqj@v~+uxOu3G(-T3o#sIooH|WO2FdD6o-?CZXL0GE4<+zAM-W=&!4LpB^I4dVfZzP zQR1NOuT-<2o*X@;3I{!e7#<#+C&U!YaH`sq|3HVaqgv4Y7t0v4xfnLSoR*XwRQdL# zOmMyY_w>acC2Ei6Z;4l7P<iPfWc=9Ci($*mzXt<cxK`}_5>mjRcSXxVpJUyv2C0Tu z-c5`X;^vvKsMUKomrrN$?5ba1_xJ2t`J}EuK^NBlb{_rqX7U|XDu3KVZt^EFT>PiI z^xON7OII)Hyl{Cd<CHu1xi|d|{$Zc<>yu6PuT78deJGb^XfxRS@Egk$w$nw{4(4W8 zn_aEg9JVKXtFWna6>@p5k~`zijvMV?zc}u_y?VuN<6!d_B4_fJpPR<xu=d*PtKJMO z@00mgFuZ8n`*H;{57&}s@%<m0&-ONWFeNNt>5=zsYTyoK47k<JvxFh%j54#pKG&6f z>Ia)oT(7+2B*l>AGU<kt?)sXlDHp=)<i9^Z>hJZ${zusjZ-cYqnPNAzGnxww{LB}{ zGbMO-UVi)WqJTw1eu+-$5yl+>Q>U=@Y2=xTGMs$M8NTLn@AZkd*IsXZ$JB80<NLKs z3IqQyeR7|j^(A|g-(0RvlRD2$_1TjD41Oy5woJMC_hO%XPyMMM;@bb?Cb`w_bN_Rf zt>Mh1-%4h4zid3u7IIn0{<-n=7kjO^9E`JVA6@Y}n;>NCcxRW;?Da={rS_-<eJ!wV z=Scp_wC%MAS7WU}Xw_Ms2FBvfo6FZRZ<(5r`^M<w!=Sx(PP2C%;G4wY@#w3Iu|z6| z&Rf@ex@ONewyH2FZFE|)!sUbP+=S1|m=|P$>&L(XpRcvAdOqF>U}#%1dG51rmWCTH zJzGi)tA+R`Y_s3wI%(Fy0tP!DhLCt6A%>SvUtXJ(W|+3{$g|zW>=jz74EtJ@<u%$^ zLq6&&GfZY@s(JEJ<ahhj`Xz#2PN|3M#ZJBWKX*axgNS3(pVp-^IDPrewDynkpWW$y z?xw~k)ZaQbli%I;%b_fr=Zpz$2No9P6lj`6GtBckTYtx^hnqt`uh!vbxt21+bd6<9 z6Muw%sHpnAeTu66lFEkg)KiSwTK`Y1o@+gM*1k74KAzh9)b3rO0n7fl2eJP0JPfyN z;?(m$9xrWYuu-+2cZ$KG;@8LQoqxTLSvg2Huo^L3zOjGnnSUoQzW4dKUg_)aLwsxw zOpZtHf3`mOZ(<|E^M<<m8#^cWvu)nMyZZQ=tFvtPoM*otYjL22^Fee8OU;}Azt6G< z{eC`^(Zg7QHAibnle?Uz$5HuQHjaz$C$apyBpfjDcS0^hC!-6$ht~gZ?@n~=-&@`T z=?-!^2r(>YRZ#fQzTSSqul;jp9b`26rpl9G_4|0+!Q(H?4J8tyxH*1QH$L=#ByZgC zLGn@mu|vge_I7fhVF>02D<!QO7#QmRJiK;2+<)W8c)I{iX9gd+>7L*6Bc=Z6#%_I5 zX!_6SBmc+g8|sbzPx|n^edj^>a?t3^WyZ7@3^lK`p4HC$>HX|JyCkoIZ{w0%_L{$9 zXLZy)<unYA`Y-rRbMbxIAhwF<wT1Qd4-^f=uRmf@`0zd?ieZ&COMQWsF~f8bjoS|T z2cP60+@$J#<(vPZ`G@{pyx9=0+TU<KNIvV&#=?Y2AJwG~*x!kL<qR1;Nn&}h@(Xjq z(hvD;>@zZ0d|5B*+oc|wU!-?ttEOkdv|ql*<a2qHZ||R5Jo)Ec%|L-T@K}%)Lz)D` z*Rnsq6>_B=q}c8Moe(npzvRk$p{D&yJ=p?mzj3eqeS2chep#V^9x7`R?!{jA?*)gk zD~E&7|9@}&9^ap0bwJgKk$cA9=tUC0x}G|n&tQeE1+d%j$=_B|&9h-m(C+<LcG<W7 zo_yxM{JX1vE<Pw<-eW(H_1UAS9EK;e0vYSLcI=Q_G<yc~1aPl-LN{S*zO|m79-jlB z11Z!xcEJWly;e|{SxlVa=g*lhQ$AcieDo;e3C0sg(WtE-7&Qu1kANf?cVvh@Wq9)F z5kt(Hq9TT(!a@eUC{~au77XI6OA0}b;5%?si=X|$qsu>>u0OI2;XCkqtpfXlN6ZO1 ziP<$5Wwh8I)K0fN2C|58hX&&c@mej39m)%zs5GkAK6tVGd|m&7?pt>ReO5gBmcwn! z@r>aK!xM%W6NWErkE2xilQkK3+cL0j_j@naGRdOuO=O3h!4ZgA9E=e{Tifq+egD=` zAkHx5L8n9P1cohz3`Gn@40=usR?1D%$#ME@hXR+|Dmihy;c<PG6msGBB0+wp3*!7Y z3+H;ASS@N37cXP~jm1W=hK=Ep=*Qg?J!HgR{S7>_>iFy4-QsMFwK5Gd4g97IyN|Yh zH|A$xsb1cDJB5L(j3fDL;ogMi12<k?d$qVsQNV~h@5c%83!HK5A9OyBO8b=4{GY*L z+ag^x$wL#uFR;zs&~<)Yv_dbVGT1z02Jr{)SN#6R93X!^@W)EV46&R*%LA&41xI3= z=3F_R`9iwzQu=&<uEvL+Yzu7TnC@NDuW;g4=h#-vP{d$&hp*t_%YV!lr2E|#7cN*J z)X-!t#KZ7*OJ2RgtECJ)JHMuA{GBkZfuAYn)0?l#A8&LSG0ZKDxvC#742nR8BK98- zEDuat5xc<R^Dj|`o=ZLj2UZLJ;c3{wQL}K)p9u^c-4~TFiKa4meJf;SoX~zirr{2w zo)!CxB}<wDu1a2PEKFwj(yY&Oi?yS&RFNUULA^rh#kZ2Cn==^_j`+M{YOKg{oxOrt z;1|nWfiF`y%x*g|Dfl-PFch)xcp;N;;B*#KZj-)H5xWM%+#OBo3Ok(Z){Dy~C`hq< z4}a8oq?wE1SIxza$q{zjk~|IMv<i<c&U1X;?edqKvtf@U54%EnL-z(ggSB@U*Dm;4 z`Ce_pkqxuVTb?~)c*0kpXmQ|+)<-v`g{(K{%`9(y{D@)BROUCfb?<)6kNbPJ`@GHP zPp9?w*Zql+irrDrXscS%YNPV<*Yl7MpKeVJ*{FZwwnV#p<nsdc$6uyyd3@P^BlDAH z#ezZxyC-}fZtQ&g_U-=4w_oqueEzgr-~RLL$1fQx9MA68e#y$Pr||KwudlBMNM76N zz}ay)`lQ0%19lS=Py8tVrC!!hW1*wRccA?aqn!P<@6#Wz-(RujyZQc?w%?}TOV0YD zI!C^CpONtYbus%nchya1GB9RV<v5j}qN1ie!P>!2;nf^XsoDjrwl0=w;BR5B5lwnf zym!64b@?0p<9an!ibsEGCSB1E&uEw}*mynf&W=jvgVSf$N-{m>_WTvX>hO-?`Et$& zb9p-L=U!b<Wj|%E_WryKkG03nDvHPHEPt=}{_@5<Q~J52BRrR-G0)MR#a5_Qv-_%E z_OkTgSufWVSXBG`JQ)%eHZAS+ntd{JTiSIklgc07ouanCk-0!m_QC4)+rAs#o4asX z|38mEyOnnDGj`v#Yt6NF>x}2epM1jD*wA42>fgJD#+3{yJWu!v>`L7yD{XzAIYmxw z&5{5qhPKTo*5})3+mth;NN1m)ef8*k=A*j%YN9vga-J7zxY*`t%CLO#<)fCHPa5_; zekmzGi<M!c$;CHI>{Xp*8j7RXe?;ZqH$1hkIP=Z?mSx)MyLzrpUD4(BTC#1ew9$_X zpJEp?gm}JXO<yJDvmlJ=!SXwd=T^VVh&nUlS<$@-r&f0uGX>lU>ghikePkzdg3j*# zDJfwtFAo2G9yd=p%V&8*4VOLV`BOc78!s1EGfd*muHdM#mwhlZxnaMt7yk^Yzf3dx zB(!I0X=(MkYy8iv(M#@S_#nk#(An_e+p+2KM(-~AJIt@Id;II`?P$k$49|nhO$>iU z_~mfS4YwBZHLkO?)^3;DcrwP<@W`@<x>CF`nU`B{?wsO3T~Oe1<m5*ob!GQb4X2sT zo2~op>63=zGj*;!Iiz2%qtCv`!|>X?xt9e*)j#htoyGR+xmC=Hli3UmhujLYTb)}? zOy0O@^fe00o<6BJuWda~=C&i7jun}-CKk%{UY=IAad%=jo5RiJCr_O_ckWV(FiY8X z^Nh9|eG#6Aql-)<Bb|dI16Q)7s8(L?ymUm$#PV%{$*j{#(OS#}R@qT=nijw8J81r^ zsCe0~;>`;WykX$}{b_TN`a%sU4K9`^90wNOS&>yPar3)qnz_&O=LZ}eGLIE+D$@A< zvNKpF_)*Bg!eWMdOPOtyFZZ_|=xWJ7H2dx1d;NcyB|F*w{Bcuyv{&sT?*#S(zk}Em zR3A;%zP`kCef)kt`M8;eF4KGz${RjweDq_8xS{T58f?Un$}VwEs>}DenL~^CTs9|> z?cIrKT}6#XZ+AX2S!1(yW?$;lK3~7ghPr~+@qNA7haTz_9q#7+c~K(b#K|@9GAw2b zC{DXBz-rgI@y@phlX{;s*T2N}U7YJX$!eqFgNG4$IY!#`GG;L{eN7jqFs_hnX1#nR zFV&F!#@fssGHKRHs;%K0b?=@MnlSZ=@9CwDM;}JWvnoVuF&6CF5W#c!-=WI80sjgk zdZg#1v1>eg?ss(8=Hx31hdY~<dehrOcPme=X}uT`muInUY2%c%Iddb+9!)u}GGEIg zkMF?ll7^KN*Gn}#*|~hvzIhYY@7s4T!QjgEX!~@QJ<~)!C5ilb+S?+ivi`}7@STrx zv{>J7XMWPFkY**>c)O*cLRp=8gTB2W6SIF;_fzZ5`qpBn(?sKWYYo>3&p9PE`JY_2 z+Tns?|Ewg#mZ%73^BsE@dLCeCJ1~87!rP5Wu2KvR?aQ>~*T1~8(o;v|r`;MIW`Xb1 zji<33h}ov}reo@sy^3Pm@0_1_aChvVVy-(~txbC7GKLiIr79j`7W?yT7#Vc`xJjH+ zOY7gvEb{sHhs7J18iGHVyKJ>)a)?;V=%;Owqsnj-k{VjyKm5M$hOEM|{U6?6_CLSm z*gm0NySqPDpYmd5a9o$I!sf@Oz+E_*VZ{o;DLx57pIYo{*D*X;KELVXp8I?glHcnw z-k7)4(KVQ%qg?Oa!;1?a)$rcf&*pn?$s9Jxc72l+aWS!HUnkBEmpOfWhtZwb8T=uV zr%r0mX}S6-v31T0Cd*{0D0YiwjjBJ7wg@Z|3E$3K@SwZ3b?4+K+9C}%vlyPKF>eT% zQhvls>g>tW+5L(|OISsWyL>NS5?E!(uxaTT#<tD7Z*n%QRbjADlT~1SP{Pz85UPGk z@j8bWLyK}4(}cMeM-5l{iLyV~Yw0klxWWCBkeXzJxY@dO8mmk^vT{u&dgk8`xa@oO zP)SP2t6y8shprUtnUfjp^z*U*PUe~fMv2P;lb-M{Wti}53R^*D;-1jNt9dJh_AlHa z@$P$GRJ1E2hssIGrUiywCKr7!v0gFOn&3P)Uhl_Y7R3n5qF*M-5t=i8bg?)DEttum z@wQ`X$%+}Sn*uW#f3_7}p2R$73S)%D60atoL*IgBAIvCe&}7h=({^cfnK?5!ekQ*P zDP*{}mN{fg<-UT4mrK|_+-m(gwXKV*`CyX4)@@M%Yrifwxs+P=zU>yzrSoYn3TGD> zge1M6_}c5&#A}V>>k8O^Brp~9xb@XmK8o-QkvZ+fP@t9VHRn{GgI7!O?V}$HlxnS4 z9+b(9V4nUkVn$p3^rIQ~ZcJh8y`Eag9pSa1`|Z)h!YZH6IV;jUc%DD++W6;NL{3o< zql1sekqw=vr%1?ktz?i;WA$WOkftFuInlXXWoikRZLTxV#KT?=VL^GbT)!QSPTI)E z<*+TpujxzXp-xcu>=L6FLymq=YozRhDQQ7lcbhP<O!u}p?l(v1)v4g4j1}uk8(M1T zFMIvI?D2_LT*0oboCmI7Ok8MGa@S*;)^}@ehI<JQ4Iav~9Q7>xxO{%xPNRCh3-i`# zvrIj{tV;UgPcd5t?%R<e){@yL&Pkv0X9$ovbuROLo<Mt1xAd*sj1w(?7M-53r^czv ziGi2(K}n&Xp21qheDCs)Pd^@#xN)@R$Na@7gBb(noH?y0w>&bz_u&+whKD*qlQ!D; z`fZnL5D0vp#2LcSa-!CYP2sknwx(Ld;aR6uxP8O}lr+uGdI{@YdE%cHAvQy2Dx*f; z$$rns2P_OX8Ba0HIdow<lfZ1N)n(JU4n!<$n;Bg6XbMBfJT1Nh)g=wjPgJmIesMOr z`$E%bAw#{(0sG)q(dyb1mIOEUie|z0v(DJ7)vSCKR&L99pt7K$EM4UIk@>6)kIx-g z#Qxy#g@DA6p1jQc`>i{V8L@qR{37u1=b{xG*;fY3l{c(>d0(6BcRPa(6T_AWnX^Zj zlo+CUMSth3F&j*6%;LH?;ghm?-n}Do#n**D9B!Yj#qemhzx`Zy_7`V%bgD3X68K>k zxX@Nzef@8B>9x5=_Ug+kykcq|@fmFWynfSrHm27;OHW>UBOy^3vEu(`2_Dg}9vjav zsJscT@w#`%=fy6ehU=;faluRicC-E2J>JfJaQ$^s-d;XyH<vk9d&L=FTzsiuv8^(v z>7IU9)P9LgFVDn%)2RAtRo%fnp_{oN(aOO}<o?bnZ{Ba!p8sE#<xUcVjNCR4(*}kE zoRSTk`YUqSl+)dWw)yiO$mU|M_-rNX_VCu!*ViNTcfaOjIM>dsb5Zf>qo#Y}A7(yw z*jsT2wA{|_V(_DGR<A#ceC2zpOA1*Q+|_k!esgg-cjpgYJ_kL%1Fg>)rZ&f}{PF$i z%ys9sZQJH|UBlwQrDtiApMEGXej2}e;nfC)Ju}}hi86$^>^}KnF^kWp`d4O`c=|rS zWKf>yA;R!Nm@z%@VzT&Orhv2Q%M~|V=Hv?yadv89m=Iz2t3$r~7X!P3IQs*qk_OGg z8zx8{$eu5sm0Y%d?dsLLPCV2wwPc&ctHPqjAoDja=_YSz^$ZcVWk2@U8~nSg@_$`? zIKNVb??joI->nW<ND3XzGqYA+wEhTN5<|&t&X-C~9E=i$S!XXTzQ>cDm~n>j#8Ji_ z4zdfR8WIkki|Ear*4O8FX!oyIU5DGtuk-R)m~B;J+-O=d^|dzZ<us#4E{3}P3xAH= z=Uv)vuED%ge#(mFO%MD7*g592ma2G*U%2SX(jf3oi-AMCs^rbZXSEi2EPM`p2adjA zxZHE3{m<(4?EiTA_>!(|U%Ky-k<A;8+%g_>=?$6+FZnN776$tsnC;oP-pcCVzu)io z{gz|+@z?%e;a@e@N2PA|p$=<aMcB;en_$wc$k6k8Yt&p_A>-YTKRP&kdC?@m$3B(e zi@WRmxT-7cM?0opXDljYuoK~%5W*1kx;Ix>wwdp^g42b7i}p4(72n?-e$PK;R))}# zgp8R#<aF<TKXUc1&&F!6Z+CyZtJd1|&wk1Kn)xxB8kUYL8^3I=ENR%HXu`PQwr*G8 zf!HG}FW+ifbo}L&#E#Vr3y!Zj&iW+q%S`Vta*%}aia~#wghTRPQHC3hY9F4QoZPH- z{P%l#yP6+=zqk9cG>A=Jc=f_hT~?{9y|Z85I@)mHhv%Q*HTBjEap6n_^L{hkzFTOR z@=bq}bR+Ww=7NdV2fBl}Uu5esBygKmw+Tpa{Af*;_{q*<6O(SJz~sADY;x+O>eisu z%=?)G8PY@+K6r8DNXrzNK&u0<Kfh#{%4PYrYPSCqhBJGL4Bv__lN8!roPN7(=fga; z%?ZvP&+i|#_)v0=Z_X~|`emIShj?$!I<g@4(yEJZPCvfc$tUf(_w1x+%nuo24)9Ib zvA_Gv$1anb?OTPM5?B(tmp*<X9$$0OreMaY#6t%Ybf#Uq7B>0mlkfNIR<}sjDqT@# z`19}gdk(&QBbH^0K0Nz=|K1+UTd7-jrHHTaVEP}zS5Wv$kI};J^||bF_5*A7sW3eA zzc0v8_^MBmF(Sgo_O9I&o&z@mEY>vS2{D|kQTTaTrh&hac~8eOeuj#ti%i^~PSu_t z{rcpA`WFoTN({2Q%Z2~(GB897HY70fwjIph|3B=g182+q#2cpzzTf@-UAFqCY?{*~ zMvI48xAWJmS-)iw=bmXEZ1MA-TRE)T&GO()X*t6g#m8xy3@dgTGia@x<`?+YL{2iy z*Zf{X8}o;S1r6eYRpR;r|6=#dU`SZHQ1tNqgOi^-o;}N)z$Dw($K~9<R5a(oLB8h9 zgbknFj4q`7{k*&7{o+5~!rb>|-sT+Mxa{Hox@la~W*;tK-=V-@F-70&a={a(!nT5n zReK66pYiW<nWd?cxri@<Tm6y4gqFgZt7|rx>{(#**1a&^urGjtVM%d$%jZp-ikD3a zS3bOs=c($lcM_+rnPg}%6e{fdrYj}0ZOWQ?HXA#ZHM;Ezxb(-f^6H0aZ{D<Rs9~7= zqenVTxHv=aa#I0=UJbj)1b2DC295>B$EuuDYo)LK=jBuSf3BIegvUaL&wJ&<#fMXj zBn^a(Z0>w}c2;CXt$Wu0ygNUh+fTNi&RF4GRe3pe#hNv1@{g9a#%6)W3mO<amOp%< z$F?q|zx#4zAlNhWz@BkPGl`zJa_x>-x%kj|-xlxCpEX<FnPJmTYmV2G!Y4DHSoTih zy3a8^ZH5zSj}Kg3d{Vf(huLVk%crR?H8Vr!w5i+*(2$?CB{4IJZ>0h_{-=B>fB0zU z<>iIHK4)Lc+K|F=fG>^VJpUETGIML0vtrJy8~7O_0<#Ku_{-%iK76mQ>z-JkB(ksQ z;holQ@$<1iV|X2A*8SgO`ASN5?gE}auM(cF*V1}zxX#SyPCyCYve3pmjCLy+&MZn| zm@~y~J+p^NM{Tr_as36=OFoJxg-f6Gc0SBG>GqZJMam}6hed_*49gw48n>6IGfr4L zjX~iqzvOxYYeok%N%nQ~tTuX-tdZzjydj#eF)`GLmoq2SiXrpxr6V#j4g8lg48#?A z_Emf|xw^6Vx!v}#C@Y??b9JXV-r{1D)8SK4_;5a;McFx;XaA}-8FlqHzCLS|x^6$Q zp25NCfb{&y6GhL>WnNTwT;RvrRr3Tsnp?dp5`1rQD{R}wMT<^csQR$r;L;p{wM+`- z4g5QpQ<5h<>)|(TF*W=Uerl$DLZ?Zv$0rxr;8}ZWRxaz~Ii@~&tv;hep-naigT`5- zMNA3tRSc6>T1;lN@SPW|F2=a^xk8^X!yLKO%i^c<Io#S{V#v<^z}ZCV%*)3i%li%4 z<~cHK3rXUzv9MtJA-!eJmIxWO$wxy1qqq!LA7nUVWZA~fxA*U}*}L!keZIY=(0o73 zi@A%HwdW|uzS_4Waf6ocBc(r+IV=(*{;uxg5<YTmZCmpxJ;l%O7#>&EhloF5*dxxc z&0NH<>u2(Vc~gw*4+v#0oA9L5YMaKfGPQ*TQ4C#gBL!+V?X<qcWH5`x;ZtQ5!!jS8 zralInd8Zit{1^F8kxgJ~fP{K4v&$pJzaQFP#e|pEdb@jzZflXVDXcqU_@ZFW7KXn* z%Ps{4y9NrL6O75ql9PB*AzYGh{XXZvb#tt3)qM4s`fqG__N7%@;w2M9zK4j+TdAoG z9V~lpMt#wKaQ{FeJIg`gu8mCVW9R3yzvw-(H<0f@HRFSjvN9i;Q<`Dt|1f&r@KMwh zF6BwhG_fps7jJcHpUKe<sfLY*)+lDa>x;PdqeLfl>l{17IrAA3;`1b?EoV*mbVTX# z&c_cufBc%k)@7!)R_b(izpJ!RyIO9<tQpS5GsRN&%w;ZUv_5e8c)x7=i^IzwA3rC% za@`v9k`f7?wPGv}em45{P1KL__HFCxI(5lct>TEK(QJPCGLt68n&Rv0nCtid{P|pb z(lc>2UG9xRACHxruZyuLXBC!jIH?!T)FAvoCib^>myg(Shs~;t4n`Iaz8dGt{VMt= zvtXVNyO4-eNrg7wf!&M?W?b(3;qXBt?Vo~h$%UCtlhj^uJa36yDr1u*6)|O*L?Ckj z53}mcJF-c;)$*-Ux1PK+C#jXW$28+pTi0bKgVdyn3-7GCW9K7tS;rtc^c>S_Mh)LH zjh`D69B&m{GKlYBPI<Wg$brYt&qZpl=3(>p?mk&qk??^<py0`$N`?gsH*NZ}^Xk&2 zOD*gRGCx23?mml8#(}xw!GXqp{dv;<^Wx?*d{Elj@_5IWL%p-jcVA$*<mK?Ae_g?% zq|(gTl_x_I4NfrZ`S`O}x;{TE%gUslZvyiN^%q>`OJ|BDJOAACZz}VL<;4xh`(>^5 zA{|&ByfhAT&Tc=-@LxvVPl$o(|2~N|YxLsx*ZtI=x2nb9#+5rZ1~xY;8W;o?KVV)i z>ilZ+@#5wBTE}myuRkZi{6gs;|G&ozE-hYKTH3ekl2!1^Hw^#(J!>q_?=dsIdZmE< zM*wqx%qeDfiDv?j82+4;RY;Uy$;iU6jb~d(q|WSF?g9-R2lV$@Wa`FmulZU3<p1xk zu1mgr$LF_OPpJ2>58|(Qu;|FH?k_K8GJ~8Cw%fj4p=ra$Fh}BT(av2D>|b0qPCqB% zQ_9@;?C@^i*c_cUeg3N^SO5IIJ8Rb3=z1ac7iH64{7n*x>x$d_!o0d9Dm|vhqHvMG z`WF{pzZJZgevO-{k2OQs$wJ@5qvM!&%`>5Bk16wxHu$E0Q(q?Zkm1QAhMEsD3HlH3 zTe{r0bkb!>XkWPMY*HZ)Pmcma`$6V;Yh3^ReRR=1JS1$2Q_h_r<}Z_fuor%~$bIIu zY_fx`-?oZb2_NocCUKvSwM=`f^ld#iqs6VA&(A!sw@=nxdsyw-ZeRC^A2rV$e0_Z{ zGd{R~ZT=C)SIvd%1tv0TR6G)#D}MY$*KOM*2Wdg(j~<>LJkwlUXJ32V@68~RGjYXb z9k26>2i4h16)Uv_!HqTc^NipAE#2+k*ZA?LPx#)00g;jU2g4eguQ2gh@U3HD(EERM z@&A7zk`@OhZj0T`Cv&4!(4esKgV1ET=Lar)<^B3q_PDN`eOXLa{NW-c@%3|Vrf)6l z-L-2C!+~?>rrG8<MtS)*?7zD^HoalG`+b|LUnRU*udl7OpU8Y9+$x@_;ls6;Vt3C; zFd1n4Xh`Ka&|t=wCn$D`foFoH^OAI{N&DRAd9{GLI3F4d8VtWVU->uv+qZA_e^<`t z%qU&4YuBz#n+`cuF)1^+yPf-<ALGaP<HhFGwPNnK!x$9)Sx?x|@Vrmfx{WuBp<%`2 zpwCVU4>w-EA{X~_&+%KyTX*g13CoUdPmShgO;~yK;-1@|#PZlqls`BdYsC2cJmW0E zREOOhtP5@~W?FDt)9q{8X6bZKqwI$#SDW8v%5hRrWmxPKP*wIS$87dZ<^IK|Kz)cG z4i*PAek)e(-Tu9lK_Rfbx;+2=#^hsO3JVSzc`$IW$Vf_9?5O)$-|#Q|-{$jqd3kv^ ze@T7H7k_wvx5Ac&<G+8kb^Lz)`n4OAoVA_G$9@i;XBW+-r5kInxh323X?gq%4-AaD ze&^1eYZVJtu4FToH{Jc=zKylRb~`^g%`1<h;*A*gmT7kJzTCTLTAe9tgX1%Xhc^!| zi+T=DlnZ1MX8a9cU}CK1WW0U*_paT)tE%~Bjl#m;XTD5eU}2djzasvh{OS2j40gZX zbVvK@n(8zCIl%HR=?^o5+>KJ6i`S3;k-vCBfVWwa@xI&n8xe_Frm}C=OG`*ds{GkJ zW!g1MnbKvyUL8B8cFLYb;llbfyUhm#=bn`>;GVH%gX?3t1Bxt11*4f74hpRed*0Xn zC+W@`;kh5qy;;ssax1yeoausW|E;-;zQ|nqnN%1LiS&a-4Z&~P+YY?FTmSOyQZ|8> zYi}do$N#(S)z)NZdBc&RV$mN>h6kTY|1&bwlog-N=4oK(3;4&#kX^R7cF*i82URBF zKg@p`_gg32c~Zc`!~e#vrjCU-q5E*6)*(N&Y0Xow=IZ(;{5kMPP&i^T!-JYHES(lx zRVA(_T?_M`v?FbA>_Y~_h0>RPI+yLeC;R>8qog~@iy3-ud@+mXWD1CXbYx}g%gquS zGE4m+S?LU;-zIrBhYbyP>nCw~1SuWwFZX2FBJ)PV=Kg-UY=-OC?OGXX3a?)iU&%P- zXFJ0ZLj%?aA0Mw#D#-BIRKJ_!@~m$WGR(?{nfpFJm-Wpzefv^EB12DC%QRAkuX*Fz z{jDtfqO(=b&S%~;H_1XW&Ea^FWSn%vxtC#U9hZyv+31^qCKUCA*ehoKHDK6Q{=KaH zzh~!xef##8TzWaduWU<c=~lTvqM!O1K1gyl=<nP1x*>+OL-9l7zPuy`%?}s8dp_g8 zX64d&?aB`J+t+ybd3gA(XU*Cc$;QSevs5&Jft{h@D|4T%7n7Ok*~QFz-nQjb?X}f7 zcy8_W*^|?nK&z7(Vg&dMSQ|9fuuLgFcvpvsjX}<;YDLXoE2aZQEDttw99XNr&x1i@ zdE>{Y(#|h_v^}x1Z+Wm=zV?e*)c#{?Jz2A7Md|z%X;5$bEj?SOPf$UjZxaJUj%l8~ zMZ!%jPfiCrIR)^DK#b*q!f(!3e*bzc((v}K(f?mnGZ<xTZ&p6}QosK9Ql10#?E*h; z#-6ZOd9cnUd_pXH7T*cp{~4dQi?@4KY-US-I_sEQ*2^db206y><_2#vve#_lQD9(~ zIV-?u9rgP3_wUwHlK;!jmdh%zgW!Wl1r5n>+>09CUSSMkI5hilyOp&H!>)5OzrPke zxF5VfVqf0l_a3qfSPqD9SF37DSUh8v^YMsNkAHmJeo!ds+~PLZ&Ako>)E>mM|FUOD zsIQNH{7mUI2g3)0ACB$JG7tXs9&6@XDYju_+GplacF-I*<Btgz4x$X4pMK5wnyv0R z!C6;Ug~{&!QBQ^k@Bi`My<fzV@HSY0(LTKIL$%ny_d(1B*B2^pc^m&;nms}&S^Tkl zltWMb!GDd6Edo3CZ{Ap#9B8~p>fp&|Y%<Je)v_bkUQN8Yr}TB2h+*#?#-f{tKWo9( z0x%R6GTifLKe1tYK>Yfxiy!NnG(11A{D0bJsRK`6Z*%7O^I|g_v&4aP!3LxMGni{G zm$JP4ersLJlNvn+fj9R#u73Facdl>m>TZD!2Zx>}*;0lBy>9Kv=bxSJoo#Mk5SakZ zCG8g(x2ZGSP!DIA#39jsQ0ngXnx9=yPpdN6{eH3dshUBizTM}ZTMU=#6!oqvcGg6f zCq}0^IefaXqx}>AqxfG26&n_vFlqRpe6@k^a^34=y~p_485ua9@jE4OwCrSl0xIg} zB(qx_sZ(LUp=(;F$oRqViF{|n#D-%}_p9VGD6-UXG5k0aEUI?-nfQEGhJDK<c)FPi z!l%CuP(HTXh{e;WIi+NKq!k;x8=HGUo8_72ITrQ*T>d*AZ(up_?9$(_TW|NS?Ys8M zZE>NoaCw7FLySzr_w9Te`WN^=y8m*WSQdl(iJBSer?1&=eK-B|^|j6n|M++kT2Fqu z(|z9V*O~5584c_y401)XFSFK6{9X8F5rYeRglpS0Ms|+bGKv!o@~RI#l~}-2GK1&$ z>({+k**Y4fd#`#jJmCYax?ukB?+Rnkzd-(qKQ}%m|F%|PsQbtokhNh=&D~w4r|UZv zKfIS?W!`b}!<+8&e|~SC#As9Cz<6MtvOAyb0=Wqc0jpSYau|OwGMv27#L|$`b5MG* zEFZh#g5==8|Nj2&{jJN~H_h!<8r$Y$-)}P(X&zzRVZpfLLTNwW2mShm*VgxcZG4?M zRr!JN;luCeTU?9}{P+5k=u>TmhTHP7@mWTa^Qyi@nly0zGGs6-E8W>Aqa<c9@n6IB z*{iMCGQTnJH1Ek}^k2X@XNJYg1uQvcjQ`ghGdrH>?#|8p;BP+riPH9Y6S}hx&c2&h z>iojFeV6C+Sfh@Y8<UTJlayfa|MP9GuzK5l#;eow<Ky@BF>JWQ#1NAo^Z!M*=12Wo zw%QQ~lzmNC{n6iZE9ZrTgyb|1i#G?}9eD2F*XLLB`=}?EL-6am)usRcnVmk6=zdym zr_cvGYX?0(hjmBU4lJ3ckS}X>#ru}tWwSHSU-`;f$bUHb@BTlr8|)l4|L6XD_36{s z{VWXseqGG!UU@a(X4aqg&I+9krc2TdPF1aV-tjtsi(z#%ySoI#dH(tGjtukuXC;+< zS^fX>+wf)&2X=-FH@4Q-uV&R*uypNNj#caSy)OkNnwakMjH`YzZT5M0JAd-K-Rb8- zrpRq!V0c}3nt`KgdFkW-d*4kzJ-wNEjrmL+-QP=3*2cC=FwCzl)b4Fudu^Vw`xB0^ zpqZ%?w$(c_@HI1+^wj+RZY<KEUg$I-jp4z+b${nJH}@URV3d~rE>H+AR!ooc1(?cA zyEEgbz{QD|misPiUbcMx0tOYUkA8QLeJWyju%5AD?N_(twG(~vObiWQDKXqR#jrz^ z<HluXv4C^TlaHUh@lw3$zF<#{dmF#?EZO+K^ZU>B_|<%UyZyc&506PS&)Nvqi2Ixq zzaMBgylL%9LBSiF+O0v0f7V%+HB3&i2vKE-bol))I$ut!_EG2Z`E`#v)$4w<uqsG6 zOvrp(#^kU#bN|-W$Ftrp`n-#giC=a_rg74K3!a+o*RH*5R@kx8Z7R#n7)J(+o|pF% znGQ_8!cfP`@`sCI$H@zA3~REKlLM?}rm4xu>{_|B6>jb2H|}f-kLCM3;(I?-D={8O zoWx;yF@9s6EW?M$<mKI6Z3kK!dPChbS?=xHTXx@0JCpD1tb+-;36U$48JHh5)&&W_ zDB!94Dq2_jDB9sc_zjK?`_|U|uS>2?KmOn(AIAafds&K~oA12;$gtCbVW-%9=8&_T zhbHYjvo2T9i|xVB&xc<y{x;W(kNfv}`HPxoi|5x_Ri!XJXcw7X5E{Dwhtlic8)Z2% z-dH*5D#)7cTDI(r3xmZ4|0oB3o;iOWt>$#PkiUN^!<H-2-=nopzv7iuZ>+PBy_*pI z*k%j!lSd3sqH6d8OttSsKlqcCa(LU$FE5wR_hV8}e873RYx$|u*|D+f=0yoTuk(7v zFtI_8*}{QqckKRusye3MOuU4ngBbFPdU)m^Phsb)EBM6CpzyVy;{fOJ0}V$LwnUxg zU|?t0op$cv!M=0n-0p!(pX_(C3%uB~gdX=dG3)5+PPJXY<B-F9V}I4pU2lJH+g-)b zeA|3lzKxNgmEkKZCI#yZ30s%Q?b+9hUvFnV9ijRl%<1T?V_w1%2R<{OJ<D)_Q6^1b z_JJNImW^BjvkyjS_5SXazJ4WtbHn%71|>Y(N|+e7-}`<sVFxr^J14DnV_T3p*~Q@& zADcDH<YyO_o}T`HUCrNpy<2T<-D6_Ilt1J*F%;}iEqUbgIP6T5NmR9ggBow*24w?D z{tbn*nXCVOi=K6msVw0?zez)UE%&pJtJk}6GQ9qNJ$(9FgFIuNrk}<oWqGe(Y{`2D z_V8;vYlqbJrmq;vuWRZ)=uEuu`@^Dl&tGp_e<N<+-sQ(R1V7AFR$+Sgs;D<grkUZe zv#BXNU*hc<7cVMbn<aHgw&mR0jW-P#7-Z}+ve@`7EDAq8IpQg<=3u}fcVte-;>!N3 zS!?0~(-=ORXS|dsIcU$20I5)7zF%crHCrb<bjqw*p@tu<IUH)%MF;*nUHUZj_QA)c zZ*O0}TFesp+{&V(H`*ig!9u<1r*E;&FP+H3HuH{5^6T7bj2uhcd%m6(JMAc*bouG5 zA6yJCURhZ*{B@G)yJq(K-zL%2uc6ZpZnL!zI{Y9ymy4kYTt1Y>%Oq$qm`}Bv=yTw( zNW<&X*}HqT&e;Fr)vK3QOghpkkE7;FsCdSPi<?bo`!Hk6qr-&^hAeq^t~F}&O7+g2 zJ9o)npY-Q1!WkT<uNF;uEXZBfx_OJFc6iR3HCegFTOP)|$uHeva_xf^xJ3B<nxR}% z`N6ESt_%mPOd0O8%i8_%yL+tG)>h!7)OW$x*RNh>UDt1x^V0r?>uk0|%MaLVOP=a_ zIxTxv)Cq$XbKO=?UGwx+NuuzJ_4|rsO?Z2F=397hX6Quk-?-^eLIX#|hqVX$_`HSJ zgBk?8{)a^598Ofd&ZwpSAtOsi`)-OC!-TJG%uIay|DXF;JEbUfYfr-xuJ=z)oap?w zPVbf}%ZF>JZ>?O^zph=#b0^J_xndKqG{+;W*mdjGG#GXtVC0+s!hEKVgM;(UU#3&p ztfcDQ)0ZE;XvAOKRPga5!%p3(5@n~=EmKRE@X9~ao;AyC`ZP7C2kJ}S9cXua|7HEV znFo&5yI*@>EXcIW>ZHN)X63&LyfZI6lRb1y-`DpHtCR=dj>Uqr`QIr|IAZgGh3P^A z?-Is_*|TK%f}g%Ja^`N^zmj23zOs}1judc<f0E$B(5lzER(1Eg^@SMB!lq5rV)${p zyda?EyQkETxxdcd_>!8mDBV<b!RrSMpYjVHB=B>6aG%##lIv!D(<L`S`6OfimMbPq z{|#EocB%z%9QZ57uwb!>u7bdZdA@E<3^}X}W~RA&K`R;hK@&2C)(5sb%-C4*?PmOb zk4p+mmlkpKu+%6SYWeKH=%w=@!++;gmxB_F%NZ`1aw!_LT`mdvZ7as|Kw-j$!uxis zMHwu9iK;3vJ~&ZOpUKL&pmmqM4FAH#N0SO8X3d)S7@YB#Km4<D=sIxh>=EW`CSjV4 z9ugYD1@>DT7O%VY)^@S*jN2A7A2Iarh`F|wll{h(M-mLoH#py$UB4E!QRw4peGLZn zU2D|X88&2;vNBz0JRI=&n;&1<wg@YkX~)){hg61)75ils921h0c^MN*S##MvezSQ0 zIP7(x{Fn4v;VpYl|GK3oUM8f-@aF#cAMTT6+^ftcZ@Vhepxexs#IVuoLWFl)(2NBP zb7s6SdZfXrt`!zCZJQFKBm;XagM<6$c~DzS^%>rXi9a|$$I4;ari`4NHC0kCzngsH zs{6HFh~a*{{A$Lv$1eK2yW5w_YTm!$Z*_8_)q&<PD+dKxt!2xnebdRiwQK9LjZ2dc z6wYJXu=1Ek_=^;_f}|}%>|8d1|3CMNvOQu<WjJK*eY5!d+6SP3%eG_Kq0ntt&L{Bm z%8i!C29N*$T0Var^Pialjr;$uJ#!#&LqnLAK*IHZbw=3+j0wl<B<mJ8?C-sHb_ugb z@Y!o;864Ul|2grK&*{3GjN7fP`&SiDNpn<t<*nN{QJ6h^{g0_W4wG2em@^z1);$E< zS10q}(B&A*1IyI793rb5YL?FSnf>7YySp>k4?Z(cZ)^A8Q=C+P{l!e>#p*AXynOCI z=fJ_$-=eEee7LZ<<G`)jt+B_>-dM71Sz590;-!n3Z+mSQV83zWa_U<1oLhTWN~X(7 z&Xur@NGv*VscZiFbvjBMG7b!7B@AY>XTP2wz4@7XNrPfMXa?N+K>J-rGtaCkk{K@l z-m?8OeQ#&e!=j=l;Cyf5Hs=Tb{pO^9%U`m8|CXdJtIOtJEj{Ae^kCk++TT?I4Hi6y zo~@m&D>g@9^Za)Uwr$I~yv%oZJ%3gi14}H!fi#AM*RNjv`nAZ5<pIODrq>%7{#)I; zwq~Y2`wL;O31xBzz3*2yPwa>;C}t=sWdE_j+JVhh^G_<nn$@@Wo(#GE#-gTTfr;aR ziH@tUsy^i3%^+VQ8MD~EpdsYanVs9d{r~yYZA0a3#@dbpXG2*Ulz(qmV(7hn+N->| zp}yYj@4Y65mZN{CUORYj+aj(v0+$SLI5K1eF@31Bc8KnL5XfMl@q?i&4YcvLyn%l= zvyaO@BZk%5uY5{(zt&*jpRIL5q)>pJzviQB`s))v^&N!%?_y|J{QFdP^*6^iyZ6@o z{q=S017_*%LAmR;lq952w4HsCA#DA$AMT7CEG!IGZSC$_-%IA$Wa=0_JP{$lusvdy zx3_iFv$KqBtjq7MVOS9Ufz8o|<ypxCzJiSw4p$hz^~`mARp!idr-#MFG;HRpd3C$C z<gpwuH&XbVpYr6vzjd`72QI#Sy7dOjhHYh!zeT+kWKfW1UwF}h(Zj7hbCn(wLtfTB z-H-3j*XOM>IUv{8&TGLEmY%99AfS`%-Mw(jp$i*x@5wBP{!q<g&$=K!yO=xVp2vx` z*KZx*c=g82kzLO4$+een93#%J70z+wbI^OoAg{)*!MI^x6GOrGch8<ZJKQc?opsA+ znHBrSy2I+{PXCc!qksSV>M$9RUFpBSz1`|#%hIshBYncLT$YBpTPAH>lOb#%s<Yt8 ze*5?9R%INU)-cU>#-FwNMv*eIVyCxFTeWD_w!T%*&GP4ovr91jW>=fQV6fymt8-pS zgNO6(2On=S^jaN!Y0BN$vG>U9%eND3_a5(!f57mh%0l+RP5%48KOEk8T7Dn9{|Ekg z!LDNSSZn`$Okd$WU-QdJ^{(q5{B*ZHIDOtotF-j!@8542dcQ6eT`Iu*UQM9njhTsA zRDm6T%KgeC3Xks}>^)r{dGn=}@vEZ9jsMPfys~6s*mB8o+66{FzCNC9g8xEArzUZ^ za2~iXtz~A(#^x8Xt&zE8tLRoHhS|Jr=W;{arsZavoo3jxpYMWtg_B~mCkNw;C4Lv& zGbBXb9AK@u@G>)|=fY<$Mj@@o=`sy5{L7hH)(F0;)p)r#giYv&AVb`)FE`USr_AJP z*sQwXm2idOb3TFDD^8R)xG7s52)dlNXxFYF=iuPz)km2aZ2q&Jt`qntxqZ6+`ZsT^ zoQ^Z9C?Aa2y-T2>J?llwjL-}Q_DB{Vh7+$CmU<n?{mO7)qVQh^haZ|o3z&7*W@WB4 zl9*8c`7y&=lSf|}KC?V}t-|ojLn*?*o8ikv(Z4JWReTQX+OIP%vitKo{{N{L-xr=@ z-E`fE!CsAF4~q<oU2ReIzuHeH)3=AtntF5h@8=Aj&JxL6H_Amno%w1)n1Ay03EP&r zEnYga=e5q(dd7dD)34{n=VxsGc41;b>^fy11_vW&A=U>66nY!Bizc$1i8^TT?{8hS zsV8%JPWHjZgbkVrn^P}u%bgp_rp#1uyT;<cf`reFThbEd9rwFA{T?G*#EsL8KNv!p zT`dp2>t|L`3sCH=`S5?er`(5RhX<<PnmmtgI-kbqaP!{EuM?AUD__pBa_EWvF43Et zn_GR$D*9W<;#b<*2i7&RtK4mz-zKr+R>DS}zMeS;j-6le@7lSyud}nww@GDxkKEYI z{*5J(p`<i&`=f{-A`Q)RPjOUSUX~`=u-}~_N%g^r1&lwh2Zm;Cn&IXBdV8tVtqp7& zR%O}AESUYQZtjtrtzT|2Y&iF_cf~6PO$Oy}LjBis`3o7kjz&gC&ibRcW5%xHso^1h z)^=thlkZ3M)!fs+-*0Vh#mOJNWbTU7hSt4L?;XvPmbz4HHP!Ukwwb1;kFS6Jowl+6 z^sLOQzu#=UB9f7pqsKVo7-QV7ijQk%HN5#~+u6WzfZu<<ub84j+fn)YLt1S4`HZT? z(@*X14-!AKl6ejL#9O<w9v?h+&P@5k`b!Ldj7?7qvxJ+N<>_U+GAaFju<(Dv>$>o_ zpWgmnb^Fw{eDim&4+j35Q2(XTq<LYpqcVfE<bMtZPo6*Nk`1Y%YAgw@jJxY|noG69 z?8CygrbkC#eRlS92>Xi5tr@-xgb#{cnCYyzcY(5Kt>X55EVG%t7+StJ-8(F+z);|A zA|-b@r?Gog?9a+-vq_$lMcbC|DPv_=e6u(|c7n@e=S#c4so12QH)hzpxMX`V<AImD z4{tLX{H%C>k1=jn$;254_SemDYiWLe@}c3`wmDPhX|kwZSXq15T_kh+dd(Kmzz;L_ zr1;4yY^=F@VE?OMe{<)~4Rbo6^oH|~ai+tX<=UrD&0n3zIxFxy`yb<|*xw8dQ*)1f zeZBp<)Jcs9y)>q$WeqG1Q|f)QB^YL>GW?szu;B!YSiy!xtClW(x^~r~<8sj-Z*-Za zR<B}c2>l$&K4YD{%zN${X$B!KpLEsxGbUI3?tlK2aoVBC$y^M6na^YzoOz9P=bq(z zF4QpVPhQrUXG{$zy_gSFu3+}zZ4OR)^j&D;$4IZkE?F~PPVQgX=&itaamLO|@0GWn z`0am1WKJ-91RK+{si95ZJQ&tg{Cc^(-i^T{=E3>8Z=0{KR<wI-apS%{!;kE@MpCi1 z+NZL$Z}VMU7azB8Z&^J5$NF{3dMpXN*&MtKZx)y~%<i5ftMDoR`!>^TEuFrD|GHM? zIviNF$<+S;_T$&que%kzWqFqW)o0J2sdJwx-OeDgU8KwTXTkU9>CZ1}m@jKQo$XyN zUt_VPvoqQ9=F!U>2R_bOBjYst<eq(Z=B#{oC&xi0smMxOEAHIsbMsUBcl56LcqgZD zRlwxivcYTZIpuo~KiVkcn;7xta>l03n@xEil_ZwEo3Kfy)wy++VrKh^)tN0p)y+je zOLhcIzRr-{vOVL$?WDx`y0CRVZwkbwRB8BcEf4w?<6D^6yhVAX$`wYx%m<+n#(clK zwx&NfaXI>~O?Ot#16f^#IXt{F33b+Q3WDyf-@>O+-ZRg{Fu#WD9M|m*3zp+6B!zG8 zoO4Fn>AvoxkABZevrTw6{(j03%%Bl{aR0JdhrSENP1t#$UsSY-rJ|*Fet<@uQ`FfQ zFXsdOmtNLc9oV$Viy<L0FtD^Xb?e4^Su6@+VSjj3`MEsW+1D6HM#=tF`BR#^{_V@i zjd{Bh|L?2a`nrB!p0(=&?*EB=Eu}0gH<grZOYGa3c{bT~sSqP4gQs3axo8bv%-i)5 z%q(|;(@fS?zS;OSd)2ooZ*N}SwtUr^H_M9}WEwUzCv0cSKazRY;?MG}VZ1++OtPz_ zk`f|PdF*6P_bz0Xx$YrWC}a1IEy2f|p(j(X`*Pwh55^hRW|G%>jvlJKyXxz1{o}_> zA4Y@~T14$>jokQ6H>q&9#Jr$s%JG-mv%Rin8g8`S9ov)YziQfZQ<uZhMMop@s&!`B zt3g+@pJWJmGq=V@WcIn-31uqNTNncFG^J{ZFgEc1yeKhcopfKK&^}LYi*+-5eEmJG zCw<di+pNA>!eWkX-*Ohl6J|~EyLlK=o);Nu*VjZbC4`lm=xnUalYMZsTl})rf&Hsy zF0Wc%vwjWlpBY;p2MbI*a$tsO*pnXnnT!o4In%_??PPX&CMw7k=A_e;ufb6N%{VjQ z%Kb;hb5Dj`zn`^XSB&k`+tpRq^yB*(?u76DBmVmIO71sJuC}pZPWERWF)>I>7aF`c z9wt6jOqHSIu%ZXUnhg<rVRm6*Ti34Qxuec@@M`s{?+HO^{-Wg#+>8x(TRrTul8<dZ zlw6tdZqWfgA<<`kD;ZCGU@G6mbKsjn)$y{h_pE#Z+vfC3UtV#|osrLA%drQcYznh{ zQY}A8zbK!nK3C-Pu_6VxUd`$LOi2aW(>F6zB_CBW;!ST2W>|5{S}OXE?(GRNLR<$n zq;aiHyC(9}=FGS0#>coKZs@ij*r1%b|B_XTaK~o0jrrXBrkDlY@N-Tm(^Fq+B*c)` zbK?C~#yAz0Gcy0`I=Iirosv<}X8d9JRJz*P&h1_8pPNff5<j@l<oGdhPsX~}45v?B zThmyd{ebnK@wBy?(SMDXE!X__>fXV`PxaB^;ZcEYlcT?_(Z7Fv^Ivgoo-l?7``&8* z6L(dZabTXZt!*z)LuhrB)!Gj}j1exk55BqQ`{}^h1^4r#nETeBNU)6fe}ij7twh)Q zANREx+Co8#xPCaaAK<;&+S*!K+hd-5w6>$GuprK|as$Joy;~S$CObb^ndDnI!&Zpl z(AxVOT9q7k*6rABVE@|as9zaV!$Z(={!KDVX8GP)c;kZIT<#|?@&a__FJG`aV8~lD zO`oyAN|}Lkk;g)*C%h+B7cz+4=Ki=P#kY0yr^HIXDF=9&qN5zVEHm9N)h#Xc;Hs@z z!8n0^pFrdZv!+6$ck||+_IxQdFPUN4TILXzt)3J0_4Zc0{OrBr42#c}&cGY?pA0Py z^b0nwWcl-=Y}RY51FtsquD;Iv?^?ruCjqfg>FaAWgBS!jBd*`SAOGDYHumnT|JkhP z`uFAk{T0RV;8m3}lfdE!@qdzBD=T&NZf#%x(ejDB%Yn9Sf89Q1olUl%&aflXd;<T+ z>wBudt39}%+#ebh^;*7q&6yo+&L2OC{{MDE>d1$Aws#oyT2wf!*6VhuMt2EHT#l5G z65~7RIiZB%Oge+eQH{3#Hxro*t}^yHhRBGTu4HJ*H&EZ@Ix&3$V}<4N_0ezMo!h-8 z%1<_8oyAJQsWFp+PP}CFm>b``Wnl_~n`s1Z2j7G|bw+cY1*aJOynlx>ZP>f8An1(z zVSCOO49kVtnRll@|5V3l@X}eb>cb2@F*VOe=MCD<x*S-Nb)f%z(ChLqj~BZ~^87iu zm#Lud6~pQ9^%47-1R~n}3Vbil@?u!<hG8Wu%O}f*29bu2oweFwIrqQsp1=NP@b3N+ z2EF+0d8}7|y*+UFxta2}dC|!ko2N~T__1;R|3B#*4u>3^><=AU^jx4JdsP%KgM-1m zt8I%+84esj&?CeYR?Pn6fO&JHwBedY?Q9*Lt<9$oFSqd#=zs3h`A{*4y~0x4W-iZx zHr<U8)^}b#Hr&zsa`Bm|F0u>u*s*MMXk%fx&7GXc@}NS(bD0KvV%rwy4@)dBKMLWi ziQH+kN>b?X&z(s+`)$Q$&zZt#;UcVPS-Zi6wINvGp<k-GhqM>lf(l9fz=-9tYu<dB z!DAi;N{f{~w<d0hS7lhfX>;<U06)Wx`!2ctDtdUk!06`AIqK{h#c#g2KKi7m%6jC) z{>>f^YfkJg`Is~7M+C3K(cPQZwEFD&*6@<?$2y%^{4;~ucPJcT{BgjnMeXKwr`~0X zI<EaHV(*koLz63K&B<dr5V3HX#Wa=!1&L-Zr!HnV=bf8oP&<WDBfU}h^>L|B+e*aM z87x?>T^YVTefq>-^Ry#_##;e_MwfHh0#{F~f8Nx6JX0(p*g}=rV1MJ%>f)|o*##C; z=3c&cS0Pt}wc(4>Y^KB07oYTIm>?!_!EMul9QU04UJR4co+SP`?YQYciLznG{PwQn zONuS~`6f(w^JuBQBZK8Fl@IH6go~z`iKtZeh%lVuU36aSgZmr#S^N9@=d~^G&zP&A z)|MW~x`JVW5~q<lUx8wWwZlD8y}R>|{=UHb?$eANCrpy94|xB7|9`>0KR%g^nhc%X ze`Z``I(7PrtkXHQ58od~GVSlbZ^!k=nW64WVAlGbDhzd#ybk=H!1|1DLN|Y#!fO3( z7K}5#D6-yO>Kz~SuaW)Fj~B1^zF!%AIN;nwHe-1smU+H5!rU_qn$P@Q$k602{O7mD zO4XiCZ|6%je6oJW@SLk5hO3Z4&ui&c*Y|a86W{+gNZfvJv3qrV?a!~@?|E-*oS|Fy z{!RP({eNDaet$7>^A)!VPwh1RK3MqOl;uI-w`p5B&oDSlWmoBcB3j;1%<540^L&ZX zkC%~K_&#j*Hv0STdAqZhaHzwi6^YxVkX%f7T#|GheWe&ydEr_UegzvB1oOXSK& z{Y)QB924^OZT5Veea}3u`f=~}`?cR++}`W{w5DT~_6hZ_bMl_0jb4!9WyTYw_A&`g zbqW6&oH7_1>lqGAk6)_n@_pYwBd&Y<&wM#!e16ZLtZMylTz)+QpC3m4cy-fTygHG2 z{eH&&d<TO+$FkS&{rK&6{+^4!&3xOJ^f4TEYdkqY6g&*YJmIySm4jq`ywLY@kvq4f z^9308{hI&(>b>vkNA8~f&-&~9^!We!S}qDgAFtl~9#P`6_QAz|{l@pzi^ccd`^~oJ zH_IG_1N}D+Y|oVNX><3G`}%b88QaG@Kx4-YPj>z1`*2$S;=jZVTnsf|Lzxcz|8@Sq z%9F+Z|G#FvU(LzHB%HYR-uER-PTcgK&+y<L+wtqwU%q^Nym!XOzY=>?!kC&AYaYK{ zEWo*W)&Y}dxs)u?vygQK*Z-C@%&+a-dH=lK;jHcD_gT-g2^ugyIL%V`zx9i9zyAJp zOb_NCQ2X|6U->WRTLA*U56B)(_@0yhUV86zyWel#1a3O?I$yrMcke&8lkGC0+j{(1 z6*7$vLk-_!c_8-vKmL>6HDBH_PGYEB{QZCW`@6}v>^AR>-~adQo2r7xOCD_Z-C=q0 zw|kH35yz7^GBz+b#va@XTMLksoZ|lVHm8fHO|p)6y4Hp|r8Y)a_v$Gr(sx)SrW{sP zoXqjhgY(_FKR2_uE}zy2zB04q#XV=$6MLqglj({peXlvCd|ScIYab6<W!p}lUvH)+ zo;3OPW|^EnyMJ!%w*R{>eQo+$&PPo1A28SyJvq{;oYBRi@-N?7>p<X{88c`6;JG}< zezrq<dD0^Jr}xu^-#$>^9;{pWD5SdDKEu1D#aK|_!t|9YO%a?*3>pj?I@`HEyge+O zlX%PJIs10?Uk3yC`nf2y-WBB9<IHhD{@>-J!d*-(4ngia_wFrzWOnka^)<VX7c-t` zsK<Qxrs3w*_rUP&+m`8bB{+BX9h`7m-lpOIX|CtotTRd(^!k48J~r>Y{vT)k*2@{k z7Ox6Ay5>sZTjqcKW_#Zr-FvOX_4gO4l{$a-*Vn|H_gZ21Lh}1YALR)iyiGgOekT4; zIUVBPb5LEbA@l!LEp2ZO#``;Y8U8$};ykhC^<R;k5~dIBU9wWLzMV~TGL{!U?6x>> zZe>`-Rrhj_-AB0}`-R`Of0H{{Gt=%ugGoz`Ag9GHPsUBJR=)IhjyTp6&=AR%ux7h> zUF^%Zq1VEAdoD8Dyq?U=|HCVL+Sk(af0$*=rhTfp_SoNgQF+y6&(iMBBPIEZv~$n5 zr9LfW+@Q%N*3T-wjoTrodg7(+$=M8g*<X!Mdq3WlA(hN?@kozIb%*gbMhD)N1?!m} zFq@fsGCuHl(#f81%WZC))Y7ZJ-1?c1du5d_=iAEOyjA@c{~QnH2b~wcEn%`bQk485 zZ_8d;m1Ol7yU%s@tJ(d2P*rdJjB62xO$kG6CX=_*0bO;*{N>`UYROeDtQMcX+%4ic zz<6x!&3i{mxEQXdn}><X%QQ&yD=4Y&es$&k&C=6Zj}9{Iu$n(3zBY+r$4pUvPKPa1 z_Q^&wZ#XmIV(vkurKe}TaGtYKmf_Wwiy{nm3B{8|80>0`8=}^3Tl4wbUH*0T1%E#r z-4wO=Uc!azaw}{Xm}l^+{5r_cQQXjB%6CB6eG-dJ$)iU{ga1CbQ`hF*P`Iq$HK6a! z-Rh2~e2bR6*>5+I=YQRXnYB8WQK8n)E{Z$|&bDNJ(53XLO1(Om;fDbmLrueCGn?+% z&8wbw&pX1*WN`VYoHfHz%cZMX84T`SX<_L3nz?jYig!=O<hV;29UNNH`j^?J_wL%H zA!ZXO)vB@AEaQInj<iz?de2B~*>c}CWbvQr%kSFE+Hxylib!wdv7&{ucW19S*YQI2 z@uo|Al{Gvksm*xU<9pX`gD&UK$uloYK4xjyqRc64ygv0T|KjUUQ$sWvj;p@qkS+|K ze`$Yny~OO@T{eMS0jsZiw(+ZJZB<>YI@PUqUfA->(qGtl7~I~TuKqsh1v~pgPB&*& z#aXuh1U|HJn#@m7kf?ds@8<t!!kmgH6PO%S68^hvdRDvZhxfl#c{~4Qa0M_J)IQg# zj**q#yhQ83)wurpD&>vWUNdH$RbG8eP5AQU<kESWB{Tnuaf=p6PyYJPlOf@+(w|o! z{f`MrC)fRd!Y1?hB~!)MbKjz_oe+KgS9)`-2FpiQo9^zq?)}<^_gTX~9XKfV-zKPH z%{hMcwf&WUHhVIhE5BNP@ZM29{$H;*|NC`sD(~r>{Mh%iiZb4t#3UBFGpuM_|A&KB z;dK@x!yD!>28QYGi#BeuOJ(O+T_L=hElR9g`pd<N;0Yl$k6gVg*YjTcuC(rDx4!q* zj}p3zY^H})?wReb6Vb-dle~EW&t20==|_G~dt7_0WmWa@rY&>8f&L}<%FgRLx}LR= zK;O*dP$#i7kBedH%pXiKJv|bPCw`T0;8%7IpQ!H3_-e^TKhO30mm8lpGBWd8tre@g z_}=!*VR<7qBgu*u<`3;To+moL)wk%rmEZIEpt^*e+|dK?75N-O8oD<7FrHxAH``fp zeu{>yhDgG-W`!%VbLCgWhcXxJ%_~b@n{8!iFtcN=8e@dLd_2oM#x+Si=96Qyr7w## zB=6nW>uIr~JUN*)<Y7(H)y>mhteqcs=fNkvu<eT&3^qoz>u;VG!FpVxFS4ck%dc?r zw1e~C&FVS6>zO6Pb6f3?Q-2-ht?1lpTC;8Au3d?e5%(JwescbJCiDC;zq5?TG#Dbo zxgS@!$cM%x+*`z4_J#HF$6JL*eUI(><iKgl01nosD>t1xuU(mbBvPPs@};xP-d$N@ zNkPFT6Bq7od&N@{W%@DYd!L5IImvT3uDosjzcF*#1{uxr#-*($3|jS*EAED{a+Dva z6#B2gutfiWT@O#J>pTP26<58cZjCCaEp2ItVo|uGC|O$VFLRUo&h67diz35J;swed zwO4=tH^If*PiEGR-#1;JuXGl_?f<uE%9;O)N)L}XvrL-OD6X!S_c4jRrRCMtLa)s# zr8h$sGHm8L5PJSb&93iWj269oA8Trp5*VJ&W^%a0^@TZb$2-LXJ<(z-xp^86#b1AI zmiAZs57Xq!3a_rz86DD>ZP5NYCHjE*Bc|$S;<_7B>l&CR<jj@1y<&ckW}Z=D<MY3q zbsBlK2L3j)^$fIMW?q@3caY=2Otr=LR=nwW$<wy4f{Wpy^?Eji_gfGCiql|NGPx*_ zH+N@d&xJg>s9%RFt_028G6fW9Jxdd2PAru;B>Z@5ZKq3{u~F&o2Mj)6Ja35hm8p0# zE7<qMlnXQb&R8#K%5bxN;=<J@9)~p)mMwM^Q2HuR>tJJhPw<N%L)Pan?tL%5X-R$H zP<}8`xu>C}Q$?)iWA}Nzc}^eMxi<V|>2EOjyMkfDIvz$IHOU`-&zdeq1~&L7@~CVN ze2{tifP`?J_Ge87nZv(Dy>n{U?8`RfZP?6kZr!~|v6B&x8DchnY5JKgeYx&@PSP1> ze+MV6%T<%7RT%_SXE8L{-)_x&`>P~>ee1$?*;V`a)-Pe0P;vNjZ0A%)?guk|9g}JJ z6JS$3_vbBZ{sSK+&*U&N^hrz?W#zC|+FDq;K!q8Sw3K)oI-{g6B{0M|{B;vm5R9C; zD_bM|Os4pw(^?FD4U^}cC~#YrC(p(3*lljw%u<zw$!5!#C&ZXf<8xS$WQ7PD!?IwJ z1!Ao6^UN}>_zu|Iy=TLAK)H*%r(C3A1>0@TkGItI<d_=GfBB1dnjGPo;e6!3>a}xe zjK6>XUf<u>_h{9GXitW^O{R?UhFpKXMCL6%;Pr&LF81UChKbb=A}>|6ay2Ah);!3u zj$x|D+eN9RRXV2YHa=9;GtM|J!f<W!^w{<D?;h~2_~7>UPnWtX#~~2~wWI?Nf<zVU zOV}AbE++&r&ggith{NHBc)k}?0q1k>OdDn1hCTW0;DoY-*<h9V8Q1B{Rk#jVXsn13 zc>YyvS<=U4AARncPBA`~n5@Eifb~t(6j)ds+<#SPbDv7dnLV>P?R(;;mkTM@Z@QIg zQBo-k3XyA}Dq=BJZ@3Ij<!Lbp{aGnE<M_&q3$OT8lNJ9o?moESzsExsmWLW*$98pW zuARYD`$B;A;mYolEeFdc@%x4!HDuipdU}G6=7Htsi&irv@3rAgQ+UZVS?gr!=Z~cV z1>J^6&PZ*@y*ioYfzAp`v856cw{IjkPB_{4^~kN7q*UI%_~_4Pj+-CY|BPYIa^Vt% zC(r&C_;NEph={fII5Txu?-4Dn194aSr9ZAa*~=TYY|0tNb!r8C1$D}XmY(z0o-1bf z<XkS$U?OP6+t6v3%=2RV0{!eDO^3rW-z;XGW^h;v3Ny#waZ8vD1f>l<CKcNF6;A45 z`u?(1Wy7wWyV>lOvrNo<J-HZqJLZ4%Nl$t4Z6b?8=bQ_m;94`gvq)mM-fI;n)wsz^ zW_#u^Df~AG+TG={bmeg#hl=NKjxo+yt~Nhx`Q>FZ9o<6|l0LM1#!CoPu$)d}WcXb; zwcxkA{GDG3Q!@4`EAaaNnc!ltS9hf5k#o&onJ~}2rQT2fDE;2{V17WuO}?fSkCtr> zR}U_$>S6LP|5+V=G5D*$pB6*R=k@9@-b6DUKC)N)s|lCQ+x4$!`Rjc9W!S(d{-H;e z@x(Kx>#xJi)NErn?-gnIyjQv)D!*n;cET?4t#SFb+;6XaU$<(}0l6JXIhWqIbAQq> zU{$a$dCu^|;Q!}IX(F9p?%WpskysG9<v?B*>x?V)vl%&BQltV_@-<hUEm6GV%H`nG zI&;PjzuJ2#%Y1(8KU}b{Y+K$FrNSol3Yi~f-d+DvvW2+}UI#vy%DBJ&!9yVrgI&_< zn!5d2oA=HzGyXZl{_&i(tZ@dfO1644)%m|@KbU$^X7^GigR>=X+L<SuW4UDQAem+z zzu$xLhQG*ziQg_>{3fjP<KR#8TjyHk=QD*oce=k%{(lS889RpUN7w3U9h19h=5V#t zH{a$Nf24?Wg<bFIMb%6bRG9LNYKx=<=ENCwmcRa0x+_J3!Gq<2zW$^q4j~OHJUs<A zOV>y(`&@rrJ#TNbV}DBQpS3^BUl;5TXz*uam~SY|P&1E_qyNpetxR{Gu)Y2&)2Oc3 z=eMCvR`~kwnZi5_IsY>rT~VAT=V9}pd-38I(#fGZTI`-b9?Pbl>VNhxmrefYqqY9O zRGm8m8kicG8V+q*&9MB&+j)8)o^?9>jXU$gx#RAl%M}d{Yvy+S)4j8TxkgFy{fqey z4BO^gm2!P&W^*_E<v6`Y<JbE#wO@Dl{XM7NZmo5Y_53QPJA13kd(YpANHv?y{r$cE zQnvmNOEzrSpfNupFtGLZ-(BK;T5C5n)&Af5|5yC~_4EJceE2UXV{`qo*6G~byKD3J zJS?@?v;J_P{jK;9Mfbe*-&Q`||407+2mXcc8qU95wQ^-<LFo1^B7&#yhliAGSaHH- zzUa*MTZf#B6l~s<p18K^(e(Ipr>is1ue04<_EwB5fFa<<IgJDCj0^NGZ&Q97^6%n5 zx1NS8A8iX)?f)w<`=9lp?EPQ93wL@fW8k0rV*k`AZu`hDS;GJF_E*kb<9#tbl#z)+ zyLM@4Yr{?_hj_t)3DS=7nhVaq{c1I#TJ+lc`u|(^|9>fM&-_jC*M(cRKEzFHtm|8) zRb1%Ec;WR_`9I7ze!t)U|KaV0X7ewnt==fe5N6EqOgvOZ$LOX6gLd1K4-?9T8NR={ zrz^66VZnyu%NR<(i#L3@{N*&~wXJie={l%O?2VbyAfCy2MS0D)(El112O19e85}z3 ztnPN%x^d6qFRk8@JU!R-H7~AaoMFlu!O1n@g5G|S(D=ea!|+<Z_1ACTh)C7dWxe|S z*X#U0UzXcjyRTiYu5-Fb;@zHmS`6|D!m0P=L~8#V{N#V{zu%ts+dtO%=bsnd^kHdO zCuXc+zoV#b<IY7d7W6WFcyO@!TlS_!46-vBG{CM^cVJ9oSm2k<!1&{w?!G==R)uL7 z-n;8dtbQDylm0P2>eu@DcNqT*w9Z{3bSTV~A%F8838rhe!~1(*t;pQGmf>{mH@8=> z7rkK?5tCh0P|eJ^elb_g!>;$uZ>rzEzwhvd@!x@mQ+My)eK<Gwo?Ad|m`c1O<DBkQ z?`xAT%WsS6i;<}Rf0*C>j@j(fp~=jg5y6kGqH^_&W_B>GSz#c*W}7iXSj=Z8hCtS3 z3<fWmxLHA6@8etshw}K%**jc?v-r1PUb@GGtEju=;t@NqC6m>@`9ANiTfh9aM(kg= zsqtREI!<<_rZ19jc^$Kl4HJE^dOZ`v*}h9PoA-89929l=7OME0<JX0UsjK)Oich;8 z@YOWz=GkXS1r_m?Pel_VieGqnpT0f)zT8g%PK$d&T(YI{`|dD4;y)1{?DsDAs_504 zd+QJ7tSdXrt;Eo+#U|Kb$MV2)b?y8(eug_+xdovC9KpCriZkfs%71}zXCH{I&T**X zY(Am<*M39eGuKHU+)tW2YAw;)WVrTS@hp)&UnfV)e|i16WM^OlOGEFoORJ0-x8Ka! z7VRm)a`XFR^BMcSQ{)pqs{CotS+mNB@mE}BtKZkX|Nl+ZUbuZ-OO0%})52*V`42iZ zF1~uzeeScYfI7Vg@s*aW38kj9zpY-e!XksYNr*{^fv;-s3npO(u>uAzP?)~?c$V>! zXg>b~j%M|m7mtp-Xi;M1eCQTgy`=0K*Sfu%=ZCc|EBY8VsVeKA>SDQ`hVA!u#ucq` z>Hc%tn=xV9GX}wi(B5l7t7Z6RfBt;%X)SMxd_;Ek>yu%JY>vpyxSMA#{{A-mtoKvp z1HSD2|8n{KfB)7xya+#hVBdw-Y1#|>PUp8DU}A7+P}sH4jLE@eA+K2j)0&3*rO#e9 zyD}V5>S5hIlZl04Th!a5F^uo-ZC1a|6)@uz3qvc@9i9UzpW^S?GAUZk{r5qGRe{ZM zMRoPWbzc{RE{Z($`D-V?hk8QjLGiC^<h%c^YL;g>m2Bnu=cyCpzF%GE-z{J$iP)R} zhv(q*C30-#b05496T0Yr(fuU*o8Pv7zI`kxUlG0U!o^FM812QaoEX1foVIw|tHoE( zro20UM0LSgHmM2E_9hsxyt80fUwQbtKttbTdpCxCJ`5exl36w0D4H(ZkRHyMH<e>9 zXTz?uJXfZEF=p*>d{FmB$^C^p*MHe0U4z8WYoo&he`{9iWxll!+8l4U^XoI~?d#+8 z7nX9CPC35o%{j(*_j(z&2r;x>IBQ!usqlQ_)-cB3tGd36iiqr)qx4IpIevZ2E4P#4 zT#Wx1nso2xx?jA`y7PKcHp5T$CyN<OcE(&4ol+n2dnW%Z3x@XdmCUw>`2re3Lm1x$ zG8-tu#<BisJ8nD2royntqAO*$Ys~#RlUq_R1^gS|xoF)}+V}qL7M1<wMnWGZ-=1GG zt)^h=vwZ<9lh|X+L?7g>Vpz);&=9x&&4#dr>$A6qztz{#(K~Ve{`8484R$*YPW!%F zut8URT}u=P1G5Dq$EEdm*!W*C%s!R-bgBf~L#7W3Odk}g4pi{}7ie&f%RfKi@a<Eg z59Agyurjp1UBI*_-jL<*Ud9zyx5ghY3fsP}|J&psj+BG91(z~7sZXunZsw@B^n1P+ zmr2FJ<2--cRaqNc{ylYLtdd^)g6VYMwRhLM4R%B?xc+?qr)IXkefR4*&itN`X}9OU z$VYZ-yPDU3mI^R!Sh{ql<d^v36}%tQwe>gc%;CMl=*G$+B*3bmbs%f^O+Utn#P!b@ zWWH3b|7x*EJ&<vq4!F>i4QrTrI&tN-J$J3;4g9bFJKZnPAiQ{ulvDL@#W>YjI*p4R z?{7(!(|N2q^XGCO=DMP-Pril9UVD7~_2kR-PwZZEUC4B=|L*gn>rL+4cORd<<htPZ z*o*IIh~NA7FIf~AKK$ihA7kk+lEBUM;mgZs(+&h4+O1SjFU`>K)PnKEp#x@*7-!$j z`+czas@mqgiw@-)H)@seTODeSyC2Xm3N6=<FJKV&u)NaBUeLIG!?$?3eSO*w4bDpb zTyC=O2+NC3(Bgng8tz^HcF*1ReX`%(7p}VHHJZOV{+wxKDzPeDINOGYVajgy)Q0=} zYJYS2)vqwy`u(@2@`K;=<2Ykq9@9K9ZCZf$=a<jYSQ40x^5qjYENoL@OcGeq#<<~7 zOvu}^-P)^GpJh0-*pFes&KDU%4e4vXIqj7b7fM#z0&1!pD)V5}+k88W>x13p#>$|} z9q%OAatkFr>|;MvPZV+6>!<d&qWr$UU1j>_yXPjbR{UUfmTa5;lVyR>v~!J2B~h|r z><k}xSRdG_Bsiu@{j>9BRIvN=@3FSxgLnlI2Zk~MhF!5D4NqHYW-#7OIPAco#w2&- z&;D17^PUF=G#+r=ewj_hrlLe(@qv4Cd=8KVw}t7<6=4<GhRW|<?NeAw+XA1+>lG=R zof4XN-&5_o;QML1GatY4UfXnFUQN+z7w-ql)oVL57pPukJvWaj$3^@`VVlN*50)Vf z8jRmvjkpiouRnEIQGOpQ15Y5swg84nOXVB(e`1^VXOc*RsTtFO#Uc%P0Zo3(e=0Bt zd}d%z-MRcYBZEn|r30fwqV;+P<C50fTn&{cPqa(5vHh?#Efi(&SacxeAUj*FdY&eW zPxqf?qK*NR{@cCW^DlDwqJI_UR(uC^!ka5gzt4MI=B~{2L44EqlnRZQ0~{5Wvh^>% zZDOzZb2d7EuP74(8&gYtir9vo2h)^)^`HGK_-pF)$uS3{W}l5oTxWNSY2W*ci?5b3 zIqbfhQ^dcJTPOao#)iXv*^LKT@9nJ)KezfIhcJT(!x@!sxht%G_jM)R-2G$UsF%KT zUw`@QL9Z9e$rD$LFn#KkpCTjg%6NkDL~w3N)LzH=Z?g>6@8e?F5~j$|o|>O6)bPsF zA@!NRV8i@>&z^}2G$=|jD1K0WX4J^Agkg;$m%xX11{G1p*s?DTjrCXA`FGuAEND5X z!QjZqcUfZt6Ex1IF!>nX71O?F^!u>a<B}WK?yq-dm{hv|`p=~Mx3>q2Rjo)#h*B2# z7O`@1>;Hy|N}0w=iGLTQy}Z7#-kisLCpdMx5UWLv+gb+hSyvhUOp#Vr*s))vVM@K8 zpMy@V>yi6*t_-)bEdIx+O+MNB>wQAtR%3?#l?)}5H4Io6%wQH_5c#%4WWl?8UHbXj zzBAP4S}k3znrYC|5I&W4zY6oOYj4-GKFD3F^hJI9j1S5TA*_vGe@!}koPYWgCq^r& zw_!6i56r7!E^R!moXN1{=Xn*zU$qbJPu;$QA;)jl+C9q_I!^t2NS%SPfj2_(bV|8= zY50VQ4;L3NU<l+E62D>XBKp8Ch%fv4JJ}T+x;_j63<i^RF6CWj2(McEjluKJ;=A|q z0>331JgKVx?{dl{VHf+E-Hj!ePTJe<Iv~;z^=pqyd{EJi33oL<tQKkT+Z@Wr|5)b1 z(&C>sTt!x<Tx*sv)ctzcp0CKTlKsv>#)@4#8DkP={XVp&jjcgigQ2m<Y`W}@@DP`0 zjQwG=SFK*nzl&YANR8ngq@_ODko`x3=%Ps0nGY5^mza9kcO8fhUoZ6QYk66c8;@w! z`xP<?L0kf{Pol%^cmDNzZfN$^eL2Ht3x=<(5B?qU&Fq?D%Np@=OPC?Uwl@8*BE<~X zRwOIveBtK*|91c0(x=Y~Iv4^z)=R(OQDEAzDJE5<R;GbhZT985Elnzni`Z)>FnpL5 zE;r>e!;bXx>+U)+axrjK&k|9X+Gy1r=RU1z!l~~O63?u}*lb!)H|FmxwqDDy;Fagq zhZlvIr&ga~y{0-}((AzQwK^+p=G<LZz|+v1wt6~KL5G%uLc-5|AGb&t__F`0|M&CM zRBwj;+B^PjXI}U`D=#C#P$1m7QdPL#^Zj-=MulAm*!ge0^<kW)$&k8^AzD_ZVRs7? zD8aaB9{AmH(8fsD<NemH>=n-!`%U6KyK6ynde7DkFM1xVowDitT;Xu;lCEdLKK+gH z8zxOkW^icU_oTp2B;gXP+#;?C9tWP?U!vEn%=705e|~J`Yc(bTRt2^HjDIW`X3xGV zS5Vo@)qI3iPk;Mv?Nx6@-3!?+<eJ@Hz1sJjVnD+^H--g|Q#NT{;(Nl_CDpJ<ZUMuC zZ6XWuR;yR?G*`WC6h4(?5hgbEPu-g<FWBEtD9L=pKe;C(<zZ-SYNy`aFILH%uRQlI zd~##sxpP9zj?3+9EPiucIL9ENz!kul@N?hg{Xb(*872Jq{_%KztRue!qeuFGDGmls zM(J77X3P^5Coo=kW)l_L`04!HV<ryLOcw<AxiPHpYIgkhbdS=PT?R+Njk;yrb1L%! z>N}*>75_g~D)VJx`1IpLXwOucYq{Hm7Q}bTt$p%gqx@eM(FN<4{8zsj-tg^`)*~i% z8TDX>h`736=9#A*?EYO{&(LsK<)5I^%JXOS8sdc-cvFt~&twR3Z_GZxtY?0+KkP>T zC5a6u(>CAka%5~fzm&m1U$lXf5nTSRW%#`9MC-|F2fbJPGFDTV9KYRO=)7F-QP$4a z5%2!ZeZ5^x?U#Ptm6iU@^TXm7*vByG>Ca&N@cr4<)%J%jHMG}%O8X|dVCSlhA`Cye z8y1`1DfZ)RVqnabZE27%`upAT;r*!-q#9C+*0(WjXtZ7F$rSLE>2dJnv$2dUE}R+0 zGauZydv01P)w779{J9N(00RhqxG1XdvF5W`@}+_Y3}HvCn95tK+xaF`e=2^I^!!Nf zqrZMJ^DSa33pf9=uHm_`+`MGp&&B5TS3XW&y?x!iUl;rPBpX`u)500w)s=2P8T0gR z{#57v9G3n}4BVm&?{24C)iHAX(DZNl`GbG|uAgGQ^*aTW<P4t1G6y%zY1pFV)WsAs zMNzE#*fd4={#(AwK7CSRSg^gF>B7N92U1i|yo2U48!m(Q^P=?i&;CApI{7Qtr;;p< zSEcL?_0{|`H%^y4V02MTP5k}h$k){k2l#)vTTfnHC;In#i3~%<kBY{BRws^_ude&@ z%>Uo{@^$+-%l@9#(LQi@{X0emD-DKP3-*YJ2`UMN8@Zn|PX70Py~nrHqEF;S8@@9r ztZtk7r9rU4Y;x}f{<A4YqEn|UGn{8+XqUMmzN2CHf!#-Cz`gP%45j?$f0t_<xbpea zw|94|S?c&2WUU-n6pKEbP;_|o!sYqe+YA2p#p+yqdBya9#HDV&C-JGB`#xzky?@nT z*Od5p^@#)j)@U6F*JN0mw%Xi8=F?msW(9={R~cDUG!5kI=Uk~5pY&(5DkH~t(>uKP zOju8dC@@@jn^xJ7Hlcxwp?U-3<edq{0xlZhBxcLLVmCWW>G$%+o7cp@oUn0nFj76S z<w4_K_EWdi?PkA_XLX<cS>J!^_vKe#dGq`_B{X;6E8ive`qxb=IC*vT-{YHPK-F>o z%2lTr8Elvtrlj&YI63UlHZv{X^Vhnh!T#TmwX^d#ewELV)jD~7f)MM%i4%(UF||Bp zP>W44-?}(2STMuk`BsPX6W-WfPiAE(V%;HpHGzeJm4TJvy)FBS(-#<BmS46#@@U82 z1>gDJq;l94FxVzJDB5MqzkP3ibWi-=MSnB?YHt(qTYtIiURCjb{|z(ms2|uL9d7N- z6FHALgz-a<FoVSrh7}nHW<7g$zlQJE`|J6t;s;tMX&S6#2wAf%CXzAe@&l{7KNji> z*6y06!dTwoxe2xwfQ^Z<ymaD9jr+U|EUY&!C+B|oG*uyq4>Sz)xR>ev_8(W9UERM- zsa^5^L!ExDh0BbJnvnJ0k?$sk{l1x?VIcGG;#KqY`-)$mozL(u@BXf+MQ2a1VyY3f zUK}3!Q-eX;|CH_XON<fs-t6KL`tW$W27|K+%cV<m)EK0HTu#&beuH_Mqeb1FqbZ7k z4O8kFx1V*nm9*>d^F2l9CwyJ<eWS*KO7FX~X0dk1mR`;*+0c`^<k*Z<)2D*`bB}84 zfhwD{#RvFV9dze2rZCtX$bHYKlkYHPepurIGuIj+wrP7R_iozs`IY|x(RXk5#3?;Z zE$90X%H*(n5ks{I>k0*dn45j)?cEqQ{h99Y;t;DD^Nll`6Q;yVTd)Q*?ucY;67KoB zQG#Lb^ER2@dv5dCJD3cfYdhX3-@pCbBqp2w`nuBF40FCb7FA<qXtmwU^+7RL|JPob z8WWYK^0t)K=Iee0EfX&+f16nQ^r1Pwx9Z>UeA#UMi9Ay`*xwF(a93^j&Wl;OHpVhV zRt}8s&g*Lg7z=d&Hmy1HC!Xm5-;e8#mG0C2YcZruuGJI2yXwUOw(#ceiyLHE6CS(> zyiji<D!|yG_hF}8yK~-|Crr;H7}rG|`xC3V<#x+@cJ>ER>(fh1FB<LKrN*G~=4E)p zBp3M;xtl%zaBMBRU82y<EbqxA@L~D&<v;h%TqgZ+#-!xGY;PC-GynaN<<#+<^NdMy z35%~PDl?>7+Du_`a62IN=zp9V!yAU(7cQQ;Ua#;f%ZTB^)dj1V7N6m{m18DQe>b;? z!Shwya!-b+wKJO)EW;WmPdl2){bSALX+OVy{k6`O`^J~7d|MlyHNL-J|JlrNps~@) z{!N_hiqAgl*7jGby?%ZC!9}CD3;S1RPoB5y-qh0m)|*=x7Hn7^%IM``+Q}5K=+9)M zkNq;6EBiOK+C1C4;QC8`1I`QwhDYnGWWU#J@7g}$jFYqH$&JaoY+io2dbm>G;JNkf zzyyW`H@j;aJDGgg6{fYW)X~r9Yt<`>VfYujqk-W~!pHtJ)`qjuS`Mv^KeQPa)cmWF zW=uHnZp-XR3?&w;-pDjmmw7R+Yy1=K+IpN}hh4s8M9kZ-XU<<QU%Td)diw5HVc|NZ zlQ+-%b=9;#y>45cP{Va=>x7xA{mEiA`>gJp+VZNe|K-SdF8WvtlgPI(S_f1ZIL}D1 zY@OtFSF>mF7Vme9e5P0!OY)qYBfre$y{Q1(tJ0GtQgbIoZ>}u*81j7MVK>j;PM*vC zPHs8b$Yc`U$j!r0aypvvLP+yczt~2Bj{SXNEDE0+X2+gAo1-e@-^s>gX1=@PmG>&9 z90jfqe}29z;JB^1bMCq3ciD_H882kttz@;xpBWeZdTn$tgU6q{asO}4N^g*1xE-#c zke<$1rSQMwbocEKg=_Dfk9}Zx`qr-d`}gh35G-yuG%t<yN7IzFTT||D4RX7jw>NhG zbg2vn=4&N^5B=O1tPEbHt>rpXU9wO1xUK#REz6$b1q`RprUmhT5Lf)}x%0V{cZ;8! zV?<{9rCUNrWd%Nz2sQlK-gafqfwmsDd4>!ZZrA>+>0r9>c1tyTD${}DYscJTxO;jp zddht{r*VMs-KJ#>1*|p?^ehtU4#elrRGposdwKGc3%g8N?rAYU*tLJ&jsutX{<Esk zV$gUK8PIShuEMdZ;PSof>3m1#-%GGaxL~y3-2V~7ln^`P{Q|Y0n$|68$vpXF>85oI zr_Y8mIJo~1-=C4A%Amvc=dHGfqC)=rL}t$CN=zFpBUv{%&gI)-sWhj{p`?+q;qKjB zMV1A3-|U#kcVO-sw#`{D=gxO8Hy3TVT>AONQNL(4@#T^X>bjp@7&k2d<-u5SJH9Kv z>v=dBxT?iO9&mfUv*ls9`t1<+%Qr{nO^aIVs2{j>ddcLOUUypjZQ`RB-DbHiqv61I zz~C8UoUypb<!uF-3@IEe?K^&cQDe}$S^v^6e--nc3kk)E0#7b2{d{*J!%P+v>vbm| zmay(nj(r%G!I3geDYx*Rk%4_?BEuZlmDhK3%?_^;Y?ycRX4Uyyv*hK})k7I%X3H~t z<T(HL-&I%poOi2cv-a;SFAHS|xN&(E!+fsOx1t{0eH!|EwFZN19K(aUEA=w<Cmjs* z-^awoMS31+zxu(Pc>~+>pDVV{7Hl#LS5Szzs(%?&9<3n2wd1X8pQAprLPThtP=oa% z2HSb2R>BtB>|TEC`5MHq?knFJX5QuDyLA4&v<hj6opw*OhpF1ZP>u1$%r)PAgk3GT zt<@4g=g8*BZ@6+6RJ2ElKDeBC^A%Tn=LJ4vHpfGJmNNUc+0TDvmnyfRfoVe3L0;3T zQ(GF2AM{gateB%_ukJ0M!nA<<!3&<PrSVqv|ANfdv2xzXWoK-#6=~pGJ(t1Y{nESr zmAW5Rr6v>>3e`Sp-?r7XPMtx+N8^CHKjX2RzN`+L#l0UjGnJe;{QTQyCyrMZ1)GjW z^D-v*7JtrUPzY{V7a~66yW@EchTL-w<rbU{o7dhlJQ17F-pp*E%JIqK8q@i*tJlAu zTW@c##-Jhcpm-5O@|$H01@WPwi|1-OR7!{>ICA~x`nJ?$Ckw-*%mbHYWg7HY8@z6= zKFhVC&6n}kn}>OGm{J%@!kHOlxfs@7s}W-f)NKW~7Fs!3XXvs@q?(JdFf!cw9(^*% zBBa52w#b8hDYJ`(SQL-Wi<On_Vk+5ac_1zIsWhv?=C&tq6e78V_^J<2*HdJ;%Xl+) z^G<fzJFMTVb}-#hUUiUzflHXnfSs$L^R5I#IWxoRHDT5a1v|X>zAG?rXXqYC;fV-r z*z@{!A&UZk^99QTkGH7K5n}UWRd7z|T^l1e?Wy90295(84(EN2E&lQ)fWa(k?a^OM zOb23jyV`=<T~3T?v5a~fW=l21tzwY&G-R2^v3F*@iEsd8LNR+hhf@#p1bq%qc7=~k zj*O?KA4)7}(r3N%Zi3G3w?#q?VXGI<&^!=p9nR}EgJD5H!})%uImz2q7*?;6;c3_; zlg9AGvVrw?5aS80yUYx;PfkjhzDrACb-h>d!pRIJ8wEBn9eDTdGsgi2h6}mh<Ggnr zU~t$Q2W|~IF+THROk+?u$5{1JCgDd+rel@sm5{}}_ca)-Gpu|zag_))bTQfZtzLLZ z-E6k50T0s$o0%*}wknkAKV!7=kbB^nt*qVN$`o)0)Y%p0GPuoE(02Edhyvq+=}Z&S z7~gqEaxGfWe`hua!$nrNeWs6P5;g}h&ewEU#Q2b5$IeR(70pZr6PO&@r*1i#;{6cZ zQ&kg5_$K;5WcNzO7h7-bHS%nBWO%3S-S~ZF595U;0UP#2vL1MxqwC?j>962gkr{!E zyAD5GDBb*gC({}?hJP~{E)-sx7{jfz=m1m0p%$hy`<Q$fAFN)lCYlft*pTy=owsF* z_{J`Skl%*c38{>M&p*Z~crr}-)ZpQuCMa4j!Fa=$;YM$F<Lw~P1wGk~+!b;O=R_A6 zOyy+YV*Meb<uHvSck47I2I=V3nQV?zf;f)4K7H!LbgSdfW<%48Sxf<2CG__GF|OKw z_6#eV@@`9p#)jP7#)4>t1#Anh-a4`9fVC03#=k%Bzb1C{Ker5OQ1*YQ>&^8cbQObl zp=`lp&-~k##aayC1)0PYzi@Em@@}iu5si8%Keb`1>FibiKYBW`If$?1xO=zu@v+?u z2N)O^+_u(ekIoc0a`1HG+gpOH8vp))8q)LgZnrV@fD;=iqc*SVy1;SZ(I+M0JDLnS z+XHz$7*>R^<=ngTpLzDdl?)z>m0235YcSkqDk?wlc3PpX0qcVBmFzo|MHe2Ozn@_N z!-HbB11@a}9arBN2(!Lu-JShR<$u>jpJheChHdr_#D6fh+>DTscxw^dp!H9-e5r}I z{X4I9UBy#pGc+`tAJ}#KRtn#Nd2;RB+nFaUJNV|NDrh9zfbqZ@?E_~SN@i`8TTqjB zL-F*dtz4`Ac6Dq1Kk!ZP*USkm38tnZ4acuCgsiq#)t{ggz<Xd;(PZrgIRhqvyhMhC zgb!cmnF}=Vy_RHWO}O_iwz^23_22wMA!-a73_td09dNS_YngWVFrPldhAF=PRx)@r z9aLMykZUH!yKal@`v-*r=k`B-$&ho8#bLI#LCNF0<@r2i<;)DJ^UrJYS5@EpAjD9_ zI%9ja`mRHN&*!&le#`$q9~=}58H`n&gc+*p85zDPF?3%|GZGb+Vfr6qwnRbTLRe&6 z=BA604xz3gM;35z+a*;IqjHvY-AcxWw)Td46@IllYz$vm89-%l*gK~8LAwudG9I{d z`~ScDObzE%8RnN&SFrBjY*-%>YWgT@$HRDwr(u-_ybVXyrZl-SIP5<CbjxFv1t(d4 zEZ*5*!shfPe(Bxko3EPuc+zlwX_nOSu%oi&md<7+^Ku`0zhI2F>K8INz|f&rz^~Wp z%@m+<Kz3Tge?10`H#fz1OI~Bz-&ENUmc#X^gn{8KYs2mmX9lL<89YZ)S1)N45Z7{u z;{LkhtZm8LTU+I-4@=JPW(@G0s5tR~!--C=3+6SM0xAob9L$+M%sG7f*5iUXM{EvE z_l!O>cLuY_6>mjQw>MqBq4Vt14H67{SsK<hi~e-YaIlr*IJR4<%VF}_PL4Gup_8s; zJdn3hUOMlo_onToFYadNzH3X{Drm>VP}{eGM{i5*kGu2t8lGT~@L;;&$PjTg;)KWo zEx{;_1IHh+a5HdKtBF2f(rll_<)B~OxcA|;Vo_F42BCkVhQ>3y3gjk8`0-4eKKsTi zvuI~ug*OJ}Ki=G%{r($ER1l|!NJ4x5i!}cox;3BgN<WWZ%wTi>?z3;Kb37TMo*p}P zh-nhr=4g!rbEh}_U(c{$LwY=;-sb3AG7An{+uXTvOKrZf@Qaz<T-=>sj1_-bFz0$W zB+NS<a!#B>nc?Ebt=7zb%g?j3>=AT&H#bc1!c~WD{P!xv=iD!LHoqq;BV|?pucrJ! zFKZ%O>NBQfksT9u99W{XFQCC%hw;BJL%@yA>lrRrH7PJ`lw05|Cs#Ia34;y;!z}LZ z6`!+>W}Z!Z_3zf(5B#C?g*`mHj|kXC<jV2guC<$f`T8TC{nj_wgg$)93_X8_ZTk1v zl>58S<ni#8PS$h~V%fQj;er?AjOknlWCI)GqmKn=7(D&B{NuwM6=sunRrgM-PChp; zwO`Ep?oFQcDLZFdt4A;d+%R6r5WMTqN`@D_{MOOC4_`lX<hAKc#}7}F|9K0tDkz+P zS!O-AZ25W4Hci8)D*6JV3#w0KmCuxlF`Iw6{Igs1fvGGCN*ivPPvJT++xc!Thl6@2 z)9JMjn7;E^7U&v;H(1NE_RU%QJL>G=UHbcL85r)ZTzy)V!Tpa+!~Y*|ug|Ykie|d- z`M~r)@%(R~v7OL$ah}2r@f+Ih?X~LBhnue@33pb`U~K%Uz;O18KuMYP0{%N;<_+B} z&KwM9)3VP`xcS`q{tfYrH<jOQ1Oiwc3K|YisWxX@DPF_*<N7t(Y0P=67!ETW_~6Tu zkeqn1Z!N1I<AUd#HwUj;UVo=&3J=4c$2+Ud+8Juzzp0jKP(G3IKaAner$)vf`u6|d zHd`HJYB)5{lXV7XgZkO9NWS*DYuDawzUCx6^QZB9eK#gCBZf;X5(@Kg``TRZOS{E# zM2zEZ&G);-=Nk4Nefi}kb8r3~i8p*dCo$N(EBe{-ZMR{`%RfsOE}U)N-ge=)>emI1 zhN~DVgcw%b-`I5_i{akKdh6B=lK<VA4xj%cCJ<kK?w79E`R(!Zzb^P;B`vw~bA6k_ zryo0a|M|N8-yOye>;AtMVfZ(BKL7jMoBx+=J6AuQ;Y0TSE3y)6?Nei-KfizeUFE_4 zT5Sf6H#>tHLZ3}u8g^59+T0LEG4bcZ`?sHHk!(!cEZf5@&>*V7!csF)VuQXybG`MG z4ZC9+H=J+PzT41{tLw$G<ECct@$Kn5-fqo%(`9EPqa^OwXT<vAYioYh+H{%Tw7<)m zR#-1&aJ*L=TOQ8%V0--<XY(Jg^Y^#Rw|-yy+tlg!|EJr7Ka~8@`J=Jqq%6b!Z+HHN z@gC4`JjBJ2s3XYFsK9mEdIdx9@u_}xt@Yno7Tn?#V-Wc^N5jFG>B8&Wsg8_H4BVEC zzG@emcpA!PciLEVGPz8?*s8j}J%3O2<I;3r=gn_&@(wKB*POp)YXIks3kA21|0rLq zd*|9Uws~b1J-5!E{n1|B@X4}Wn6W~Lafc?yCT1>y$nS4|cvvy)|MP8~JEKZrqwRaa z?Vn%oy2C6GTg2K>I?pO(As2(P#I~PO$D9tSur#cE>}A@u_xET2x8P)IC-Oi$G_10I zW%#>^cUc<Jc1vDn=su(Nid%k*bs*EN#uh*8WxE*uq<opNyFD)`yo`h4MRrEsG1Gz- z4EcL5iaoF^XzzEmYrA#m;X{L;ikmymFn(igixO<GHQ%cl_&|0pbI+AZn|W4K%C#8k ze$V^==H~7cy?1Nc89%(Kj*mXq^mG3?Q-=4G6eDfoFJ*kV{)E+>@q)jTImdx5g-bua ze{TO@{M7#XpJ5CEGXfe`GU${VFw8q*>9F~7+o|;zc=**A7I3aeFJH`8BE(?CsKLyT ze@B8zmP4rEMXCHIzJ}+o6|ed^GG1yuG=t#;qXgIDlZ<>0Et>2J+6)&a8uF&Gi!6BF z?^Eo0ZWY6eg^F#PAIGbUKA4*y{P^&{Im-?%Im0+9qI4IN%kk-22hJA0p0d(D>;I<* zCoh09k~nL`D-E5mZPkZ-7=xZ~ztG2^VriJRdv=F?5>G>eL(aRx+yV}jyB7kY8$#V) zBosF=INW$JW&K`jiyy0{&F{&+nwDBHoAV!&-E!jzTn^<@4By|}+M9h`<bf>z@zb&s z0?KuG8q#0CdhN(`EpdrrlU5H)lAvY$y-FK~Cx^Y6CM@ClxAEO;{maLu&TQDfSN^|? zfXj`~=IjxvtPH1${BL-QD*V{xa6oByXH=b_)&8zPhDkT=Eqem*N;?<a7v?i*X1-In zEA_{*Uhmu@hS_SS)`w^OVUqi_+nj^pp@`IPh3OwHPpUjnV12OR`mGfV1?h_qq#wWO z^Wbj&aohC!*4~U2&vjarDh@ps3~5MzeZJ1~QsAp|Mi&;`f3tIQF~gI?!Hg4l{^i`g zIi)by@xbqo{!0$p)Q42uJC&Fk&~R*(*7}`XF%LE@Htk{9a`WvBmX6i+zO64<ikT)^ z&tX<M+3?|U?rXmG>I<TBGDWw2mNn~L58U~@X1mOX(vmGd3~ZWMT>iv1^_qO@78MX^ zXsP#`UcBKe&#nW9dqdOrTvK5vw|%$u!25;eKl>-9C(pgT--hA8i_(|RTHW(jcU_px zn6ZA75knAbM)DtLrdghbEUrwGED8=}y8qf4$X#V9b;+zGd4u|cqKO@ycWbY_JzV?l zdUf8N1a136GrqkE?kSjZ%9G)NC)0t2ZmX{+nyQOFST}bz^O>B-b^mXsx72$a_<i<& z=h6bEhV#y@FU}S>lo>NG*glo%!WqVh6eD5Qh?Hf90_vQsw;Spm9DXoLva9f%&b@o@ zVSu-L`^pZr(+_4@E{}dEDRt!0^7r>-_}C}ZUuku_nZ8+2lw-!0VAdDT3{l!LJPo_& zGqC>Xx%pq{s6Y!RL)si>7lu=xK5Y2D<WB9mt~J3b3#1y#K7@Pf_00WvKDT_=+#lJl zt}nb+JpI!8?2*#svptr^3uNE=?Ra*fH`_da!V&TL{jL+|md!q$_TAD`<;KfT8#N02 zlcyeznyM%m71AvDbVCa#!!s|&eOwG*X01L~zz}xfNV*+2M}HZMLQd(e90kUQf{Y$> z60Hk(`gN_Uo_f7F^paaVW=R>(GT&sit;Pa!a#B)qeC-U=?Or(@p3D3HVDR?!H=h>m zY&Q_R)yVZhg<-=2hc7IGtbwhZ4DTi|9Xa+Qjh(@OhaqR`ZgxqAw;{~4JVYIqF&y0Q z%9)|OJ6G+I*zLslz<t&%We>kRO=EJfVUeHXaKPZ9{IWeaVoQWB`h{Ic%v&g2U&?nf zj>+NXDvblNPK@vNF!*pVa532R7dO0M62EzowXA_jW{dcuKgP2)7(H!66ciHYo^0FP zU=}7K<udPd$~v>er#krxA{jet5}z=gV^U%``;ALs?paxfUyaMT)0^v35=9Qo<1bxu zc^OlJ|Kh8!Lq#6s1qxQR|5Tr)#K~YczqsM)0tWB3oBnifSRBg7f2-T$?r)_Z?H@La zi#039Wbm=ntWjWUoPV}u_C^U4h4m-il(I7|JT0yle_*PznG(}Mezqq+Gaj-Wki91I z#BT$?{w1Lg6P-C2=B~AkTfKPWskjfD0vh}qm~}uMh#HLp;fxKTaY4LlZ){)k&No)R z5o5OKw?IQ$PJ2kow%>DD8q})VH%?5lXz>#bJF)5e*9%imPuJhkb*l2jk-n2xR6grJ zF;mv`)tlx0xXdcHV6E7PuENU!{o6$^C#1x1=RD^MIK%jE4TDJUMOKFS#jF~qS1_EP zDmJrg;}u&0fy4Vl{e)k#r*E@98}fPPiJ*s%ly)?7`!pR94R32dd^lXM-#v<<;iqew zI-4g~x38?C62sjctB&Msvy9|gF#Bo9Zcc{7E{seJ{$ZM21?pi<9Kn%J3;_%ue$HWL zVTgJw(x5KbaC8Y5&&+4a7vlx;U0ydXQU2Vxq1()7`Cli4;9ZZrEDBiPe7e1`MwsD3 z%er+dHy8*gy}V>+o^~PN+fNTo$4R`*i!PPSVZ2bc>3Pr9z@yI?z6&rti(u@^|6yy- z^YJ4GgPlCrg^<vv2GLIq!W&dR^BX=dni#Qe?zN*vjpjLvESt71dQ`h4Ic?)fAFpL$ zEl!Qv)1AZreP|DuD9aGx(x-jMCi8oskA#YZ${+3@8Kx}^s~mRUth-~W^1JTM1|bHk zH6e^j48`}vBWezVI=n|^6wWc8dcf3vID~OlAfwR2a33+&WJL!q2Oi#JzRfdDjvkp; z=xP?wpz!@pxRdU5{g$@>zqcnYV#xd}5%1pKpW;1vYT*_xr-uKJlqV+gbQ&=5&EHrh zeU;%%sj0+AUU3Ey1`)1;^3>3&TeoyEY?!+9fSTB=1q@rnCwbaWzRSsVA?>h>1dGDU znVdN$jdOO_>^yv(iR;7u_M1{x>aiN^=hol#ymtEMoHcAbj0p{2rWYUD>d3h5*SShr z4hI9a19x{%W)J}_HJ-tcpeMSZ)LzR$r)>el1-~WoUn@RZPGeP(ykNrNWAmWq%M@Q% zzv|;pL2DOv6&Rlu1q-TuZ~U_O$m)Wki|uj+?3yYaDPL}$WogiRzEv(?n_)vc;{@H- z+lj0@(j>iaTE70cT=t&z-k-lV|J!wMPGi#2?dQ(N6(2}i`Fhqp5BsFUo0J$dd>B4l z6j5Nfu<?G?Gafd_sNK<quUR+$oly5=c}K<m8ydplO&9n))ucGoyq|K--N!A?v@qbs z+6z<TSx;EnZMq~LUTyJq-vr^)^A}yeebJGz<8ou3bYO!YlY<ev#$`)~y?l&2(!bBM zwO+}fzWY_us^zbzT|M%b(c*Pv<otE<=g-c67WMbdudeiYOa(c~wHJ-|iZYnn@Yh`Z zFTfz8CX%p=iG|@5$AK`01kLXEv)-IxR4ZllUAD+6q+!QDapOOye7P99nLoYcmX%$2 zVXF2*?K7K8FE@pk`BZW;urOy$KFGMvE<-6*bdFxNfB$zG-lm3`8}$q1m<rq%ACR7R zbY0}t&qvRCoMbp)ctkik<XUc>P<-MfMuB(B{zfwvd~Ia>F@64jNb4z+>q1n<*Hb=> z)0*nJ*^DNbIc=J)!C=V3vq@n37D4q0#sw#3_Z9s5(i-5v$Li#F%i*N&&-UK)cV}=) zEL!^Epc99~0p;TLZ}mP~mNa%UZL!i|=w!OHbuw4Mz5Rx_Tb%?vA4kS~iT)GY`BU!= z_nZjn)$Sj!T3d!ak7s%1Q}t)Ep=gU(v4!#Tcwa^ZX}JSCt}`V&GOYWZ{(Pf;!LJ?L z9^5~_UEzT<qk+^FudSzNZ})#G|LQJl^K7%1zfSZ;eYzhz-GJ?VwcuHiJG!31-|Kfy z+~hlZi=_YUU!mfc{<WDDcix(KWX_g3SO32<SS45+!%(pN6N4e|vMr~$SzI`T#CKH0 zJ31B`I*P31cFumx{Cd^PpoBv%F<ee-EJZTjakS_$+}#s)%SL$ezW}|YkNX`SWGKk+ zwQi8HvpALVuRlbjM9C#~ncO2L>#8R~tHl{27993@VOuRCUZF0caAJ4F-7cmBN0MZB z94yW53@zw+o}Qe|A^ZQ;u{Ei-hS#KT&zKW(ZqB#o@BFVOKP+xASR;BqMX14GTIP8+ zhJwZ7ZASfE2DvesV*k4j?ayJ@u%@hXr~agl%=i2Ub}nmYN|28;Qp;TQA>oC0kBb~f zgW;*OJqFQr6%2WQSIuPHaYy8=8AJN77N$FztUHdUY-3``RN69~VZ)9c3l=EM|9QZx zG~~hj-ZOlQxP8K!L<GZku8_P?e(PzTW$m52ZLV`8EhT4te^VyE_D5Z`#A?+OC-w^* zYc8BWJLS5_?HHMc#19SLD?uZYd>RK#8Kf6_TQP*c%6yWTFl*O=d6kQPTzg&NkhHPR zw7q`b>bh6c`4i$FyyTtAq>{|W(DzV`CE?bNpr7s=%lZG=iZCS4QvI|;<*t0g#CGNf zOmS7m5*S!qO}Y6$2`VyV_eTHo_qfHzuzlMZhBMo2->hI^koRSrHrv~f*Uj#+FO$IK zn22keXRa$#Hf21!MnbHUYw}qM!%VxoLK&GE7O(hBZagu&nXRk)RNMZ{GlvC_?Cbw; zov@oJ_P)Tb19nlu$8yyfOc)tt85~~EG`^;ly>jk(#&;{2=4`pr+;}UP*}(Ai`$H1% zkG*lwU!l)*VBJeoZ~Yne$DVT)TZfw6JuULUs?yPv;ks_NwAlXCUPgn`n3Zk^%k}@+ z&S9F;^ux&5s@HG6Lzr%MQfJ1@6b>ea#lDl|%%=8AoBnD&(xLLQsphuF2G2`<##3G_ zy=BDkU{YZA4DTbR##Kt{kA?m;n)UWbGtLdNxya&_;kBH#+<M*2OP;BLbxw<Pg4}Mh zG00pz^i9%6zVEsIrkG_{-50U-hHYoj@1O3<;P&N*#dICmS^&duI)WFy96!u?TpK03 zXD;jE9dX-D7F~A}S{1H(=MA@);I|saW16N%rvwHt=smoho-fEBrpNi-&f}s@>V-@b zq56&B4bV!bsu#`|{VUHV8+v5Lo;HSrbI%z#84mtsm3-#Fz{Ge`Wx-6d6DFdf&o}ca zM*Ljb@xnoPCZmoU!!D}>jC>~MrU_TYx98OEJ^Om;NB)upjtu{vzu%u{AF=R=L(PN# zk4zTc_1VOoYrD2jK3cHh_i7Q=9nY7}4QY^;GwQ!?I-Bc2Rh7&8Csu6A0#?#r7chiQ z%HL>L{zuyRqxsF*bC}Pt9GH4?okV!U>hE)Y{r;K%{^<U8<_A1A8v|D^WJ+P^k#1@b zt5sU}zWaDjFz4brO}j<C>c1QqKjkW43aN2)-XL_<<<aS12}zN&8s|UV;b!m3DPYoc zASRAYeutSyfC=lI*_Vz#o^>l@&9=_@6AZi_7Z-9JkW>;CW!NOX@~yYa^arI6o~9{n zOwHtDv|wJ5az1TI>kpX)Y8gF89lMxrJy<G`c6wE~YJFW$f!?Wk`WbGI0t?Rm)DU4% zyRRg(CZ<S5?5f*~+r2_Qn`}Sm-!qw1qyJ{J(tI6RQHBZcMH$Xi+8+4(d$pJM-nb8O z@$Re<0Wp1<=awZg9Y{($a5QO?+PWFbJcapZ*yJ84dr{PVHdlZ9>A0JT>k2AT*IP5R ztS;Cc|Nr0j`0B;Sqe7yx(*88}KKOU*?webBEDIK|^0Euga$`tR`r_SjV0xJk<GJdN z=N5OT&S2*>%}->IS-pDw`=~oB4tp>&Y%t$u_?F$gB*Av0+4*y`<D;XypK5T$?3b9m ze!f4Wf_$7%gLE>pbf@d-TeGTSW^z{O|FcaIu`mneW^&^_!1|+VF}Ka~M*UAAxrZ$L z_J8AW*w_1Gfz_k?3y(PSP4GQf7|`%%eTeW}qX(hMJ2(SUj_i+6U<u+|cizu~P0K{e zH!_CT;hWM{mjkY*JqibAPu!%M=~)?e&3%q<(cKjy?$4*mteG3QSBp{M*Vi|eg^kKq zsUlnqQ@3-luhu_#=Z{81j$_wn+uDN_d&+En#@$G7-MqRagu`K9ID^4-u7d0D?p6yl zxGv?(R}OAqUGbl5LisZ%VRnV8-De_pKX{ZR#j<g3p2q5xB_|rWiYs^C)!u#Zy{^&j zy9^=icVih@a;pFK?$^)F`C`PdE3#O1M~WTOhS0J(TV}1@6cg3OaAq+>jVEKmJJAIF zUfDI**XH<C?MkcGWO!a}E+qY+`P?sdHT6KoZ`Zyz9p)5S^IB;^XJm)(vlscr)Asub zHPlpiGN>7FZMqn3zG0%w^=T}Ba+SOpjuf=&u{Hz?&1If5>C~xHJu(faSsH4je|o*< zWN7vk;d9h|W7Kne>d$>UOjt7*9L_bc_h?*_e8BOSiFLuW`3~#OKVz6)>if}z;ZV0r z^Q6Q^BVDnTrd}&}RhI}>xAAE&P+Mhqh_!U13d8b{^{aejH$893{ICs_WLMo^^lMVh zJYy#Dh=6ZLO4yv^ZJzEm2-+N|$nZ0c@j#501Div3-O9t)BT~0AF?<$mU}gQ?6}(2n z^MKN<|A&$l%qC~Xr!GDJdh6}Fgw3<PxetE&%zd^@yUU9`X7iOd6>m+KKVqD`ZAMA2 zVOx04rt5W=qPX0Z8MsR;EA{^@7h$klxAnm0y&I1lyOHu}=7#-uX4mtxAFybZWn3_k zX@Zx6?F$*P+EB&``N516PDuwen7B=1Nm#T}EO5h`mAQ)?w7XUM85lMRUt#cZnb=Sq zq{Z-XieP8aQHh>Qwx?(49-cRmdjV5S*db{xhH0)r$?9(`6L0Nslx$^k_!=D&KZ!XY zYC(R}yUr^?&v$8l-TWxOTl0XLAj?fg)*ac`y%`*e>sHD#L}Zm#?ET65lk1z$mRH_& zg+lGWC75@bm|UGQ{e5=Eu?r^-7U?XU@6R|Ro+n!C4&Mf@-FH8J7ZtT@ty^PKmnq0} zfrX)jfkF1%rAvQ*bC>SgBmSu@f-!AdujhXI!@o8!pVqM7H-)po_}9!AO?fggw_g}# z?5x=1FWl`p(dEht|0BJ%AAcNb`m|Ke@YuAUW}*z!O}sl*a|Ql5{@6UHYZZgVQo{$$ zjZeNacBPegcV?)ZV?5#K!Kff0u+C-O43-BzB9%P*z8cMwx;oj``w~;V*}8V-2T%6h z%1Elzl5KbsD$20wIrprYKQ`$t>QS;}Us2b+%Dafc%u|RfXj{6ca$<$c-#7_|pDP#| zYSuF(_=zeMEZb4=|KH!GY3ZS%nVC}$Gcu&G=%`#w2x!)_jo6^?E2YG6`ZT}XmWM~= z#F`ix-s$V;o;|N`l&qM!`|$5w*Ps9U_u9+5<dP@n?Nd`Pu}J4HzM04>e~0tW?fvqM zKMn~mWSDRGyWeFS!;Fp>roTQ`D8;E=J+?SCsvz9(*~+zZ7X~J*%<=pgr2l%U;67j0 z11>JBUrQg<D3Dy>z<Xdt+k)IVXF4ZVtZ}^5tHF?>zJy7D;|6=&GP@hoSsv_gOpjrb z@N9e9v2WV@nUjBPw%BoE;w{Y!omM9|a5lUNoqW^y%g-jczGG)|L^J~vp6agBJIP=X z%r^g8$HeysALND13f?cy=3u8K#Ja4Sm7#bg!+bX3s|jTXjs)$9iCUsFt;6)I=I1%a z7Wx9ULT=yoXdhU92()}~eqQ(YmnCh2uC+Q6rMqq|RZmKBV&q~4b!T>(E@WtB5&_K& zYKSZV?E-o9SP<0X0?mPgI;&i);2m=Yj1CJK<}<VII6F@vwlwzU`u1Iq6kRun?bfN= zC*t>oMNo}FLnPrH!;ZD{6h6;8`ZY3Wg4S%a`1pQ?n5}2l>t74pleS*HgJFRI$U2|< zJNM30n7l;YW18xNJ?X7Hty8S#zDr{8Nm;Ib;xS8tjar2=Xjz2tN`~!sg;SER&42p- z>#?e)D~)PXH-zaJnli|5Rre@kI3d0+(`edjpKY5xzFq5h$mzSQC*1R=|B{OyZvWzU zt)I4jb~VH4fY@J(w_0;H>2@30J4_c!I%%i)GIWWt+>)4i>ppZZ+gtbN;HqS91}@ee zz6^QW)laNiYR7InQJ^8@?%F{1-+|RlZ&Vm2ztx$v*ECH)+IZq`W&@#aw;!qEX+jg9 zy0bpG_SVU))TB~@!47KpcZLVg(v)}_Ha=(qtw*q4&anNya7yyNJX3Xs9zlk6#te2* z{u5+b6V$p{w(mQob~a4<&aofcnUGA{bC<b6lI7VCTl+%B13DT9mcReT@||U2&KXt) zVV5W^y${M?Sxz!CEdIs8G{N-74C9Pm?k&9RE1zn5PEzX#4Slh!(DPE()}RTp;_oLf zS(XtaY`AK7%?bvO=oXhRLf=Y~-<njsti31QlB?E{_}8`KyTG-0mrU^{Q%>PaJ(u)a zdYZ~iJU3YB2s&?SWzRl+aK)~k+*!YxYNmXccgeacp<F05y1yb|_51G4T!y>${b1^| zSdmb1vLjtzUqUHp@~W8}JaJ9^$8NCuKeW0e^Lx#UvZtFLxpOn*i8q`-Y+akdkhfX= z1l!A7w`VIe@cArbbrib&j&o8i!?E647sg9$(uF_v>A6Qr^R0Q(a@5ZwpLbX1tIhk= z%3|glO3m+HR{k^LM3;Z&Gmq{G759twoL{l!asY$J`A4&bPySQ#`Yv!UzGXXiAcM#4 z)(wGY3|Ln%OxXJ2L9XQqnOGTtThlwT#eZl#v|U>M<-j|YRomnqr+)mwv{EpG<JcqP z!q991lfNPs_lrMd9h;dNYAjfD^{MJo+m25&FTMQ}`*|^FG-WgAfw$jSX1<Avoxd*r zG%rK=t7q56UyBDb?6^BmA(A~IIQSB4o>ZrIt;I~&+XVs)Zd<o3X1_ayML|w{`SYda zKNbFWI#@C(P7zb|o@J4}d?Jg2ZFjz(%I|KCyUuBo6h$xTxiBi^iXU>@E!ptJXpb&C zUtpAn#IlkHy2pP)9L}L8^lF=&;9Vw3%iPJ6q9mlw7_iEp6UymTyK>g$l$(gS#QJ6i zmBmYL|J?n|otuG+Ax*g9_8ZP7)5Wi=H`}qk_!#lZ=JU$V3;nl*RZcTpne=HcgVD8w z<dtEodN-NLa2%-64Pv?Lx~Y?Sg52@!`emg*7dTGHh?)5*iffu=qvDpiZdwdm_nc;1 zsQp@h(L1?AA_}?Ulb9SHXv@A0V|bEld1C4BX<rXq5nNiyxo<g-tZh)%tiBfx?-S<> z<SH>UOjlv{ve*=Q)u-iKSJ1lVt3f9<UY7k^_wyoXqx{WYru0qetJFQFi6+cfbYnOn zm7^kLxQKHjFFR8~ON-nIzB@}8CdeG;n0oK2^GxQH-Osi>_`}GMY0Z1D+J$k!k`~sA zdco4~0(GXB^i*UUO18)}Y&2o`v`yv+`?cPF6*o7Y;!_$AZBt*oSjeU@Z&{=om%%*7 z2H(e?I;&=NF41sca<KB+%5+8RZYZeGEapFuzFOU5+H;244NMl1?ov5N(jWa0U7l;f z(6Hu~;kRoFvxRC?3*SsQ*~cyASg*UZ{HF)|1B-8l><pXAq}F`zU~<@IIq?lBkQT-8 z-fs1InbfJ-%j96jb?G9Dulc+VsmY-5TEV|2+E3@@fve9Jw|t+naB;g-gZJsqR86mC z-w%ZMx%@1B9-+@*5iK6baAJ0+kLL%qWg!eHNU{4&uHpPt+Z+*#I58Ji59ypEO;xeM zQ?@>2V2~0NJQABS*K*nh<<xj#Ydx_mlQaslruUaHxb+@erm1=CcIJkA%71kB%`|BJ zDjrzM?-{4N>UB5!*0zp7Mg?isml;mYCw4_mWqOipxq`n(b{3Du{Ts0GdoU+3eTEX_ ztfx_)x0i2Wf2S;07$UCPJIRuv(pa$IrKd;)qk^>R-E}o#e=<P(Hy95bXFZU6<J<Iu zOSfI#asQd@hPP#3I@n%{JSbA~V}9!2!pXqJz~yao@TkD0TbXy#Kl|M<v#v7Gw@<q8 zvN;fJ0%)liD4BpTxLO0{$5yaDkX~WXKqLr*>arg#-&h(yze+FuetqXfQ-^v%TSu@B z&jcE7zv7(aab(vYOO^*S<}({)+dBSpW(b(Uuz(>!N!r$NgT#Y~cMKM{tyeP`Oq@GU zq5kL3d{zdqHEB!LPkcGx_Kso4i+F$b2aCV4{5*ZWejh8NJXgb>w9n6s&P2#7-n^D{ zXUEk-{=Cz(GD56XJyaPq7-l##<gHgfvB-Yf2}y?QHrkw~4Dw6WJ^tu({V(HcxN$AX zn!!XbpDCfLohK3GyGYgt->RC{RE7y#GCyG3^@NK-eyzI4oTT@1e@@#mT-?U&kZH`o zx(t*`=O37-aJjT#($PofPsTsv;#+y{QRGst4->1J0zTaBuIJ}JaK%k#Pa`{%!0o6r z6Ss+R9MF|2lh}}Zf=A!2%dINGg;#2AUZ?nnZg$mJ`IY@=&a1CAi7@9|YTIGJ=)icu zo$-NLb(6s9L;;nFkC@U~9yEVtS@>eN_<uR6hL^n-8O;1l4u6Aelw2hlw&&bv{AI9? zN#S*)<F*0@w%&_BELat+=Wn@`aMz+@F~n#6ETH1^W+AAi<xqUE_s91O3_jUM8l$J1 z6dq;0=4Z%|cXLDY7GEz$g_n&B*%UJ6Hu*4~I0tdmJq>42G(AveEZA-9SSHhO`yJ<} z>Ayd`U&uJ)T1T__hX)KMJ;qQIf_5KRv%Z6?;Ym50Lz&%LJ+Iw`8lg_$c(jWWu~4p^ zw{)ID{Z4a`PcnNgDwz4%9CW0-7<Yso?z70;Cl<^&;oAX^&<1WUhMPZG8rDd+Gb{k5 z5kAHrIbT_Rp0@o7ar!~&3I&GKi`Hz_Whz+RCc7n9=>73Vi^aAE2i)WOKC8)X_5D~e zfoZ~Ae})sg3JVzY*pr!3KyiGN?SXOH|A{Bm7%oc{ZeWvRX;5|(5=aYtaE8}9Nh(Kr z(}CZG55%r9FZMUm`K%^)(dgt3g^d~pmh%}Vls*o))x0GA90w%H-}$GmY9~i>7SRB0 zH3#ic{E!JwZafhOt`&dTZrLk7|M`05swS}MXG9scUl%^If5*Ogn^QR^Uw-|XXWh-$ zpyL)nIt^-{f2(TBi746`u&&&2rOD>2!Y+`k^kCWC+pjq%v3|I7*d}{6!;clVjx3N& zly?u*_WS%b_TR>>GJiAIMeq6(!(x}^FTm&kPW#dC?!@^|aLFmm&D~_@?l^&W$B#9w zLLUUHn?TD@8AKTFRDmot@GrG%ub;n0=ArU87C{CPP^RDU^ql)kPNvyKKfYTU%$&pQ zld~J#<OC&T-EvUd?#-WHw<5y(ln!=Z&VIeQG+UG5P9DPMw|DaVCmfCIPS**qydY8Y zF4lJmL&0sh%k8e1|C#v;=JyYY`yQSTgKK^yt;e9@<J!9XE+|)m3Jgf(h^T=ADu5wi z1}Hy)iZE!=1x|@w`8yaEfF-~aDQXNqVwfSvC~z{^g)@kJn=lKDMnWdrUJMa<x&ad2 zkt$q^xEQ#gS|H)10g8FhWCmyt8^~$klnq{(#@C?Sx%>ktFhPfUNHf%Ewd+At>sWJr zcxepUl@0P7=zI#0xDTkC07`m^;4Pitau_sQ1KJ)p1Js)UCG|s)wW|yoZ|-q@*sb;Z z-uHd%<xd_~_uo%YsRqk2h)i>4ox$e7wqKp60cz6exlAF&?hx}6&rfCYaboA3;pGBy z!Tf9nz3isw_j4}g-`f_V_OfZ?@BObj?Dp;5du!#{^(#A9%{yw!J^Q>-U;f{ugAb-H zx1Eyt?44=T7slMLJI{HZ?$74?aI&Pal}Uu5riS5-vB$=C#)J^5$hfSis)qfS_T|2- zbz`s)T4+D}|MbfucMVy8$WC2)_`}hjget?w>p{-5o6XSUUEFr!Fhhagsjmg|1sN7y zb(8xn^T002P5I8=rf;9Ls}ICgWrkncH#tdv-w!464*h?3zwi6~$ky|Rl4%jkoNuu< zp-&S&&wVAlL-=28#ZvHQJ-a*xzn6~$nYj+C?U<Y<RLr(^V%^Tgsgo2r7-EdFct0G~ z@17!fJ=asXbF=Z*t*1K|sCYhbai6VXTXS>z=`9}|-YdK9aGM+^-J>SPqdZAu$#PxE zuan$1^He{cr8zf5%=gIeFHybAI^XnWEL~u7RDWMWhlj5ggVL($riMrSMdA_{`dm6J zFD$!hBY&I0^p32A2_O2VOpm$Xy)~7mK~Q<0oV?Dy#g;ZU>*jm;NU9w6`4ro9@R*BB zllFl!D;buAJ^qtsEjqRIlHDBGaPXn0vwsPtN_n~qoUjY6nyKom6KIxq<%RepXG!R> zmoFAG8Tc}Wg@#(r?M;YVFQf46^#{#skGy8_uH960zTEQNzRm3)KJ8x+7-IBwhGUSC z-@m)}1&{1qp>aa|qkPW4-QtrSC7#N#g42wh8N;+*^`uLam>dLJI#WOC_DDBv%$RJq zktZakEq{BU)O=-**Oy=XdeEVH!^~Cflh~4{JND_z8T+Yb{T8~YTGrpuoPDGzwUhO% zht8_rf^|o>6sN3TW+t^dm+9LP;m2-8ZzVGo?bsPo#N#hWq)#(uGRR{QuS(IgQ+B;M zq4(F)3YRV|h7^5C);Rswk_<0je>^y;oF$nxQ2Mxd+yt>n?}gUIanJ87V~d}#Kb#=} z+?)C2y|mtfTdHBH3De_PHwLvCJJOs}raLk`%5dLOn5vPwafhRPuQ}`HH4Kam9s7?N z8*Mi}a_Hr?%Uxy5K0bY45K{Y`U3X!_nX>i+OlLT(SFe^{wVMCm19tn0)|>Ne@7GTJ z^sZW_H*4ySlMSulWShsJryO~~IfUWHahDTJHNF>F<usk;i(*Wd%gdTG81=3&{nBtw z!y(C!`Tm}jw_J2zsg&_|C|_q#(ajLCH%Xk)WUL`~vA-pJhT##8T!G`YS6I`;;~&g> zuf||zy73^xju#U;x!26~Vw$k;$qvJ-tZmmHH><wSU^9uk?6HU^-r&ZPz8Hq(4GRid z)t>IndiLlF_p|9<{|i2Zp5%C7?8&GgtF)Jg@qvY$L};6lU!1MukqR@>gu1#!yFU%0 zqHp!@?yui3xn|X=yz)hYQz|=8emS`J`EE~!ZCkgNexLL5C8G_W>e|^7wIM$^Q*!UQ zs(pKKBs|8wf7jhSYXPCRQ^P-fwp979(f57b^naK6LB&qZ8HP8PJu(cBvc|2Nn#Gy3 zOvQ>_;>xtCQzefDKfCehL+f?P1nY`I7iVA34@V+qcd)4*X4=hu#5a?3@v|!oF_U<X zt#lD;DDaqk@W3Oc7ko+#H;;;&be>_jqLd<ZaiWnjLnq(Np7RFt_#BRSaWQN?m2gtB zP}abBmcl%(13`cFN{uHbgmg(yeaOIY^66wyKulto6U01$cVes>=YiC(0R>BDE_&C$ zgyDYO@3*J#=q)G}_-tyvyGD=6ak=anpPc`7`46V1oIAsxvUA0jEhl(*jAy@1+r9VD zTe~iEgYDsdTd&EV-W|7u@!9k<Ywe|N%HQ33aGtH6t!&;+|N38-%~$MgE4z}I3pyR( z!$&58T4OFTz5^~wZdXM`)fI|n1vT>&iiK(<-+wDu5+^?8gh!1_(svieW9FI%)T(4f z8KzH8%usv$<LG6^j1Z9o`4z`jn235$FmG@^7X0{<j}C7`$l|386Zog?Tl{fT;g?eP z7d9)T8fp(3edt_Xa7^z|vx3gAgZfkdJ<MTsuyQGr6lJ*Te3FmhvdDw?b>A&-%ny2d zTL1r+oA3U8{Cxhto#y^EXBg|YFEjmf?RNg&(x;!MYKzRj!v9XeZo}oy%WYHUwze)^ z8Wwr)YkA4J_twwmUEkN=^W>Ps&1-7gFWoOlj(&Zz0aO|Fi#G%dJ-oR?(Qs+E>M|4K zk25U}yZUsgEON6=VEcDx*`ibBd)DVDTCv{$FL^geNvc!dBT$L+_oI)jHzZW;ba$#3 zH`IQNxG*Dt*??(Quy`P|!7^rEujT)be=S&*=C!jzsKNVUhKX2C*YV#kWD<gNYdG2V zv-m7>Hk;;J7^b^Wx;;^ykt1Dg+7?ZQC(TRWYjiCX{?PK!pfj(#KfrR=lGf9%B@OOo zGIN-Dq!xG1RIOY2g<oB^;n+TnpA|0zZl;DXgz&4XbA903c>evJpO4<%)$h2gdd&00 z+tuqoyt^J}xsqXj@MWtnXU*$Rr_}#_wm#$k<Mm&R8gAT=oyc4rG3#Nu&)wW!&)7(h zH6`_eTZ00oW_(Bx;tF8+@R4alPugSGMh(Zcib-=iS-)C6>(i6iveme1(hnWh$@f1- z%nvhqRDVdbd5LtV^u>0$;~C3Bwy~&OmHfHaifax_LWsEPt02|`b`mZ<9vfU_<~;X! zE3$tFS3}0*i$-e;-IM|!R74ucnV9rmIk!<!+P!B|q2%IypsuBl%a&dHCW<h8I=HF) z%98HO<;{T!OlqA^AHMkYVe!nMd)iC}pl++d?vPvUllcxvdV0H<{tkHw3yKL$AG*)) z_h5W5{pFuzkvl90>i?=8{nusB;BX-6)wlh1pI3*8GTh(s^X&Zp?Cy`>+x`1-Jt2Q_ z<BTU+pO`W{K}`wya*>MVxt>ve3V%bwZj1Z7@D}@@llVPt{;~h3qBRekw|D;+$Z*e> zF+q!Q|1GA5`qL|zuKxM`YW4a{3_r9ty<HzIS^aZ6xFvCqVMd2U$*%;jUC~=SmmB-p zS}OOr3i<1U()9By`#iJ`=<nb2`|0$TLLXM!|NptVgrRP__JRF|EUO%U@b2|?n!x;_ zI)w4x@9-|cCx3sQUuoQz5|R%(T<yml#(*1sS`MxCS>h6mKUnWv`St#M$HDq@90&5v zm_N)mQJ=BvfWBJngZ;H%*Z=?JE7TPK_vdPR^{Mji-~nBxhC@Z`7$%xK*m22q9kl<; zS$Z@4#g9n>C&DGze!l9D>tWK?`yjoN!DnLq{SCG?Sq83*+wQXb2)sYz5&wr@SK}{p z{!j-66)QvQ(-OW33?Dw<pBQ1#redH_^SnER+4qNN;sg0qQ-=Bv$1a$kG$?phVc@99 zxIpHH^J!mZhNrXdFA<txbtrzR!iQo*c5vG>hy5AD|B~l#J3GJIIaT*dRx*YBd2!BN z{_itCH->8k40Vfro(lHVw*KFjC_S-QrlE^zO8wXOJN}-}i2tV<d~G2pFEce9Dof!L zxO4Bj)$iZGt#2RH+ZMm)M%}9<bsGU`*4Op#yY(#@;!iRh*#9^1WU}YR`2WAm_y7DF z%XPqh3Dblf$I9#fempLpto7~vgLmau?9^tvh(v%|tvCEM59}`2i?cbpw)V@F#2N() z6^=bW?uo}gD0z^^dP3&DecZk?*DjQn#4t?h`&Pb5U0m<Z(#?%C8Gbw!-Sq8T`|;A4 zjCTR&Du4Ue?^?|SkAS5Nj!gF$ua^I@_~-mWk-?iUm_>-Aj)lR6DUa>JeG3N3Jq5mo z8OKg9?&%QMyZ?9gB8HnQcl&%gTmPRe=7GK9{}?@SnX+AyYZ%x5{PL2I$pI9SKYSPs z)a+iU`!m*+v41H4{x08vK`y^3T0rK(;dcJ**QZSly(YvmFUy@FN$N{@{NK&jXA7}N zrWsDKkMnrcfBQx2<%Ugt+~uAsKl1A<oa>%e%QUPt?_~NT{4m~lUX2t-#jZxd4bB=4 zj0e<N8fICBvT7_nS9t1L^^~Y*-!ztOVPKHl=jq}Y^43u-DPl=Nmgi&<g^2cq*W2VZ z^#zU!T}V|9)C`{cw@`NHtcP|Ls;2xxnQPsQ_!SmS_c(dIaPF}cbw@SNE<3OI_R-8Y zfnw|5HZc8p^G^K0{@wB=M*qcaA0B@DBcOVLnC!!4%Weg8I0&)*EahS-<P&)D{eAuK z+wU6^j~^@#{kUHK!MFe0%m1zSz5iS8{IqFTzpf}2b2lq4K4%-#XE{OMZqB;Cvlk80 zA|AxGnK!znE!jUsGwJr7U!V16ThG6xz`iZ*roivhWw-AAW&F(DWIw0SWtl41)~8Ji zmla4aS*;<aIi>A))!uvWzpKyppS8<yjqlf=9#_?JXGMKE`1$YC&vnbHGb5~Z*J)=n znw=K-!J+DKPtJM%<R{NN8It$D^t&X#cd2VbaIi;%Kms$zhFj4O#S*?=+{*gmO7oVp zpG^;aHf5^udE5S+b=t2Nsr7SW<sxIe8<MlHaoqK&kbPQtcGvv_Kc_`^EB`xsO5(5O zoA{bbOfgxR8(1o43qJ@IN-#Q_*suQoQ}f^2+Kc-)Ff6X;Xl**VdK1SXCYx<h4Jij^ zTdHo7i9dJxbLFcPUF~bxEEW6bF)S~hSz)wp+20VBH$5K%EaH?MR2(=8m{}u^>K@R_ z=Dx6!{bJoJ!Gx#b``#ocGM@W&?T@<gv>&PNm7X82Kbjo>^U>}L7bc56SW}<Ox<iZc zl)#UyZSx=BQ~0Zwy?><!_k`<C9u11GU&U^XT))AMX;0>A#tl{%6w+63Uy>TgG^fgk zDTPt(>Ld2^Oy9m8Q(AMNf{#;xDMy0w(@ND<w~}s_$IV@_NZ$7TEAv$8tG~J)?rYhf z>cZ{+%5mDZpMP|9b?ej_UrF)VeR3#I`_O#p{lfRF#SKyw>Z)Eo46_q;ou014zjekl zU!ey{+{dM!8-#?aILT-D2DB_T+);F;W4?Va|IC-!H_cS;bIkWxvDz`jxASzttCoWk z&DJwiR@l6s(mq3-zrFqG`kZq<C0w>)+n4oh{S~!6cL_tbP{PAi-VG{tn}4@{5$D^? zuy|pBMa8>sbD~&Q&Sd_=VX)kM|A$OHA!g48L*)bP_Oc&2HmqTpp~iShs$jkEuf>T4 zYc@|7>0setG@HUuy0z6LR^`^2#djtf+DAvn8@{pMU*kQuJLi?-6NVM4^92{Ie5mk$ z#yK7_h40lfe!pV%k?e>M&F!)LD_6U4X{xNEd(`wp7gM)0gi0UR>5a>O*}UfGe}U9p z(O-_fG<838`n1-;lM~mhl5)8^#bRU8?N7Wv%}egR7ku#P!k<q8Th|=1x%p>mFT+{? zC2`rO`gx}@a2v7gn=+L_<KJs_!EfsSCV9BrzU|sjzhO>oL$>;X6g73ufcd(<-%r+0 zn|em%i`k6HeGHX?E@n>m&mMhjQWVA*Ao#k6Md{<m-IrBW95^;4vaDFGoATnguHvh} zQkVGL+SZ!9)pmPauQp%dxNxOlLcLFV8%M#$M|ZBbT}@S}i`}|0*Jo<_iSHX%oX`6D zv*y{Atjiq7AD@f7U-|FHgldjC6Ryi-n=;?n_$qMCs%1z1{8x;=D!M@K`0VQ+=4koW zr@A-1QV-g0n!1?PA|~>hxue;^6_yTO4S$;}Z+^ctzst?Dfm2&_>!F7WgcQ^dtaIKS z_LjNkgM-k5wJa-mPwY;pTDwB|0K1aI)fG2IESIfgowr$)A??zy@MC`ElNgwPAA3Ko z>vFIw$X|vae@&^V+59Uocjv5MTtDCMJ8QYO>$PJROGcK(3HxQnix>58H~;=sB6@n` zkJ77bM@=-(M|^30aXD&w2vdyQxBII^6S)6!eSC36`M|;zJdN$qrHpgE8&)kl;uCvr zmh(4T=Y*i^Iq?=DwY>cd+plsJ?90nObxi$$YOOKTo|hZ}ddv@N4OaS0;b>nRc-m`8 z(6ybri?o_JHk_<hJ;A)4p*V{n=k&ge-P51koT+&yUZb0#e|D~c??kz+ylpHeb~EJO z_S?)*sd;nJa~2(;2eH!`CNd}<;Pq?heD&mi&R>=nSHgF&WW0j-#)468PPowQ8Nbt> zto=9pSLxL@lMEeIq0{>+&nulUkk$@ScGz{@>Y1nlXNc_9D(Bip!|#cIR;^jG$Z!Ai zUlVS*PFR>bRa)2N#s8~JYi`fl^pUe5@V*U`Oh|FV8HZK&sgXGjzjS3p8FvXK^fJt4 znQ?litAMJ*Zu7gEOeUNcR2}4)E;qY+HK;gLDaErZIX{d@Pq)~hUcTMLHr9oyg+bb- zVb`sso90{26vwA^^;H|4yM7Jg_-QrU#ip-jGBK5TB7b>iB1^_QZ~?K-(jj!7p-AkS zGxN(?JlN)}5Bcp7B7MB~RM738rwTGWW6bXu>EBv>ea>9<&3e~30<KQc_&<04{Q2>d zat-##y1ktmIY;($e~8<mJCU1~F_h+a{p`Gw>$~$|^ql)1jF<Z3@2_%xkhz&-()rb) z6GAqu_vguIb!qrteA}~OUEBkY{+BE}U&e5#TPIZT&S>8}vEarkt`DDAG5oa9Q9iK6 z#ob!(yx*~;xergglVVh2bZU^^%cXNw?DW2kX7x!YD@%OVGF89Uj$tZ!<tVgo+RarA zzP#;gm{y<TQFbVf-`k+c{4n%^VkGOer;>Zr?mQDx_<i;&yHaS;#jv>7bMHT!KF4Z~ z$B*cGk>__gw!V74i)BTY*N^IHWrFu7Y!<BY*khqISH62;z@t6&zKpN#i9A;iT5p<a z`KDhlKZ9w{()_4~%a`A=%#fF{nBcW_dpCn{jjc!0hWok>;Y@!ft23(US|_Vc&N6Kg zO1PB7SawY`!9TR&_m_W}`$TSRn>=@;gT*1v4|<#*jD!?eb{u4?ar(3E^aH7NJSoA+ zLJxRrS947eUT}tSS&DpumV}S0&>oIU;=JlYC#RU!Ma%ZQ_id>EwO;>7u8Kov-8mM8 z-d5w2Dfv1AT<Wj6Cg``$Hs&mdzjJNsk!`t4l^InWI6ll2evrTT;wguDtc-lstPvU> z+{=U(L~%&^Y-PWFm_?r@dJ3~=!!D7`MP;HlI3_U3ck0)_i?Oq}vpc7r!K+qnefXh= zPGxXB_n({T&sKUhXfYl$E>qKEPQT}w;cn$Q>!C+pA8UjYQwziVY{sUq9u4-dW{WM5 z_)rzLuI2P;EsmoH5;SryzL<XOz{P$0T1x%qcJF^6n&86uVV_{aSA_$ADyA(Fn;hvP z_TX<TyKyN?#HP!8SSC%3JbcDZD6z%uz(WS^4JNivIRrQbm|7SP3VSs?|9W;V!+LL~ zCyW0_G^8AGnXy@()nS_gcR*O9`YyXQ$_IXbUmn@e_xB0U)q@MSi25DTdEND;`3lzr z>4{=qJI@@KG|oD=#Q0-HzK#%AdkE7Ukkb_%J}ePVh-KspVLkEBTQx<P$I`%OKkIo* z=Lc3yH3yh{)ZX^YJ=^_vQ>2|>!3JZe^Su$%w=($eV*TM-ndiu9u>AS|41+m-xAzKq zH7FfW5>jB{U~F1GmErf~8dZ<|LJ8H@tPb6G#d!q}o;G3%W6FtAb+Eh9&yu0O`R5+7 z2Nl;gDedW<ApF27mx)K_r&HHO@12L`(=T~7_*lttyBt~=a3qa!+5`qg2Mz(7UX}{a zZ;Z=s-E923hu7`X{M5*Y0nyPXcCDIixR>+8+j*0BeVfiYV`Hu;!^wu9e;7Col;R&2 z#WGHdY$)DYQseKoo?-L<OA{SbwnQ~}GzciLaLfx}lDTX?oniU<G<FN7Q+^VB4Q>r@ z_HPR)xHFYucD&Ki7rU$ME;7w|beP|0)8hX7Z))@=OTOtXWV#dU-jH8XyO7K9Y0!=> zam;@2E0rhnu^d|HaAaS!mLj85gMdQBcb13%rZZ`iG>cUfI5*tgH0k7&rGLa_<`;8b zP&x2yd$rxn$BZ8i)e0`yCDYgM_`qYjyv&x%8w+meI!M~?|1wGWz#`fE+t1%*NlxcG z{<zUWLxf`k!^_){INPwVxWTRAZy!VN|4Gw?5<FNUw64Bk4q+-$b%@r@SW&*|r_Uwj z1Cx)x=A6K@LY=|0!G!Hy3gf#oKBo`Ja0)QpNnpG-uXfGOhgMDV^n*S>X5aTxc)?B1 zyK*Z3StA>2=Ea*bYB9dMzi}FaCR1Pk^Cv#ya$L_hdrGlJFT6WlyoAZ-Us<s&<FTm> zH|MlJHeoYkn&Sp4hC>@ZcV>kjE4aOAU*GmV4dEXT&0?6=Y!-}SnNgz0=E>^X;9_#7 z`#?5FLHK#~)X*ObSx$WNJ|&tvg@Jw3&po-{BcmF)r!c(cx^PSRbHlggWzQb_*vNIa zl)V#S6a!UGZlVijfAC24W$bfo=$+2+n!7+@Y5Rd1l^!{!$qZ`^xDDc0acwxM>)_9{ zBtFdTcmF=Gd+&VJ7hQYWx=AeIu3|yW{du;#zt1S05RtC3hPA`efn$Rp%Zg}+lC`aR zk7ukc%Xg{$cP&zA!JMDw*Op0}UwOZ3;^L^^j8ck>RqI&y)$Ludj#bV__C(s|EcN-( z4Ph2Jp{zUV_dmB-^IvZ_%ZUij%X~H-<w_F`@|zRAr=|OS+w(T3+49R$v4mMXC(duu zWGw1eljk_gw8z%ewind&YhVcF?pV3?LaNN>tM7O%JFciYT-~v-+WbdLK=@nsj24y` zmVB$V4lpzSlIpA3XTO4V#-q0v!a^HbqZrR=FpBLEGZqu(K9w1<N<bya)p~7r(Wk42 zeE7Kowq3|kWt?_e>449l<{dVkVg4EnWo~8b#1ufK?nLJYmG>9z7D>6d-zNI)y%nMh zvhG~iE3M-Iv9iLu&$*PPB9?Q*{{xL{1QWK|X1o*?{B~o#;DWh5s+T4G8rIl*3#u{i zV#&}`ddLzn&zLFX;>PRG?3Q@62qz?@7jZ!v@Or<}O4qh(KAur3Ya6c&@mEB{x?B7g zI5-R9?)NZo|FX~#TwwN~amp>;8~5G`E=cFQpzP4u;Atm(?Al9xAw}k>xhJ)YuHHU$ zW}@=$S=lTWUqhdLV9$DTN4ndEscUW{gOCEt4F{$<OIb3!9DNS|)MC`jWDN6pe2n+5 zuw_r{gkxR}^ZX{f)?zBsywupZW+v;IH1@M?8D<F==CHr;ZD15y5XGwb<a3pf!Q!&> z9shsmw7LYva{Dm7xw*9cfN$g9`KJXcUTIqwN7(9%34YFGyanpDHFE@PZI}_o9eXU$ zy<zILiKmiQ_?qXvxW}9K#K2$n*vVzR6B%sR7U&#sN_-_WpKUQu28+gWC6!=LZHW@u zj#sg)%UC?vbb^_dJWn~&xcln|^Q4apFTK3i!%^VJ@`9()v!SN2@S}Fz@6gri6A!#= z=xh-D{mlgAQ30kax(d6lIZZFTBK%-&?%F`FhE;{m-}5|_+cS^6ILyCGQD_1CwfkqC zJ{oaOFfn;>hKX+z)9;-xl_hw#^<TfwR{TeWvtV6J+>9U3dQ1|%)=v~n(3$I}UiEhM z9xj{kd6r??2Oixic^4e__pAozDKiN@x9vuj*WwvD1(@z+FgB&@Gp-X-P(5%urqzY( z$=mAmb5E`wT+!TI8+em_uFU*Z=IlFOAC^--VEi*iZ+>vc1*2WBHKuFtV7(C|s<3`# z&8ZH?{!^?fep_YUGwUe8%f}s_&IyJJ`7L4UQ7adJT)S;sLbQS9hq*!tEAyCQ9<a<P zP7J^Q_nCBclXgQU$DhjS=8Qosb9ViByZwHgiI2c0{n`8G3$0^qV^DPXAR_eOY||M= zA$GQwHAx8z=DP2H>sj?+cTg;2-3cb0ZU$DdI8i&f|G&HMPiA;-!I;Hpwwb{`#5ti> zw$tRu*_|5Gymkn&fa=-Ho(-)pz4N|q{2S50d~^T5udmndw<}ir{a1?V&CG{_1(gd; z9Ct117w=YmYj9Px=s%-k-esmM&JWBj884|{H(1*AETphXYV`qd1#?d3!P%GIH{Z-O zPT%+Yols$He(y!AAnpTE4f0pTA22Vq`t{iEdWv1ef}*+H{!Dj%K6~yewqS!-QxV^x zFJFAmKmzJMOT|;Q#WBx6g|4&{j?E9VcHGv%a9ZcUrrV{(+~?kN>oLl`?z#{>d-iNg zM!D$V2Gwix=LGeJCU6KavAmwmus$Y`X^xi9in0k(E}lzQRxzznJ5Utq`y^C!$AfA2 zWqxjqNnzZ#gyqMpd&yr}v!5J?M$dZ|4Yntx6Py+vt5*H~+p8s;JD}a}{D$kQ2PSzo zM5{2a(>$QLoq-!tlU_4tv|^g`!Oi~r?yTCRtqi;OFvWBitxOSGAed0N_0JxrCtD>| z9Ka5m!?3=CfxFcGKpx|z+efc5iTs}`pS*t($A%YT3cQV}pVoWC@7Q7WTl2Rlq}pFo z&7{I?7uOIf9Cv5;b>##7uedgx$e;7eXUF8Deai9^8RCmLH@t{rEmZfr$#nSsMNr=i z<nb8{`n#199$x1ta2H;mY+}6o-`@0PAtH>^=IjWc6}@+dHvf*W#`wF{9r7~;yMlv{ zcCPGXx~r!4WYUCu)_W14^4X~&L00&|Qtj|{r{Z2P$@Bd1=2|0|aNXVb=^~>CTC2DZ zadUq<U?^v6emUBhv*7>z{V8_peVpyPTK@k2{;sxo{j>9)P(ROLuq<5p!~Em<S*a6_ zb4|#%{x+H6s#M3ed;ed?^DN&zzeB$6$HVtWrn~RDH{qf&XTkG(fA#)3|9H?`-fACy zQ`VWOg+b9_Lm%gdMTQSrnpdog5YK12bMfm%e`DkS+Ov|+cfR=dZM**dKRp)rivLvj zUvFp1es^Kv|0RkWe3;DMZulmU&#^u$PAsnE@$E(y4o0Vjgt@{CBqp4B9yYfx-zz7_ zOmd^#?ce%+4BIy_coymHs;YXiPwLXCe0P3h`6G{ie|vj#^XsW;l1%xTbq62s6leL{ z-YyG`cw@$Y&40f0yB8~285@^-_@A+S^Y&HMYOMpyWZnB^?dzskmVH^#ll=Jop3U#) zdbAYZ^;`J$=`^wZ@!fs%ZXP+GXV#Sci#5&>=EDG+@(g=@JC03E8{>N;dD}z#s=wbY z|NigZuh_|Q4Cdy4^*-zpip}=+KHZ)D;ZV1JSncw8J4&qsug>|Gb#`?nE2OP#rFP)H zaQ@z<YrbELu2O6YSBks8w|e`$x!IY8oa<I*ty(6Y!l2!@x-Y?&IlBCkG1Hw*U;A(W zFSQm|G5ya3_t>WAap9{LYkdE7+9V;hW}kSi>Esf(G#Q5N9$Y!A=5CJ8)>vI~^GV9_ zx6#}4ms&^9+J2=09JmiO)eeY<uXVpNab9#@8s`m;3)xo5)}Jl=o>cr@9I2V-{W;r& zr7pKR_^UI-E;9wjb+a}_8(*|EJlDAV)3*H5cU8$Tx!WG!S>d)<<-m2&N$`1Zps9e7 zvta7iRfkqroj=(S?a>hZf5%-9pT9+%zGmLmzl`NWnR0?23KWCew`N9+d0fz3<j!<Q z#sMq}5ny70*v-fUPNOXhjNqobQv(Ay2o)VbeqrJe0F?#|Of8_n0S5*~C2)t7fyspl z)bD0w>0s%Ac)8M};qdGaY~TW`LWrqGZwmwCqxse>910E`0uBuz6BxlN1r!><t$8LE zfd|E^2hN`R{5iapLD7MMfrW#Si2<aXfq~Jffq?;3S}=f|%<#jX2PE>q-Vm&Yk%>cq z0c<MBU={`@76AnYkhzQv3?DZ$L-c@cWB?Vy3?K{f84p#OaQNRF28P)(90Cjs3Sjpz zFbF6xFgP?YFfcGOflNRb_;KcQK0|{qAIp!~l`IT5o`78ovXhaC0oj!g#93~9ymG)k zf<fVcoA86nRi0oUFtHHfRy$F~Fzx5^b8Ra=d^vop|9<6to08?sOe_pcHPcuuZW?CY zvn~B~@9@R!t@A&8c%uw5_(P<~gROmh$p@D9Rrmk50)@^4W5okYx%>Xl;rQ|Irs%)N zhhDrm*!<<;)Qin`7kf{wmero>zWn}cx!9eT4R7ksFJ3#l{gk$B`7Zu-d&<}Detg9l zWNg7@j)Iph8UBCDuSxRm|Nk$Nt5MuaExhjCj$q5`tjyGsDXcqc9=*AAN`>>op;L>S zuWL<amk(3(U$ds9#Q)!(ONGBKo_e_%?3y&e2d6YS>*oDA9yQ12ZsFRZha!8c%cd~N z*?!$CD)ivItoAv9A8#_AiYz~#S9D*cc4^=IlXLEZwJ!5)n7n3Ru<5^Kx7|}2-2Ls} zSh$wxPh{Boz@=evy7pc5AI#-PY9UHzGtas5{M*jI#|_`l5_^#8)sW@8M2&G0ga68@ zNnjsMUdQCW?$iCb?@qRt@6r#^liB0GZnwMQhrp)~Rk#b5R+pqcy``*t!20fYt^Rdu z9U2%M8fKSDpV=Dv_<r`?_oeX>>tZYle<ggEExO(M?z>+HgEqgvyv>Q#2fP}lo)rGl z33kS_yX+Nf%l`Q*J14|&u`{sz@X2rax04B!M(oZ{KeUz~q)LH>gNTG$^NFb@>w&xu zgFr$vrvOGqfu>{y1_l!qP|{^$0c9gda&BN?WMIH4AT{Sd`{c~{g3WE+x(o~q44$rj JF6*2Ung9;xy-@%F literal 0 HcmV?d00001 diff --git a/resources/assets/javascripts/bootstrap/vips.js b/resources/assets/javascripts/bootstrap/vips.js new file mode 100644 index 00000000000..69eb34c5dd8 --- /dev/null +++ b/resources/assets/javascripts/bootstrap/vips.js @@ -0,0 +1,336 @@ +import { $gettext } from "../lib/gettext"; + +$(function() { + if ($('#exam_timer').length > 0) { + const exam_timer = $('#exam_timer'); + const user_end_time = exam_timer.data('time') + Math.floor(Date.now() / 1000); + const timer_id = setInterval(() => { + const remaining_time = user_end_time - Math.floor(Date.now() / 1000); + + // update timer + exam_timer.children('.time').text(Math.round(remaining_time / 60)); + + if (remaining_time < 180 && !exam_timer.hasClass('alert')) { + exam_timer.addClass('alert'); + } + + if (remaining_time < 0) { + if (document.jsfrm) { + clearInterval(timer_id); + document.jsfrm.removeAttribute('data-secure'); + document.jsfrm.forced.value = 1; + document.jsfrm.submit(); + } else { + location.reload(); + } + } + }, 1000); + + exam_timer.draggable(); + } + + if ($('#list').length > 0) { + const assignment = $('#list').data('assignment'); + + $('#list').sortable({ + axis: 'y', + containment: 'parent', + handle: '.drag-handle', + helper(event, element) { + element.children().width((index, width) => width); + + return element; + }, + tolerance: 'pointer', + update() { + $.post( + STUDIP.URLHelper.getURL('dispatch.php/vips/sheets/move_exercise', { assignment_id: assignment }), + $('#list').sortable('serialize') + ); + } + }); + + $('#list > tr').on('keydown', function (event) { + if (event.key === 'ArrowUp' && event.target === this) { + $(this).prev().before(this); + } else if (event.key === 'ArrowDown' && event.target === this) { + $(this).next().after(this); + } else { + return; + } + + $(this).focus(); + $('#list').sortable('option').update(); + event.preventDefault(); + }); + } + + $(document).on('click', '.add_ip_range', function (event) { + const input = $(this).closest('fieldset').find('input[name=ip_range]'); + + input.val(input.val() + ' ' + $(this).attr('data-value')); + event.preventDefault(); + }); + + $(document).on('input', '.validate_ip_range', function () { + const ip_ranges = $(this).val().split(/[ ,]+/); + let message = ''; + + for (const ip_range of ip_ranges) { + if ( + ip_range.length > 0 + && ip_range.charAt(0) !== '#' + && !ip_range.match(/^[\d.]+(\/\d+|-[\d.]+)?$/) + && !ip_range.match(/^[\da-fA-F:]+(\/\d+|-[\da-fA-F:]+)?$/) + ) { + message = $gettext('Der IP-Zugriffsbereich ist ungültig.'); + } + } + + this.setCustomValidity(message); + }); + + $(document).on('click', '.vips_file_upload', function (event) { + $(this).closest('form').find('.file_upload').click(); + event.preventDefault(); + }); + + $(document).on('change', '.file_upload.attach', function () { + const button = $(this).closest('form').find('.vips_file_upload'); + + if (this.files && this.files.length > 1) { + button.text(button.data('label').replace('%d', this.files.length)); + button.next('.file_upload_hint').show(); + } else if (this.files) { + button.text(this.files[0].name); + button.next('.file_upload_hint').show(); + } + }); + + $(document).on('change', '.file_upload.inline', function (event) { + const textarea = $(this).closest('form').find('.download'); + const reader = new FileReader(); + + if (this.files && this.files.length > 0) { + reader.onload = function () { + textarea.val(reader.result); + }; + reader.onerror = function () { + STUDIP.Dialog.show(reader.error.message, { + title: $gettext('Fehler beim Hochladen'), + size: 'fit', + wikilink: false, + dialogClass: 'studip-confirmation' + }); + } + reader.readAsText(this.files[0]); + } + event.preventDefault(); + }); + + $(document).on('click', '.vips_file_download', function (event) { + const text = $(this).closest('form').find('.download').val(); + const link = $(this).closest('form').find('a[download]'); + const blob = new Blob([text], {type: 'text/plain; charset=UTF-8'}); + + link.attr('href', URL.createObjectURL(blob)); + link[0].click(); + event.preventDefault(); + }); + + $('.sortable_list').sortable({ + axis: 'y', + containment: 'parent', + items: '> .sortable_item', + tolerance: 'pointer' + }); + + $(document).on('keydown', '.sortable_item', function (event) { + if (event.key === 'ArrowUp' && event.target === this) { + $(this).prev('.sortable_item:visible').before(this); + } else if (event.key === 'ArrowDown' && event.target === this) { + $(this).next('.sortable_item:visible').after(this); + } else { + return; + } + + $(this).focus(); + event.preventDefault(); + }); + + $(document).on('click', '.textarea_toggle', function (event) { + const toggle = $(this).closest('.size_toggle'); + const items = toggle.find('.character_input'); + + const name = items[0].name; + items[0].name = items[1].name; + items[1].name = name; + + const value = items[0].value; + items[0].value = items[1].value; + items[1].value = value; + + if (STUDIP.wysiwyg.getEditor && STUDIP.wysiwyg.getEditor(items[1])) { + STUDIP.wysiwyg.getEditor(items[1]).setData(value); + } + + toggle.toggleClass('size_large').toggleClass('size_small'); + event.preventDefault(); + }); + + $(document).on('change', '.tb_layout', function () { + const toggle = $(this).closest('fieldset').find('.size_toggle'); + + toggle.find('.small_input').toggleClass('monospace', $(this).val() === 'code'); + + if ( + $(this).val() === '' && toggle.hasClass('size_large') + || $(this).val() === 'code' && toggle.hasClass('size_large') + || $(this).val() === 'markup' && toggle.hasClass('size_small') + ) { + toggle.find('.textarea_toggle').click(); + } + }); + + $(document).on('click', '.choice_list .add_dynamic_row', function () { + $(this).closest('fieldset').find('.choice_select').each(function () { + const template = $(this).children('.template').last(); + const clone = template.clone(true).removeClass('template'); + const index = template.data('index'); + + template.data('index', index + 1); + clone.insertBefore(template); + clone.find('input[data-value]').each(function () { + $(this).attr('value', index); + $(this).removeAttr('data-value'); + }); + }); + }); + + $(document).on('change', '.choice_list input', function () { + const index = $(this).closest('.dynamic_row').data('index'); + const items = $(this).closest('fieldset').find('.choice_select'); + + items.children().filter(function () { + return $(this).data('index') === index; + }).children('span').text($(this).val()); + }); + + $(document).on('click', '.choice_list .delete_dynamic_row', function () { + const index = $(this).closest('.dynamic_row').data('index'); + const items = $(this).closest('fieldset').find('.choice_select'); + + items.children().filter(function () { + return $(this).data('index') === index; + }).remove(); + }); + + $('.dynamic_list').each(function () { + $(this).children('.dynamic_row').each(function (i) { + $(this).data('index', i); + }); + }); + + $(document).on('click', '.add_dynamic_row', function (event) { + const container = $(this).closest('.dynamic_list'); + const template = container.children('.template').last(); + const clone = template.clone(true).removeClass('template'); + const index = template.data('index'); + + template.data('index', index + 1); + clone.insertBefore(template); + clone.find('input[data-name], select[data-name], textarea[data-name]').each(function () { + if ($(this).data('name').indexOf(':') === 0) { + $(this).data('name', $(this).data('name').substr(1) + '[' + index + ']'); + } else { + $(this).attr('name', $(this).data('name') + '[' + index + ']'); + $(this).removeAttr('data-name'); + } + }); + clone.find('input[data-value], select[data-value], textarea[data-value]').each(function () { + if ($(this).data('value').indexOf(':') === 0) { + $(this).data('value', $(this).data('value').substr(1)); + } else { + $(this).attr('value', index); + $(this).removeAttr('data-value'); + } + }); + clone.find('.wysiwyg-hidden:not(.template *)').toggleClass('wysiwyg wysiwyg-hidden'); + clone.find('.add_dynamic_row:visible').click(); + event.preventDefault(); + }); + + $(document).on('click', '.delete_dynamic_row', function (event) { + $(this).closest('.dynamic_row').remove(); + event.preventDefault(); + }); + + $(document).on('click', '.solution-toggle', function (event) { + if ($(this).closest('.solution').length > 0) { + $(this).closest('.solution').toggleClass('solution-closed'); + } else if ($('.arrow_all').first().css('display') !== 'none') { + $('.arrow_all').toggle(); + $('.solution').removeClass('solution-closed'); + } else { + $('.arrow_all').toggle(); + $('.solution').addClass('solution-closed'); + } + + $(document.body).trigger('sticky_kit:recalc'); + event.preventDefault(); + }); + + $(document).on('click', '.edit_solution', function (event) { + const tabs = $(this).closest('.vips_tabs'); + + tabs.removeClass('edit-hidden'); + tabs.find('.wysiwyg').attr('name', 'commented_solution'); + tabs.tabs('option', 'active', 0); + event.preventDefault(); + }); + + // add select2 to modal dialog including selects with optgroups + $(document).on('dialog-open', function (event, parameters) { + $('.vips_nested_select').select2({ + minimumResultsForSearch: 12, + dropdownParent: $(parameters.dialog).closest('.ui-dialog, body'), + matcher(params, data) { + const originalMatcher = $.fn.select2.defaults.defaults.matcher; + const result = originalMatcher(params, data); + + if (result && result.children && data.children && data.children.length) { + if (data.children.length !== result.children.length && + data.text.toLowerCase().includes(params.term.toLowerCase())) { + result.children = data.children; + } + } + + return result; + } + }); + }); + + $('.assignment_type').change(function () { + $('#assignment').attr('class', $(this).val()); + + if ($(this).val() === 'exam') { + $('#exam_length input').attr('disabled', null); + } else { + $('#exam_length input').attr('disabled', 'disabled'); + } + + if ($(this).val() === 'selftest') { + $('#end_date input').attr('required', null); + $('#end_date span').removeClass('required'); + } else { + $('#end_date input').attr('required', 'required'); + $('#end_date span').addClass('required'); + } + }); + + $('.rh_select_type').change(function () { + $(this).parent().next('table').toggleClass('rh_single'); + }); + + STUDIP.Vips.vips_post_render(document); +}); diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index 102a558fde1..22104712676 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -77,6 +77,7 @@ import "./bootstrap/admin-courses.js" import "./bootstrap/oer.js" import "./bootstrap/courseware.js" import "./bootstrap/external_pages.js" +import "./bootstrap/vips.js" import "./mvv_course_wizard.js" import "./mvv.js" diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index 2103ca2e5d8..4af6ed9659d 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -78,6 +78,7 @@ import * as Gettext from './lib/gettext'; import UserFilter from './lib/user_filter.js'; import wysiwyg from './lib/wysiwyg.js'; import ScrollToTop from './lib/scroll_to_top.js'; +import * as Vips from './lib/vips.js'; const configURLHelper = _.get(window, 'STUDIP.URLHelper', {}); const URLHelper = createURLHelper(configURLHelper); @@ -165,5 +166,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { domReady, dialogReady, ScrollToTop, + Vips, Vue, }); diff --git a/resources/assets/javascripts/lib/vips.js b/resources/assets/javascripts/lib/vips.js new file mode 100644 index 00000000000..aaff2599037 --- /dev/null +++ b/resources/assets/javascripts/lib/vips.js @@ -0,0 +1,122 @@ +function vips_post_render(element) { + $(element).find('.rh_list').sortable({ + tolerance: 'pointer', + connectWith: '.rh_list', + update(event, ui) { + if (ui.sender) { + ui.item.find('input').val($(this).data('group')); + } + }, + over() { + $(this).addClass('hover'); + }, + out() { + $(this).removeClass('hover'); + }, + receive(event, ui) { + const sortable = $(this).not('.multiple'); + const container = sortable.closest('.rh_table').find('.answer_container'); + + // default answer container can have more items + if (sortable.children().length > 1 && !sortable.is(container)) { + sortable.find('.rh_item').each(function () { + if (!ui.item.is(this)) { + $(this).find('input').val(-1); + $(this).detach().appendTo(container) + .css('opacity', 0).animate({opacity: 1}); + } + }); + } + }, + }); + + $(element).find('.rh_item').on('keydown', function (event) { + const sortable = $(this).parent(); + const container = sortable.closest('.rh_table').find('.answer_container'); + let target = $(); + + if (sortable.is('.mc_list')) { + if (event.key === 'ArrowUp') { + $(this).prev().before(this); + $(this).focus(); + event.preventDefault(); + } else if (event.key === 'ArrowDown') { + $(this).next().after(this); + $(this).focus(); + event.preventDefault(); + } + } else if (sortable.is(container)) { + if (event.key === 'ArrowLeft') { + target = sortable.parent().find('.rh_list').first(); + } + } else { + if (event.key === 'ArrowRight') { + target = container; + } else if (event.key === 'ArrowUp') { + target = sortable.parent().prev().find('.rh_list').first(); + } else if (event.key === 'ArrowDown') { + target = sortable.parent().next().find('.rh_list').first(); + } + } + + if (target.length) { + $(this).find('input').val(target.data('group')); + $(this).appendTo(target).focus(); + event.preventDefault(); + } + }); + + $(element).find('.cloze_select').filter(':contains("\\\\(")').each(function () { + STUDIP.loadChunk('mathjax').then(({ Hub }) => { + Hub.Queue(['Typeset', Hub, this]); + }); + }).select2({ + minimumResultsForSearch: -1, + templateResult(data) { + if ($(data.element).children('.MathJax').length) { + return $(data.element).children('.MathJax').clone(); + } else { + return data.text; + } + }, + templateSelection(data) { + if ($(data.element).children('.MathJax').length) { + return $(data.element).children('.MathJax').clone(); + } else { + return data.text; + } + } + }); + + $(element).find('.cloze_item').draggable({ + revert: 'invalid' + }); + + $(element).find('.cloze_drop').droppable({ + accept: '.cloze_item', + tolerance: 'pointer', + classes: { + 'ui-droppable-hover': 'hover' + }, + drop(event, ui) { + const container = $(this).closest('fieldset').find('.cloze_items'); + + if (!$(this).is(container)) { + $(this).find('.cloze_item').detach().appendTo(container) + .css('opacity', 0).animate({opacity: 1}) + } + + ui.draggable.closest('.cloze_drop').find('input').val(''); + ui.draggable.detach().css({top: 0, left: 0}).appendTo(this); + $(this).find('input').val(ui.draggable.attr('data-value')); + } + }); + + $(element).find('.vips_tabs').each(function () { + $(this).tabs({ + active: $(this).hasClass('edit-hidden') ? 1 : 0 + }); + }) +} + +export { vips_post_render }; diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index db400c2bc54..55171f5de54 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -25,7 +25,7 @@ &:hover, &:active, &.active { - background: var(--color--button-focus); + background-color: var(--color--button-focus); color: var(--color--font-inverted); } diff --git a/resources/assets/stylesheets/scss/courseware/variables.scss b/resources/assets/stylesheets/scss/courseware/variables.scss index 033c99fa005..b3da454fb18 100644 --- a/resources/assets/stylesheets/scss/courseware/variables.scss +++ b/resources/assets/stylesheets/scss/courseware/variables.scss @@ -74,6 +74,7 @@ $blockadder-items: ( key-point: exclaim-circle, link: link-extern, table-of-contents: table-of-contents, + test: check-circle, text: edit, timeline: date-cycle, typewriter: block-typewriter, diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss index 5c8f3a4d68c..1cb037fb1c7 100644 --- a/resources/assets/stylesheets/scss/forms.scss +++ b/resources/assets/stylesheets/scss/forms.scss @@ -136,6 +136,7 @@ form.default { } .formpart { + display: block; margin-bottom: $gap; output.calculator_result { @@ -198,7 +199,7 @@ form.default { margin-top: 2ex; } - fieldset { + fieldset:not(.undecorated) { box-sizing: border-box; border: solid 1px var(--color--fieldset-border); margin: 0 0 15px; @@ -229,6 +230,16 @@ form.default { } } + fieldset.undecorated { + border: none; + margin: 0; + padding: 0; + + > legend { + margin-bottom: 0.5ex; + } + } + .selectbox { padding: 5px; max-height: 200px; diff --git a/resources/assets/stylesheets/scss/jquery-ui/studip.scss b/resources/assets/stylesheets/scss/jquery-ui/studip.scss index db487f0d4c3..002eb1716cf 100644 --- a/resources/assets/stylesheets/scss/jquery-ui/studip.scss +++ b/resources/assets/stylesheets/scss/jquery-ui/studip.scss @@ -209,3 +209,45 @@ textarea.ui-resizable-handle.ui-resizable-s { background-color: var(--base-color); } } + +.ui-tabs { + &.ui-widget-content { + border: 1px solid var(--light-gray-color-40); + margin-top: 1.5ex; + padding: 0; + } + + .ui-tabs-nav { + background: none; + border: none; + border-bottom: 1px solid var(--light-gray-color-40); + } + + .ui-tabs-tab { + background: none; + border: none; + } + + .ui-tabs-tab:hover { + border-bottom: 3px solid var(--dark-gray-color-40); + } + + .ui-tabs-nav li.ui-tabs-active { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 3px solid var(--light-gray-color-80); + } + + .ui-tabs-tab .ui-tabs-anchor { + color: var(--base-color); + padding: 5px 15px; + } + + .ui-tabs-active .ui-tabs-anchor { + color: black; + } + + .ui-tabs-panel { + padding: 5px; + } +} diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss index 1fcd1ce8392..0fcc32a74ab 100644 --- a/resources/assets/stylesheets/scss/sidebar.scss +++ b/resources/assets/stylesheets/scss/sidebar.scss @@ -179,7 +179,7 @@ div#sidebar-navigation { .widget-links { margin: 5px; > li img { - vertical-align: text-top; + vertical-align: top; } a { display: block; diff --git a/resources/assets/stylesheets/scss/tables.scss b/resources/assets/stylesheets/scss/tables.scss index 32a26c29038..788392df104 100644 --- a/resources/assets/stylesheets/scss/tables.scss +++ b/resources/assets/stylesheets/scss/tables.scss @@ -85,6 +85,10 @@ td.blanksmall { background-color: var(--color--global-background); } +table.fixed { + table-layout: fixed; +} + td.tree-indent { img, svg { vertical-align: bottom; @@ -476,6 +480,15 @@ table.default { white-space: nowrap; } + img { + vertical-align: text-bottom; + } + + input[type="text"], textarea { + padding-bottom: 2px; + padding-top: 2px; + } + padding: 10px 5px; text-align: left; @@ -715,7 +728,7 @@ table.default { } } - tfoot { + tfoot, th { // Fix button and select alignment select { vertical-align: middle; diff --git a/resources/assets/stylesheets/scss/vips.scss b/resources/assets/stylesheets/scss/vips.scss new file mode 100644 index 00000000000..f11afda08e4 --- /dev/null +++ b/resources/assets/stylesheets/scss/vips.scss @@ -0,0 +1,592 @@ +form.default { + .inline_select { + height: 32px; + width: auto; + } + + .label-text { + display: block; + margin: 1.5ex 0 0.5ex 0; + text-indent: 0.25ex; + } + + .vips_nested_select { + transition: inherit; + } + + input.cloze_input { + margin: 2px; + padding-bottom: 2px; + padding-top: 2px; + } + + select.cloze_select { + height: auto; + margin: 2px; + width: auto; + } + + input.percent_input { + text-align: right; + width: 4em; + } + + label:not(.undecorated) .select2-container { + display: block; + } +} + +button.vips_file_upload { + @include background-icon(upload); + background-position: 0.5em center; + background-repeat: no-repeat; + background-size: var(--icon-size-inline); + padding-left: 30px; + + &:hover { + @include background-icon(upload, info_alt); + } +} + +progress.assignment { + appearance: none; + background: var(--light-gray-color-20); + border: none; + height: 8px; + width: 120px; + + &::-moz-progress-bar { + background: var(--base-color); + } + &::-webkit-progress-bar { + background: var(--light-gray-color-20); + } + &::-webkit-progress-value { + background: var(--base-color); + } +} + +.vips-teaser { + background-color: var(--content-color-20); + background-image: url(../images/icons/blue/vips.svg); + background-position: 64px 50%; + background-repeat: no-repeat; + background-size: 120px; + max-width: 562px; + padding: 24px 24px 24px 244px; + + header { + font-size: 1.5em; + margin-bottom: 0.5em; + } +} + +.width-1200 { + max-width: 1200px; +} + +.breadcrumb { + margin-bottom: 1ex; + + img { + vertical-align: text-bottom; + } +} + +.smaller { + font-size: smaller; +} + +.monospace, +.vips_tabs .monospace { + font-family: monospace; +} + +.vips_tabs .vips_output { + background: none; +} + +.vips_output { + background-color: var(--dark-gray-color-5); + max-height: 30em; + min-height: 1em; + overflow-y: auto; + padding: 3px; + + pre { + margin: 2px; + white-space: pre-wrap; + } +} + +.sidebar_exercise_label { + display: inline-block; + width: 120px; +} + +.sidebar_exercise_points { + display: inline-block; + text-align: right; + width: 80px; +} + +.sidebar_exercise_state { + display: inline-block; + text-align: right; + width: 32px; +} + +.sortable .gradebook_header { + font-size: smaller; + max-width: 8em; + overflow-x: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sortable_item { + padding-left: 2ex; +} + +.exercise_types { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + max-width: 50em; +} + +.exercise_type { + background-color: transparent; + background-position: 0.5em center; + background-repeat: no-repeat; + border: 1px solid var(--color--fieldset-border); + color: var(--base-color); + cursor: pointer; + margin-top: 1.5ex; + min-height: 50px; + padding: 4px 4px 4px 56px; + text-align: left; + width: 342px; + + &:hover { + border: 1px solid var(--brand-color-dark); + color: var(--active-color); + } +} + +.exercise .points { + float: right; + font-size: 14px; + font-weight: normal; +} + +#exercises .points { + text-align: right; + width: 4em; +} + +.exercise_hint { + background: var(--activity-color-20); + border: 1px dotted var(--dark-gray-color-75); + display: inline-block; + margin-top: 1.5ex; + padding: 1ex; + + > h4 { + margin-top: 0px; + } +} + +#exam_timer { + background-color: var(--white); + border: 3px solid var(--red); + cursor: move; + font-size: 1.1em; + left: calc(100% - 134px); + margin: 3px; + padding: 3px; + position: fixed; + top: 0px; + white-space: nowrap; + z-index: 10001; + + &.alert { + color: var(--red); + } +} + +.inline-block { + display: inline-block; +} + +.inline-content .formatted-content { + display: inline-block; + vertical-align: top; + + > p:last-child { + margin-bottom: 0; + } +} + +.print_settings { + background-color: var(--activity-color-20); + border-bottom: 1px dotted var(--dark-gray-color-75); + display: none; + margin: -2em -2em 1em -2em; + padding: 1ex 2em; + + label { + margin-left: 1em; + } +} + +.choice_select { + display: inline-block; + padding: 0 1ex; +} + +.rh_single .rh_add_answer:not(:nth-child(2)) { + display: none; +} + +.rh_table { + border-spacing: 3em 1em; + margin-left: -2em; +} + +.rh_list { + background-color: var(--dark-gray-color-5); + border: 1px dashed var(--dark-gray-color-45); + min-width: 160px; + + &.hover { + border-color: var(--black); + } +} + +.rh_label { + padding-bottom: 2ex; + padding-top: 2ex; +} + +.rh_item { + background-color: var(--white); + border: 1px solid var(--base-color-20); + margin: 4px; + padding: 1ex 1ex 1ex 2ex; + + &:hover { + border-color: var(--dark-gray-color-45); + } +} + +.cloze_drop { + background-color: var(--dark-gray-color-5); + border: 1px dashed var(--dark-gray-color-45); + display: inline-block; + min-height: 32px; + min-width: 80px; + vertical-align: middle; + white-space: normal; + + &.hover { + border-color: var(--black); + } + + &.cloze_items { + display: block; + margin-top: 1em; + padding: 2px; + } +} + +.cloze_item { + background-color: var(--white); + background-size: auto 22px; + border: 1px solid var(--base-color-20); + display: inline-block; + margin: 2px; + min-width: 48px; + padding: 3px 1ex 3px 2ex; + + &:hover { + border-color: var(--dark-gray-color-45); + } +} + +.mc_row { + margin: 1ex 0; + + img, + input[type="image"] { + vertical-align: text-bottom; + } +} + +.mc_list { + display: inline-block; + margin-top: 1.5ex; + min-width: 32em; +} + +.mc_item { + padding: 4px; +} + +.mc_flex, +form.default label.mc_flex { + align-items: start; + column-gap: 6px; + display: flex; +} + +.mc_flex > img:first-child { + padding: 2px; +} + +.mc_flex > .formatted-content { + flex-grow: 1; +} + +.correct_item { + background: var(--green-20); + border: 1px solid var(--green-40); + padding: 3px; +} + +.fuzzy_item { + background: var(--yellow-20); + border: 1px solid var(--yellow-40); + padding: 3px; +} + +.wrong_item { + background: var(--red-20); + border: 1px solid var(--red-40); + padding: 3px; +} + +.neutral_item { + border: 1px dotted var(--dark-gray-color-30); + margin-right: 2.5em; + padding: 3px; +} + +.correction_marker { + float: right; + margin-left: 1em; + + &.sequence { + font-size: 20px; + margin-top: -15px; + } +} + +.correction_inline { + vertical-align: text-bottom; +} + +.group_separator { + border-top: 1px dotted var(--dark-gray-color-75); + margin-top: 1.5ex; + padding-top: 1.5ex; +} + +.dynamic_list { + counter-reset: vips_item; +} + +.dynamic_counter::before { + counter-increment: vips_item; + content: counter(vips_item) "."; +} + +.dynamic_row:first-child .hide_first { + display: none; +} + +.template { + display: none; +} + +.solution-close { + display: inline; +} + +.solution-open { + display: none; +} + +.solution-closed { + + tr { + display: none; + } + + .solution-close { + display: none; + } + + .solution-open { + display: inline; + } +} + +.solution { + vertical-align: top; +} + +.solution-col-5 { + vertical-align: top; + width: 20%; +} + +.solution-none { + color: var(--light-gray-color); +} + +.solution-uncorrected { + color: var(--red); +} + +.solution-autocorrected { + color: var(--petrol); +} + +.solution-corrected { + color: var(--green); + font-weight: bold; +} + +.vips_tabs.edit-hidden .edit-tab { + display: none; +} + +#assignment.exam .exam-hidden, +#assignment.practice .practice-hidden, +#assignment.selftest .selftest-hidden { + display: none; +} + +#list > tr:first-child .icon-shape-arr_2up, +#list > tr:last-child .icon-shape-arr_2down { + visibility: hidden; +} + +.options-toggle { + display: none; +} + +.options-toggle + .caption + .toggle-box, +.options-toggle + .caption > .toggle-open, +.options-toggle:checked + .caption > .toggle-closed { + display: none; +} + +.options-toggle:checked + .caption + .toggle-box, +.options-toggle:checked + .caption > .toggle-open { + display: initial; +} + +.options-toggle + .caption { + background-color: var(--color--fieldset-header); + color: var(--brand-color-dark); + display: block; + font-weight: bold; + margin-bottom: 1.5ex; + padding: 4px; + + > img { + vertical-align: text-bottom; + } +} + +table.default input.small_input { + margin-bottom: 2px; +} + +.size_toggle { + &.size_small .large_input, + &.size_large .small_input { + display: none; + } + + .flexible_input { + display: inline-block; + max-width: 48em; + vertical-align: top; + width: 91%; + } +} + +.vs__selected img, +.vs__dropdown-option img { + vertical-align: text-bottom; +} + +.vs__dropdown-option small { + margin-left: 20px; +} + +.cw-exercise-header { + display: flex; + height: 20px; + + span { + flex-grow: 1; + } +} + +.cw-exercise-fieldset header { + background-color: var(--color--fieldset-header); + color: var(--brand-color-dark); + font-weight: 600; + margin: 14px 0 8px -10px; + padding: 6px 10px; +} + +#vips-sheets-print_assignments { + display: block; + min-height: auto; + width: auto; + + @media screen { + margin: 2em; + + .print_settings { + display: block; + } + } + + footer { + display: none; + } + + table.content th { + padding: 3px; + text-align: left; + } + + ol, ul { + padding-left: 30px; + } + + .assignment { + page-break-after: always; + } + + .exercise { + margin-bottom: 1em; + page-break-inside: avoid; + } + + .label-text { + font-weight: bold; + margin-top: 1.5ex; + } + + .vips_output { + background: none; + max-height: none; + } +} diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index 624e99a4326..9183081efe8 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -105,6 +105,7 @@ @import "scss/tree"; @import "scss/typography"; @import "scss/user-administration"; +@import "scss/vips"; @import "scss/wiki"; @import "scss/multi_person_search"; @import "scss/admission"; diff --git a/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue new file mode 100644 index 00000000000..9c90291278e --- /dev/null +++ b/resources/vue/components/courseware/blocks/CoursewareTestBlock.vue @@ -0,0 +1,266 @@ +<template> + <div class="cw-block cw-block-test"> + <courseware-default-block + :block="block" + :canEdit="canEdit" + :isTeacher="isTeacher" + :defaultGrade="false" + @storeEdit="storeBlock" + @closeEdit="initCurrentData" + > + <template #content> + <div class="cw-block-title cw-exercise-header" v-if="assignment"> + <template v-if="exercises.length > 1"> + <button class="as-link" @click="prevExercise" :title="$gettext('Zurück')"> + <studip-icon shape="arr_1left" size="20"/> + </button> + <span> + {{ $gettextInterpolate( + $gettext('%{title}, Aufgabe %{num} von %{length}'), + { title: assignment.title, num: exercise_pos + 1, length: exercises.length } + ) }} + </span> + <button class="as-link" @click="nextExercise" :title="$gettext('Weiter')"> + <studip-icon shape="arr_1right" size="20"/> + </button> + </template> + <span v-else> + {{assignment.title}} + </span> + </div> + <template v-for="(exercise, index) in exercises" :key="exercise.id"> + <div v-show="index === exercise_pos"> + <form class="default" autocomplete="off" :exercise="exercise.id"> + <fieldset class="cw-exercise-fieldset" v-html="exercise.template" ref="content"> + </fieldset> + <footer v-show="exercise.item_count && (assignment.reset_allowed || !exercise.show_solution)"> + <button + v-show="!exercise.show_solution" + class="button accept" + @click.prevent="submitSolution" + > + {{ $gettext('Speichern') }} + </button> + <button + v-show="exercise.show_solution && assignment.reset_allowed" + class="button reset" + @click.prevent="resetDialogHandler" + > + {{ $gettext('Lösung dieser Aufgabe löschen') }} + </button> + <a + v-if="canEdit && $store.getters.viewMode === 'edit'" + class="button" + :href="vips_url('sheets/edit_assignment', { assignment_id: assignment.id })" + > + {{ $gettext('Aufgabenblatt bearbeiten') }} + </a> + </footer> + </form> + </div> + </template> + <courseware-companion-box + :msgCompanion="errorMessage" mood="sad" + v-if="errorMessage !== null" + /> + </template> + <template v-if="canEdit" #edit> + <form class="default" @submit.prevent=""> + <label> + {{ $gettext('Aufgabenblatt') }} + <studip-select + :options="assignments" + label="title" + :reduce="assignment => assignment.id" + :clearable="false" + v-model="assignment_id" + class="cw-vs-select" + > + <template #open-indicator="{ attributes }"> + <span v-bind="attributes"><studip-icon shape="arr_1down" :size="10"/></span> + </template> + <template #no-options="{}"> + {{ $gettext('Es steht keine Auswahl zur Verfügung') }} + </template> + <template #selected-option="{title, icon, start, end}"> + <studip-icon :shape="icon" role="info"/> + {{title}} ({{start}} - {{end}}) + </template> + <template #option="{title, icon, start, end, block}"> + <studip-icon :shape="icon" role="info"/> + {{ block ? block + ' / ' + title : title }}<br> + <small>{{start}} - {{end}}</small> + </template> + </studip-select> + </label> + </form> + </template> + </courseware-default-block> + <studip-dialog + v-if="exerciseResetId" + :title="$gettext('Bitte bestätigen Sie die Aktion')" + :question="$gettext('Wollen Sie die Lösung dieser Aufgabe wirklich löschen?')" + height="180" + @confirm="resetSolution" + @close="exerciseResetId = null" + ></studip-dialog> + </div> +</template> + +<script> +import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue'; +import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue' + +export default { + name: 'courseware-test-block', + components: { CoursewareDefaultBlock, CoursewareCompanionBox }, + props: { + block: Object, + canEdit: Boolean, + isTeacher: Boolean + }, + data() { + return { + assignments: [], + assignment_id: '', + assignment: null, + errorMessage: null, + exercises: [], + exercise_pos: 0, + exerciseResetId: null + } + }, + methods: { + storeBlock() { + const attributes = { payload: { assignment: this.assignment_id } }; + const container = this.$store.getters['courseware-containers/related']({ + parent: this.block, + relationship: 'container', + }); + + return this.$store.dispatch('updateBlockInContainer', { + attributes, + blockId: this.block.id, + containerId: container.id, + }); + }, + initCurrentData() { + this.assignment_id = this.block.attributes.payload.assignment; + this.loadSelectedAssignment(); + }, + prevExercise() { + if (this.exercise_pos === 0) { + this.exercise_pos = this.exercises.length - 1; + } else { + this.exercise_pos = this.exercise_pos - 1; + } + }, + nextExercise() { + if (this.exercise_pos === this.exercises.length - 1) { + this.exercise_pos = 0; + } else { + this.exercise_pos = this.exercise_pos + 1; + } + }, + loadAssignments() { + // axios is this.$store.getters.httpClient + $.get(this.vips_url('api/assignments/' + this.$store.getters.context.id)) + .done(response => { + this.assignments = response; + }); + }, + loadSelectedAssignment() { + if (this.assignment_id === '') { + this.errorMessage = this.$gettext('Es wurde noch kein Aufgabenblatt ausgewählt.'); + return; + } + + this.assignment = null; + this.errorMessage = null; + this.exercises = []; + $.get(this.vips_url('api/assignment/' + this.assignment_id)) + .done(response => { + this.assignment = response; + this.exercises = response.exercises; + this.$nextTick(() => { + this.loadMathjax(); + STUDIP.Vips.vips_post_render(this.$refs.content); + }); + }) + .fail(xhr => { + this.errorMessage = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + }); + }, + reloadExercise(exercise_id) { + $.get(this.vips_url('api/exercise/' + this.assignment.id + '/' + exercise_id)) + .done(response => { + this.exercises[this.exercise_pos] = response; + this.$nextTick(() => { + this.loadMathjax(); + STUDIP.Vips.vips_post_render(this.$refs.content); + }); + }); + }, + loadMathjax() { + STUDIP.loadChunk('mathjax').then(({ Hub }) => { + Hub.Queue(['Typeset', Hub, this.$refs.content]); + }); + }, + vips_url(url, param_object) { + return STUDIP.URLHelper.getURL('dispatch.php/vips/' + url, param_object); + }, + submitSolution(event) { + let exercise_id = event.currentTarget.form.getAttribute('exercise'); + let data = new FormData(event.currentTarget.form); + data.set('block_id', this.block.id); + + $.ajax({ + type: 'POST', + url: this.vips_url('api/solution/' + this.assignment.id + '/' + exercise_id), + data: data, + enctype: 'multipart/form-data', + processData: false, + contentType: false + }) + .fail(xhr => { + let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + + if (xhr.status === 422) { + info = this.$gettext('Ihre Lösung ist leer und wurde nicht gespeichert.'); + } + this.$store.dispatch('companionError', { info: info }); + }) + .done(() => { + this.$store.dispatch('companionSuccess', { + info: this.$gettext('Ihre Lösung zur Aufgabe wurde gespeichert.'), + }); + this.reloadExercise(exercise_id); + }); + }, + resetDialogHandler(event) { + this.exerciseResetId = event.currentTarget.form.getAttribute('exercise'); + }, + resetSolution() { + $.ajax({ + type: 'DELETE', + url: this.vips_url('api/solution/' + this.assignment.id + '/' + this.exerciseResetId, { block_id: this.block.id }) + }) + .fail(xhr => { + let info = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; + this.$store.dispatch('companionError', { info: info }); + this.exerciseResetId = null; + }) + .done(() => { + this.reloadExercise(this.exerciseResetId); + this.exerciseResetId = null; + }); + } + }, + created() { + this.initCurrentData(); + if (this.canEdit) { + this.loadAssignments(); + } + } +}; +</script> diff --git a/resources/vue/components/courseware/containers/container-components.js b/resources/vue/components/courseware/containers/container-components.js index 0a89bef5e89..c920dae3464 100644 --- a/resources/vue/components/courseware/containers/container-components.js +++ b/resources/vue/components/courseware/containers/container-components.js @@ -27,6 +27,7 @@ import CoursewareKeyPointBlock from '../blocks/CoursewareKeyPointBlock.vue'; import CoursewareLinkBlock from '../blocks/CoursewareLinkBlock.vue'; import CoursewareLtiBlock from '../blocks/CoursewareLtiBlock.vue'; import CoursewareTableOfContentsBlock from '../blocks/CoursewareTableOfContentsBlock.vue'; +import CoursewareTestBlock from '../blocks/CoursewareTestBlock.vue'; import CoursewareTextBlock from '../blocks/CoursewareTextBlock.vue'; import CoursewareTimelineBlock from '../blocks/CoursewareTimelineBlock.vue'; import CoursewareTypewriterBlock from '../blocks/CoursewareTypewriterBlock.vue'; @@ -66,6 +67,7 @@ const ContainerComponents = { CoursewareLinkBlock, CoursewareLtiBlock, CoursewareTableOfContentsBlock, + CoursewareTestBlock, CoursewareTextBlock, CoursewareTimelineBlock, CoursewareTypewriterBlock, -- GitLab