aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/classes/SimpleORMap.php4
-rw-r--r--lib/classes/sidebar/VipsSearchWidget.php42
-rw-r--r--lib/filesystem/ExerciseFolder.php111
-rw-r--r--lib/filesystem/FeedbackFolder.php96
-rw-r--r--lib/filesystem/ResponseFolder.php107
-rw-r--r--lib/models/Courseware/BlockTypes/TestBlock.php125
-rw-r--r--lib/models/FileRef.php17
-rw-r--r--lib/models/Folder.php27
-rw-r--r--lib/models/vips/ClozeTask.php505
-rw-r--r--lib/models/vips/DummyExercise.php83
-rw-r--r--lib/models/vips/Exercise.php855
-rw-r--r--lib/models/vips/MatchingTask.php341
-rw-r--r--lib/models/vips/MatrixChoiceTask.php268
-rw-r--r--lib/models/vips/MultipleChoiceTask.php196
-rw-r--r--lib/models/vips/SequenceTask.php255
-rw-r--r--lib/models/vips/SingleChoiceTask.php279
-rw-r--r--lib/models/vips/TextLineTask.php271
-rw-r--r--lib/models/vips/TextTask.php279
-rw-r--r--lib/models/vips/VipsAssignment.php1308
-rw-r--r--lib/models/vips/VipsAssignmentAttempt.php99
-rw-r--r--lib/models/vips/VipsBlock.php92
-rw-r--r--lib/models/vips/VipsExerciseRef.php137
-rw-r--r--lib/models/vips/VipsGroup.php79
-rw-r--r--lib/models/vips/VipsGroupMember.php50
-rw-r--r--lib/models/vips/VipsSolution.php160
-rw-r--r--lib/models/vips/VipsTest.php121
-rw-r--r--lib/modules/VipsModule.php471
27 files changed, 6370 insertions, 8 deletions
diff --git a/lib/classes/SimpleORMap.php b/lib/classes/SimpleORMap.php
index d8cdb8e..bea9595 100644
--- a/lib/classes/SimpleORMap.php
+++ b/lib/classes/SimpleORMap.php
@@ -536,7 +536,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate
/**
* build object with given data
*
- * @param array $data assoc array of record
+ * @param iterable $data assoc array of record
* @param ?bool $is_new set object to new state
* @return static
*/
@@ -551,7 +551,7 @@ class SimpleORMap implements ArrayAccess, Countable, IteratorAggregate
/**
* build object with given data and mark it as existing
*
- * @param array $data assoc array of record
+ * @param iterable $data assoc array of record
* @return static
*/
public static function buildExisting($data)
diff --git a/lib/classes/sidebar/VipsSearchWidget.php b/lib/classes/sidebar/VipsSearchWidget.php
new file mode 100644
index 0000000..dbfdea6
--- /dev/null
+++ b/lib/classes/sidebar/VipsSearchWidget.php
@@ -0,0 +1,42 @@
+<?php
+/*
+ * VipsSearchWidget.php - Sidebar SearchWidget for Vips
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class VipsSearchWidget extends SearchWidget
+{
+ /**
+ * Renders the widget.
+ *
+ * @param Array $variables Unused variables parameter
+ * @return String containing the html output of the widget
+ */
+ public function render($variables = [])
+ {
+ $needles = [];
+
+ foreach ($this->needles as $needle) {
+ if ($needle['quick_search']) {
+ $quick_search = QuickSearch::get($needle['name'], $needle['quick_search']);
+ $quick_search->noSelectbox();
+ if (isset($needle['value'])) {
+ $quick_search->defaultValue(null, $needle['value']);
+ }
+ if (isset($needle['js_func'])) {
+ $quick_search->fireJSFunctionOnSelect($needle['js_func']);
+ }
+
+ $needle['quick_search'] = $quick_search;
+ $needles[] = $needle;
+ }
+ }
+
+ return parent::render($variables + compact('needles'));
+ }
+}
diff --git a/lib/filesystem/ExerciseFolder.php b/lib/filesystem/ExerciseFolder.php
new file mode 100644
index 0000000..e400bbc
--- /dev/null
+++ b/lib/filesystem/ExerciseFolder.php
@@ -0,0 +1,111 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips exercise folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class ExerciseFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $exercise = Exercise::find($this->range_id);
+
+ foreach ($exercise->tests as $test) {
+ foreach ($test->assignments as $assignment) {
+ if ($assignment->checkEditPermission($user_id) ||
+ $assignment->checkViewPermission($user_id) &&
+ ($assignment->checkAccess($user_id) || $assignment->releaseStatus($user_id) >= 3)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $exercise = Exercise::find($this->range_id);
+
+ foreach ($exercise->tests as $test) {
+ foreach ($test->assignments as $assignment) {
+ if ($assignment->checkEditPermission($user_id)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/filesystem/FeedbackFolder.php b/lib/filesystem/FeedbackFolder.php
new file mode 100644
index 0000000..17511b8
--- /dev/null
+++ b/lib/filesystem/FeedbackFolder.php
@@ -0,0 +1,96 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips feedback folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class FeedbackFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission() ||
+ $assignment->checkViewPermission() && $assignment->releaseStatus($user_id) >= 2;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission();
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/filesystem/ResponseFolder.php b/lib/filesystem/ResponseFolder.php
new file mode 100644
index 0000000..598bf28
--- /dev/null
+++ b/lib/filesystem/ResponseFolder.php
@@ -0,0 +1,107 @@
+<?php
+/*
+ * ExerciseFolder.php - Vips response folder class for Stud.IP
+ * Copyright (c) 2024 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class ResponseFolder extends StandardFolder
+{
+ /**
+ * @param string|Object $range_id_or_object
+ * @param string $user_id
+ * @return bool
+ */
+ public static function availableInRange($range_id_or_object, $user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isReadable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ if (!$assignment->checkViewPermission()) {
+ return false;
+ }
+
+ if ($assignment->checkEditPermission() || $solution->user_id === $user_id) {
+ return true;
+ }
+
+ $group = $assignment->getUserGroup($solution->user_id);
+ $group2 = $assignment->getUserGroup($user_id);
+
+ return isset($group, $group2)
+ && $group->id === $group2->id;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isWritable($user_id)
+ {
+ $solution = VipsSolution::find($this->range_id);
+ $assignment = $solution->assignment;
+
+ return $assignment->checkEditPermission();
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isEditable($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param string $user_id
+ * @return bool
+ */
+ public function isSubfolderAllowed($user_id)
+ {
+ return false;
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileDownloadable($fileref_or_id, $user_id)
+ {
+ return $this->isReadable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileEditable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+
+ /**
+ * @param FileRef|string $fileref_or_id
+ * @param string $user_id
+ * @return bool
+ */
+ public function isFileWritable($fileref_or_id, $user_id)
+ {
+ return $this->isWritable($user_id);
+ }
+}
diff --git a/lib/models/Courseware/BlockTypes/TestBlock.php b/lib/models/Courseware/BlockTypes/TestBlock.php
new file mode 100644
index 0000000..181fff6
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/TestBlock.php
@@ -0,0 +1,125 @@
+<?php
+/*
+ * TestBlock.php - Courseware Vips test block
+ * Copyright (c) 2022 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+namespace Courseware\BlockTypes;
+
+use VipsAssignment;
+use VipsModule;
+
+class TestBlock extends BlockType
+{
+ /**
+ * Get a short string describing this type of block.
+ */
+ public static function getType(): string
+ {
+ return 'test';
+ }
+
+ /**
+ * Get the title of this type of block.
+ */
+ public static function getTitle(): string
+ {
+ return _('Aufgabenblatt');
+ }
+
+ /**
+ * Get the description of this type of block.
+ */
+ public static function getDescription(): string
+ {
+ return _('Stellt ein vorhandenes Aufgabenblatt bereit.');
+ }
+
+ /**
+ * Get the initial payload of every instance of this block.
+ */
+ public function initialPayload(): array
+ {
+ return ['assignment' => ''];
+ }
+
+ /**
+ * Get the JSON schema for the payload of this block type.
+ */
+ public static function getJsonSchema(): string
+ {
+ $schema = [
+ 'type' => 'object',
+ 'properties' => [
+ 'assignment' => [
+ 'type' => 'string'
+ ]
+ ]
+ ];
+
+ return json_encode($schema);
+ }
+
+ /**
+ * Get the list of categories for this block type.
+ */
+ public static function getCategories(): array
+ {
+ return ['interaction'];
+ }
+
+ /**
+ * Get the list of content types for this block type.
+ */
+ public static function getContentTypes(): array
+ {
+ return ['rich'];
+ }
+
+ /**
+ * Get the list of file types for this block type.
+ */
+ public static function getFileTypes(): array
+ {
+ return [];
+ }
+
+ /**
+ * Copy the payload of this block into the given range id.
+ */
+ public function copyPayload(string $rangeId = ''): array
+ {
+ static $assignments = [];
+
+ $context = $rangeId === $GLOBALS['user']->id ? 'user' : 'course';
+ $payload = $this->getPayload();
+
+ if ($payload['assignment']) {
+ $assignment = VipsAssignment::find($payload['assignment']);
+ }
+
+ if (!$assignment || !$assignment->checkEditPermission()) {
+ return $this->initialPayload();
+ }
+
+ if ($context === 'course' && !VipsModule::hasStatus('tutor', $rangeId)) {
+ return $this->initialPayload();
+ }
+
+ if ($assignment->range_id !== $rangeId) {
+ if (!isset($assignments[$assignment->id])) {
+ $copy = $assignment->copyIntoCourse($rangeId, $context);
+ $assignments[$assignment->id] = $copy->id;
+ }
+
+ $payload['assignment'] = $assignments[$assignment->id];
+ }
+
+ return $payload;
+ }
+}
diff --git a/lib/models/FileRef.php b/lib/models/FileRef.php
index 2a7f485..4196367 100644
--- a/lib/models/FileRef.php
+++ b/lib/models/FileRef.php
@@ -299,7 +299,6 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
return mb_strpos($this->mime_type, 'audio/') === 0;
}
-
/**
* Determines if the FileRef references a video file.
*
@@ -311,6 +310,22 @@ class FileRef extends SimpleORMap implements PrivacyObject, FeedbackRange
}
/**
+ * Get the preferred content disposition of this file.
+ */
+ public function getContentDisposition(): string
+ {
+ if ($this->isImage() || $this->isAudio() || $this->isVideo()) {
+ return 'inline';
+ }
+
+ if (in_array($this->mime_type, ['application/pdf', 'text/plain'])) {
+ return 'inline';
+ }
+
+ return 'attachment';
+ }
+
+ /**
* Export available data of a given user into a storage object
* (an instance of the StoredUserData class) for that user.
*
diff --git a/lib/models/Folder.php b/lib/models/Folder.php
index 1c7a13e..111decf 100644
--- a/lib/models/Folder.php
+++ b/lib/models/Folder.php
@@ -274,6 +274,17 @@ class Folder extends SimpleORMap implements FeedbackRange
}
/**
+ * Retrieves folders by range id and folder type.
+ *
+ * @param string $range_id range id of the folder
+ * @param string $folder_type folder type name
+ */
+ public static function findByRangeIdAndFolderType(?string $range_id, string $folder_type)
+ {
+ return self::findBySQL('range_id = ? AND folder_type = ?', [$range_id, $folder_type]);
+ }
+
+ /**
* This callback is called before storing a Folder object.
* In case the name field is changed this callback assures that the
* name of the Folder object is unique inside the parent folder.
@@ -381,11 +392,15 @@ class Folder extends SimpleORMap implements FeedbackRange
*
* @param string range_id The ID of the Stud.IP object whose top folder shall be found.
* @param string folder_type The expected folder type related to the Stud.IP object (defaults to RootFolder, use MessageFolder for the top folder of a message)
+ * @param string range_type The expected range type of the Stud.IP object (defaults to auto detect)
*
* @returns Folder|null Folder object on success or null, if no folder can be created.
**/
- public static function findTopFolder($range_id, $folder_type = 'RootFolder')
- {
+ public static function findTopFolder(
+ string $range_id,
+ string $folder_type = 'RootFolder',
+ ?string $range_type = null
+ ) {
$top_folder = self::findOneBySQL(
"range_id = ? AND folder_type = ? AND parent_id=''",
[$range_id, $folder_type]
@@ -395,10 +410,12 @@ class Folder extends SimpleORMap implements FeedbackRange
if (!$top_folder) {
//top_folder doest not exist: create it
//determine range type:
- $range_type = self::findRangeTypeById($range_id);
if (!$range_type) {
- //no range type means we can't create a folder!
- return null;
+ $range_type = self::findRangeTypeById($range_id);
+ if (!$range_type) {
+ //no range type means we can't create a folder!
+ return null;
+ }
}
$top_folder = self::createTopFolder($range_id, $range_type, $folder_type);
diff --git a/lib/models/vips/ClozeTask.php b/lib/models/vips/ClozeTask.php
new file mode 100644
index 0000000..b5c8069
--- /dev/null
+++ b/lib/models/vips/ClozeTask.php
@@ -0,0 +1,505 @@
+<?php
+/*
+ * ClozeTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class ClozeTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('log', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Lückentext mit Eingabe oder Auswahl');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['text'] = '';
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->parseClozeText(trim($request['cloze_text']));
+ $this->task['compare'] = $request['compare'];
+
+ if ($this->task['compare'] === 'numeric') {
+ $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100;
+ }
+
+ if (isset($request['input_width'])) {
+ $this->task['input_width'] = (int) $request['input_width'];
+ }
+
+ if ($request['layout']) {
+ $this->task['layout'] = $request['layout'];
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ["L'text", 'Eingabehilfe', 'Abgleich'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ $this->parseClozeText($this->description);
+ $this->description = '';
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Kleinbuchstaben') {
+ $this->task['compare'] = 'ignorecase';
+ }
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+ $this->task['text'] = '';
+ $select = null;
+
+ foreach ($exercise->items->item->description->children() as $name => $elem) {
+ if ($name == 'text') {
+ $this->task['text'] .= (string) $elem;
+ } else if ($name == 'answers') {
+ $answers = [];
+
+ foreach ($elem->answer as $answer) {
+ $answers[] = [
+ 'text' => (string) $answer,
+ 'score' => (string) $answer['score']
+ ];
+ }
+
+ if ($elem['select'] == 'true') {
+ $select[] = $this->itemCount();
+ }
+
+ $this->task['answers'][] = $answers;
+ $this->task['text'] .= '[[]]';
+ }
+ }
+
+ $this->task['text'] = Studip\Markup::purifyHtml($this->task['text']);
+
+ switch ($exercise->items->item['type']) {
+ case 'cloze-input':
+ $this->task['select'] = $select;
+ break;
+ case 'cloze-select':
+ $this->task['layout'] = 'select';
+ break;
+ case 'cloze-drag':
+ $this->task['layout'] = 'drag';
+ }
+
+ if ($exercise->items->item->{'submission-hints'}) {
+ if ($exercise->items->item->{'submission-hints'}->input['width']) {
+ $this->task['input_width'] = (int) $exercise->items->item->{'submission-hints'}->input['width'];
+ }
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'ignorecase':
+ $this->task['compare'] = 'ignorecase';
+ break;
+ case 'numeric':
+ $this->task['compare'] = 'numeric';
+ $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'};
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing a cloze exercise. NOTE: As a cloze
+ * exercise has no special fields (it consists only of the question),
+ * normally, an empty template will be returned. The only elements it can
+ * contain are message boxes alerting that for the same cloze an answer
+ * alternative has been set repeatedly.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $duplicate_alternatives = $this->findDuplicateAlternatives();
+
+ foreach ($duplicate_alternatives as $alternative) {
+ $message = sprintf(_('Achtung: Sie haben bei der %d. Lücke die Antwort &bdquo;%s&ldquo; mehrfach eingetragen.'),
+ $alternative['index'] + 1, htmlReady($alternative['text']));
+ PageLayout::postWarning($message);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate($view, $solution, $assignment, $user_id): \Flexi\Template
+ {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Return the interaction type of this task (input, select or drag).
+ */
+ public function interactionType(): string
+ {
+ return $this->task['layout'] ?? 'input';
+ }
+
+ /**
+ * Check if selection should be offered for the given item.
+ */
+ public function isSelect(string $item, bool $use_default = true): bool
+ {
+ if ($use_default && $this->interactionType() === 'select') {
+ return true;
+ }
+
+ if (isset($this->task['select'])) {
+ return in_array($item, $this->task['select']);
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns all currently unassigned answers for the given solution.
+ */
+ public function availableAnswers(?VipsSolution $solution): array
+ {
+ $answers = [];
+ $response = $solution->response ?? [];
+
+ foreach ($this->task['answers'] as $answer) {
+ foreach ($answer as $option) {
+ $i = array_search($option['text'], $response);
+
+ if ($i !== false) {
+ unset($response[$i]);
+ } else if ($option['text'] !== '') {
+ $answers[] = $option['text'];
+ }
+ }
+ }
+
+ sort($answers, SORT_LOCALE_STRING);
+ return $answers;
+ }
+
+ /**
+ * Returns all the correct answers for an item in an array.
+ */
+ public function correctAnswers($item): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'][$item] as $answer) {
+ if ($answer['score'] == 1) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+
+ /**
+ * Calculate the optimal input field size for text exercises.
+ *
+ * @param int $item item number
+ * @return int length of input field in characters
+ */
+ public function getInputWidth($item): int
+ {
+ if (isset($this->task['input_width'])) {
+ return 5 << $this->task['input_width'];
+ }
+
+ $max = 0;
+
+ foreach ($this->task['answers'][$item] as $option) {
+ $length = mb_strlen($option['text']);
+
+ if ($length > $max) {
+ $max = $length;
+ }
+ }
+
+ $length = $max ? min(max($max, 6), 48) : 12;
+
+ // possible sizes: 5, 10, 20, 40
+ return 5 << ceil(log($length / 6) / log(2));
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $ignorecase = isset($this->task['compare']) && $this->task['compare'] === 'ignorecase';
+ $numeric = isset($this->task['compare']) && $this->task['compare'] === 'numeric';
+
+ foreach ($this->task['answers'] as $blank => $answer) {
+ $student_answer = $this->normalizeText($response[$blank] ?? '', $ignorecase);
+ $options = ['' => 0];
+ $points = 0;
+ $safe = $this->interactionType() !== 'input';
+
+ foreach ($answer as $option) { // different answer options
+ if ($numeric && $student_answer !== '') {
+ $correct_unit = $student_unit = null;
+ $correct = $this->normalizeFloat($option['text'], $correct_unit);
+ $student = $this->normalizeFloat($response[$blank], $student_unit);
+
+ if ($correct_unit === $student_unit) {
+ if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) {
+ $options[$student_answer] = max($option['score'], $options[$student_answer]);
+ } else {
+ $safe = true;
+ }
+ }
+ } else {
+ $content = $this->normalizeText($option['text'], $ignorecase);
+ $options[$content] = $option['score'];
+ }
+ }
+
+ if (isset($options[$student_answer])) {
+ $points = $options[$student_answer];
+ $safe = true;
+ }
+
+ $result[] = ['points' => $points, 'safe' => $safe];
+ }
+
+ return $result;
+ }
+
+
+
+ #######################################
+ # #
+ # h e l p e r f u n c t i o n s #
+ # #
+ #######################################
+
+
+
+ /**
+ * Returns the exercise for the lecturer. Clozes are represented by square brackets.
+ */
+ public function getClozeText(): string
+ {
+ $is_html = Studip\Markup::isHtml($this->task['text']);
+ $result = '';
+
+ foreach (explode('[[]]', $this->task['text']) as $blank => $text) {
+ $result .= $text;
+
+ if (isset($this->task['answers'][$blank])) { // blank
+ $answers = [];
+ $select = $this->isSelect($blank, false) ? ':' : '';
+
+ foreach ($this->task['answers'][$blank] as $answer) {
+ $answer_text = $answer['text'];
+
+ if (preg_match('/^$|^[":*~ ]|\||\]\]|[] ]$/', $answer_text)) {
+ $answer_text = '"' . $answer_text . '"';
+ }
+
+ if ($answer['score'] == 0) {
+ $answers[] = '*' . $answer_text;
+ } else if ($answer['score'] == 0.5) {
+ $answers[] = '~' . $answer_text;
+ } else {
+ $answers[] = $answer_text;
+ }
+ }
+
+ $blank = '[[' . $select . implode('|', $answers) . ']]';
+
+ if ($is_html) {
+ $blank = htmlReady($blank);
+ }
+
+ $result .= $blank;
+ }
+ }
+
+ return $result;
+ }
+
+
+
+ /**
+ * Converts plain text ("foo bar [blank] text...") to array.
+ */
+ public function parseClozeText(string $question): void
+ {
+ $is_html = Studip\Markup::isHtml($question);
+ $question = Studip\Markup::purifyHtml($question);
+ $this->task['text'] = '';
+
+ // $question_array contains text elements and blanks (surrounded by [[ and ]]).
+ $parts = preg_split('/(\[\[(?:".*?"|.)*?\]\])/s', $question, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $select = null;
+
+ foreach ($parts as $part) {
+ if (preg_match('/^\[\[(.*)\]\]$/s', $part, $matches)) {
+ $part = preg_replace("/[\t\n\r\xA0]/", ' ', $matches[1]);
+ $answers = [];
+
+ if ($is_html) {
+ $part = Studip\Markup::markAsHtml($part);
+ $part = Studip\Markup::removeHtml($part);
+ }
+
+ if ($part[0] === ':') {
+ $select[] = $this->itemCount();
+ $part = substr($part, 1);
+ }
+
+ if ($part !== '') {
+ preg_match_all('/((?:".*?"|[^|])*)\|/', $part . '|', $matches);
+
+ foreach ($matches[1] as $answer) {
+ $answer = trim($answer);
+ $points = 1;
+
+ if ($answer !== '') {
+ if ($answer[0] === '*') {
+ $points = 0;
+ $answer = substr($answer, 1);
+ } else if ($answer[0] === '~') {
+ $points = 0.5;
+ $answer = substr($answer, 1);
+ }
+ }
+
+ if (preg_match('/^"(.*)"$/', $answer, $matches)) {
+ $answer = $matches[1];
+ }
+
+ $answers[] = ['text' => $answer, 'score' => $points];
+ }
+ }
+
+ $this->task['answers'][] = $answers;
+ $this->task['text'] .= '[[]]';
+ } else {
+ $this->task['text'] .= $part;
+ }
+ }
+
+ $this->task['select'] = $select;
+ }
+
+ /**
+ * Searches in each cloze if an answer alternative is given repatedly.
+ *
+ * @return array Either an empty array or an array of arrays, each containing the
+ * elements 'index' (index of the cloze where the duplicate
+ * entry was found) and 'text' (text of the duplicate entry).
+ */
+ private function findDuplicateAlternatives(): array
+ {
+ $duplicate_alternatives = [];
+
+ foreach ($this->task['answers'] as $index => $answers) {
+ $alternatives = [];
+
+ foreach ($answers as $answer) {
+ if (in_array($answer['text'], $alternatives, true)) {
+ $duplicate_alternatives[] = [
+ 'index' => $index,
+ 'text' => $answer['text']
+ ];
+ }
+
+ $alternatives[] = $answer['text'];
+ }
+ }
+
+ return $duplicate_alternatives;
+ }
+}
diff --git a/lib/models/vips/DummyExercise.php b/lib/models/vips/DummyExercise.php
new file mode 100644
index 0000000..daa9dc5
--- /dev/null
+++ b/lib/models/vips/DummyExercise.php
@@ -0,0 +1,83 @@
+<?php
+/*
+ * DummyExercise.php - Vips plugin for Stud.IP
+ * Copyright (c) 2021 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class DummyExercise extends Exercise
+{
+ /**
+ * Get the name of this exercise type.
+ */
+ public function getTypeName(): string
+ {
+ return _('Unbekannter Aufgabentyp');
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ */
+ public function evaluateItems($solution): array
+ {
+ return [];
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers (defaults to 1).
+ */
+ public function itemCount(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Create a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $template = $GLOBALS['template_factory']->open('shared/string');
+ $template->content = '';
+
+ return $template;
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = $GLOBALS['template_factory']->open('shared/string');
+ $template->content = '';
+
+ return $template;
+ }
+}
diff --git a/lib/models/vips/Exercise.php b/lib/models/vips/Exercise.php
new file mode 100644
index 0000000..a4ef00a
--- /dev/null
+++ b/lib/models/vips/Exercise.php
@@ -0,0 +1,855 @@
+<?php
+/*
+ * Exercise.php - base class for all exercise types
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+abstract class Exercise extends SimpleORMap
+{
+ /**
+ * The unpacked value from the "task" column in the SORM instance.
+ * This is an array, but type hinting does not work due to SORM
+ * writing the JSON string into this property on restore().
+ */
+ public $task = [];
+
+ /**
+ * @var array<class-string<static>, array>
+ */
+ private static array $exercise_types = [];
+
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_tasks';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['has_and_belongs_to_many']['tests'] = [
+ 'class_name' => VipsTest::class,
+ 'thru_table' => 'etask_test_tasks',
+ 'thru_key' => 'task_id',
+ 'thru_assoc_key' => 'test_id'
+ ];
+
+ $config['has_many']['exercise_refs'] = [
+ 'class_name' => VipsExerciseRef::class,
+ 'assoc_foreign_key' => 'task_id'
+ ];
+ $config['has_many']['solutions'] = [
+ 'class_name' => VipsSolution::class,
+ 'assoc_foreign_key' => 'task_id',
+ 'on_delete' => 'delete'
+ ];
+
+ $config['has_one']['folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->id, 'ExerciseFolder'],
+ 'on_delete' => 'delete'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->type = get_class($this);
+ $this->task = ['answers' => []];
+ }
+
+ if (is_null($this->options)) {
+ $this->options = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ $this->title = trim($request['exercise_name']);
+ $this->description = trim($request['exercise_question']);
+ $this->description = Studip\Markup::purifyHtml($this->description);
+ $exercise_hint = trim($request['exercise_hint']);
+ $exercise_hint = Studip\Markup::purifyHtml($exercise_hint);
+ $feedback = trim($request['feedback']);
+ $feedback = Studip\Markup::purifyHtml($feedback);
+ $this->task = ['answers' => []];
+ $this->options = [];
+
+ if ($this->title === '') {
+ $this->title = _('Aufgabe');
+ }
+
+ if ($exercise_hint !== '') {
+ $this->options['hint'] = $exercise_hint;
+ }
+
+ if ($feedback !== '') {
+ $this->options['feedback'] = $feedback;
+ }
+
+ if ($request['exercise_comment']) {
+ $this->options['comment'] = 1;
+ }
+
+ if ($request['file_ids'] && !$request['files_visible']) {
+ $this->options['files_hidden'] = 1;
+ }
+ }
+
+ /**
+ * Filter input from flexible input with HTMLPurifier (if required).
+ */
+ public static function purifyFlexibleInput(string $html): string
+ {
+ if (Studip\Markup::isHtml($html)) {
+ $text = Studip\Markup::removeHtml($html);
+
+ if (substr_count($html, '<') > 1 || kill_format($text) !== $text) {
+ $html = Studip\Markup::purifyHtml($html);
+ } else {
+ $html = $text;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * Load a specific exercise from the database.
+ */
+ public static function find($id)
+ {
+ $db = DBManager::get();
+
+ $stmt = $db->prepare('SELECT * FROM etask_tasks WHERE id = ?');
+ $stmt->execute([$id]);
+ $data = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($data) {
+ return self::buildExisting($data);
+ }
+
+ return null;
+ }
+
+ /**
+ * Load an array of exercises filtered by given sql from the database.
+ *
+ * @param string $sql clause to use on the right side of WHERE
+ * @param array $params for query
+ */
+ public static function findBySQL($sql, $params = [])
+ {
+ $db = DBManager::get();
+
+ $has_join = stripos($sql, 'JOIN ');
+ if ($has_join === false || $has_join > 10) {
+ $sql = 'WHERE ' . $sql;
+ }
+ $stmt = $db->prepare('SELECT etask_tasks.* FROM etask_tasks ' . $sql);
+ $stmt->execute($params);
+ $stmt->setFetchMode(PDO::FETCH_ASSOC);
+ $result = [];
+
+ while ($data = $stmt->fetch()) {
+ $result[] = self::buildExisting($data);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Find related records for an n:m relation (has_and_belongs_to_many)
+ * using a combination table holding the keys.
+ *
+ * @param string $foreign_key_value value of foreign key to find related records
+ * @param array $options relation options from other side of relation
+ */
+ public static function findThru($foreign_key_value, $options)
+ {
+ $thru_table = $options['thru_table'];
+ $thru_key = $options['thru_key'];
+ $thru_assoc_key = $options['thru_assoc_key'];
+
+ $sql = "JOIN `$thru_table` ON `$thru_table`.`$thru_assoc_key` = etask_tasks.id
+ WHERE `$thru_table`.`$thru_key` = ? " . $options['order_by'];
+
+ return self::findBySQL($sql, [$foreign_key_value]);
+ }
+
+ /**
+ * Create a new exercise object from a data array.
+ */
+ public static function create($data)
+ {
+ $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class;
+
+ if (static::class === self::class) {
+ return $class::create($data);
+ } else {
+ return parent::create($data);
+ }
+ }
+
+ /**
+ * Build an exercise object from a data array.
+ */
+ public static function buildExisting($data)
+ {
+ $class = class_exists($data['type']) ? $data['type'] : DummyExercise::class;
+
+ return $class::build($data, false);
+ }
+
+ /**
+ * Initialize task structure from JSON string.
+ */
+ public function setTask(mixed $value): void
+ {
+ if (is_string($value)) {
+ $this->content['task'] = $value;
+ $value = json_decode($value, true) ?: [];
+ }
+
+ $this->task = $value;
+ }
+
+ /**
+ * Restore this exercise from the database.
+ */
+ public function restore()
+ {
+ $result = parent::restore();
+ $this->setTask($this->task);
+
+ return $result;
+ }
+
+ /**
+ * Store this exercise into the database.
+ */
+ public function store()
+ {
+ $this->content['task'] = json_encode($this->task);
+
+ return parent::store();
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers (defaults to 1).
+ */
+ public function itemCount(): int
+ {
+ return 1;
+ }
+
+ /**
+ * Overwrite this function for each exercise type where shuffling answer
+ * alternatives makes sense.
+ *
+ * @param string $user_id A value for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param VipsSolution $solution The solution object returned by getSolutionFromRequest().
+ */
+ public abstract function evaluateItems(VipsSolution $solution): array;
+
+ /**
+ * Evaluates a student's solution.
+ *
+ * @param VipsSolution $solution The solution object returned by getSolutionFromRequest().
+ */
+ public function evaluate(VipsSolution $solution): array
+ {
+ $results = $this->evaluateItems($solution);
+ $mc_mode = $solution->assignment->options['evaluation_mode'];
+ $malus = 0;
+ $points = 0;
+ $safe = true;
+
+ foreach ($results as $item) {
+ if ($item['points'] === 0) {
+ ++$malus;
+ } else if ($item['points'] !== null) {
+ $points += $item['points'];
+ }
+
+ if ($item['safe'] === null) {
+ $safe = null;
+ } else if ($safe !== null) {
+ // only true if all items are marked as 'safe'
+ $safe &= $item['safe'];
+ }
+ }
+
+ if ($this->isMultipleChoice()) {
+ if ($mc_mode == 1) {
+ $points = max($points - $malus, 0);
+ } else if ($mc_mode == 2 && $malus > 0) {
+ $points = 0;
+ }
+ }
+
+ $percent = $points / max(count($results), 1);
+
+ return ['percent' => $percent, 'safe' => $safe];
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return array_fill(0, $this->itemCount(), '');
+ }
+
+ /**
+ * Return the response of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ for ($i = 0; $i < $this->itemCount(); ++$i) {
+ $result[] = trim($request['answer'][$i] ?? '');
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_values($response);
+ }
+
+ /**
+ * Export this exercise to Vips XML format.
+ */
+ public function getXMLTemplate(VipsAssignment $assignment): Flexi\Template
+ {
+ return $this->getViewTemplate('xml', null, $assignment, null);
+ }
+
+ /**
+ * Exercise handler to be called when a solution is corrected.
+ */
+ public function correctSolutionAction(Trails\Controller$controller, VipsSolution $solution): void
+ {
+ }
+
+ /**
+ * Return a URL to a specified route in this exercise class.
+ * $params can contain optional additional parameters.
+ */
+ public function url_for($path, $params = []): string
+ {
+ $params['exercise_id'] = $this->id;
+
+ return URLHelper::getURL('dispatch.php/vips/sheets/relay/' . $path, $params);
+ }
+
+ /**
+ * Return an encoded URL to a specified route in this exercise class.
+ * $params can contain optional additional parameters.
+ */
+ public function link_for($path, $params = []): string
+ {
+ return htmlReady($this->url_for($path, $params));
+ }
+
+ /**
+ * Create a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/edit');
+ $template->exercise = $this;
+
+ return $template;
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ if ($assignment->isShuffled() && $user_id) {
+ $this->shuffleAnswers($user_id);
+ }
+
+ $template = VipsModule::$template_factory->open('exercises/' . $this->type . '/' . $view);
+ $template->exercise = $this;
+ $template->solution = $solution;
+ $template->response = $solution ? $solution->response : null;
+ $template->evaluation_mode = $assignment->options['evaluation_mode'];
+
+ return $template;
+ }
+
+ /**
+ * Return a template for solving an exercise.
+ */
+ public function getSolveTemplate(
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ return $this->getViewTemplate('solve', $solution, $assignment, $user_id);
+ }
+
+ /**
+ * Return a template for correcting an exercise.
+ */
+ public function getCorrectionTemplate(VipsSolution $solution): Flexi\Template
+ {
+ return $this->getViewTemplate('correct', $solution, $solution->assignment, $solution->user_id);
+ }
+
+ /**
+ * Return a template for printing an exercise.
+ */
+ public function getPrintTemplate(VipsSolution $solution, VipsAssignment $assignment, ?string $user_id)
+ {
+ return $this->getViewTemplate('print', $solution, $assignment, $user_id);
+ }
+
+ /**
+ * Get the name of this exercise type.
+ */
+ public function getTypeName(): string
+ {
+ return self::$exercise_types[$this->type]['name'];
+ }
+
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('question-circle', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return '';
+ }
+
+ /**
+ * Get the list of supported exercise types.
+ */
+ public static function getExerciseTypes(): array
+ {
+ return self::$exercise_types;
+ }
+
+ /**
+ * Register a new exercise type and class.
+ *
+ * @param class-string<static> $class
+ */
+ public static function addExerciseType(string $name, string $class, mixed $type = null): void
+ {
+ self::$exercise_types[$class] = compact('name', 'type');
+ }
+
+ /**
+ * Return the list of keywords used for legacy text export. The first
+ * keyword in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return [];
+ }
+
+ /**
+ * Import a new exercise from text data array.
+ */
+ public static function importText(string $segment): static
+ {
+ $all_keywords = ['Tipp'];
+
+ $types = [];
+ foreach (self::$exercise_types as $key => $value) {
+ $keywords = $key::getTextKeywords();
+
+ if ($keywords) {
+ $all_keywords = array_merge($all_keywords, $keywords);
+ $types[$key] = array_shift($keywords);
+ }
+ }
+
+ $type = '';
+ $pattern = implode('|', array_unique($all_keywords));
+ $parts = preg_split("/\n($pattern):/", $segment, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $title = array_shift($parts);
+
+ $exercise = [['Name' => trim($title)]];
+
+ if ($parts) {
+ $type = array_shift($parts);
+ $text = array_shift($parts);
+ $text = preg_replace('/\\\\' . $type . '$/', '', trim($text));
+
+ $exercise[] = ['Type' => trim($type)];
+ $exercise[] = ['Text' => trim($text)];
+ }
+
+ while ($parts) {
+ $tag = array_shift($parts);
+ $val = array_shift($parts);
+ $val = preg_replace('/\\\\' . $tag . '$/', '', trim($val));
+
+ $exercise[] = [$tag => trim($val)];
+ }
+
+ foreach ($types as $key => $value) {
+ if (preg_match('/^' . $value . '$/', $type)) {
+ $exercise_type = $key;
+ }
+ }
+
+ if (!isset($exercise_type)) {
+ throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type);
+ }
+
+ /** @var class-string<static> $exercise_type */
+ $result = new $exercise_type();
+ $result->initText($exercise);
+ return $result;
+ }
+
+ /**
+ * Import a new exercise from Vips XML format.
+ */
+ public static function importXML($exercise): static
+ {
+ $type = (string) $exercise->items->item[0]['type'];
+
+ foreach (self::$exercise_types as $key => $value) {
+ if ($type === $value['type'] || is_array($value['type']) && in_array($type, $value['type'])) {
+ $exercise_type = $key;
+ }
+ }
+
+ if (!isset($exercise_type)) {
+ throw new InvalidArgumentException(_('Unbekannter Aufgabentyp: ') . $type);
+ }
+
+ if (
+ $exercise_type === MultipleChoiceTask::class
+ && $exercise->items->item[0]->choices
+ ) {
+ $exercise_type = MatrixChoiceTask::class;
+ }
+
+ /** @var class-string<static> $exercise_type */
+ $result = new $exercise_type();
+ $result->initXML($exercise);
+ return $result;
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Name') {
+ $this->title = current($tag) ?: _('Aufgabe');
+ }
+
+ if (key($tag) === 'Text') {
+ $this->description = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Tipp') {
+ $this->options['hint'] = Studip\Markup::purifyHtml(current($tag));
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ $this->title = trim($exercise->title);
+
+ if ($this->title === '') {
+ $this->title = _('Aufgabe');
+ }
+
+ if ($exercise->description) {
+ $this->description = Studip\Markup::purifyHtml(trim($exercise->description));
+ }
+
+ if ($exercise->hint) {
+ $this->options['hint'] = Studip\Markup::purifyHtml(trim($exercise->hint));
+ }
+
+ if ($exercise['feedback'] == 'true') {
+ $this->options['comment'] = 1;
+ }
+
+ if ($exercise->{'file-refs'}['hidden'] == 'true') {
+ $this->options['files_hidden'] = 1;
+ }
+
+ if ($exercise->items->item[0]->feedback) {
+ $this->options['feedback'] = Studip\Markup::purifyHtml(trim($exercise->items->item[0]->feedback));
+ }
+ }
+
+ /**
+ * Construct a new solution object from the request post data.
+ */
+ public function getSolutionFromRequest($request, ?array $files = null): VipsSolution
+ {
+ $solution = new VipsSolution();
+ $solution->exercise = $this;
+ $solution->user_id = $GLOBALS['user']->id;
+ $solution->response = $this->responseFromRequest($request);
+ $solution->student_comment = trim($request['student_comment']);
+
+ return $solution;
+ }
+
+ /**
+ * Include files referenced by URL into the exercise attachments and
+ * rewrite all corresponding URLs in the exercise text.
+ */
+ public function includeFilesForExport(): void
+ {
+ if (!$this->folder || count($this->folder->file_refs) === 0) {
+ $this->options['files_hidden'] = 1;
+ }
+
+ $this->description = $this->rewriteLinksForExport($this->description);
+ $this->options['hint'] = $this->rewriteLinksForExport($this->options['hint']);
+ $this->task = $this->rewriteLinksForExport($this->task);
+ }
+
+ /**
+ * Return a normalized version of a string
+ *
+ * @param string $string string to be normalized
+ * @param boolean $lowercase make string lower case
+ * @return string The normalized string
+ */
+ protected function normalizeText(string $string, bool $lowercase = true): string
+ {
+ // remove leading/trailing spaces
+ $string = trim($string);
+
+ // compress white space
+ $string = preg_replace('/\s+/u', ' ', $string);
+
+ // delete blanks before and after [](){}:;,.!?"=<>^*/+-
+ $string = preg_replace('/ *([][(){}:;,.!?"=<>^*\/+-]) */', '$1', $string);
+
+ // convert to lower case if requested
+ return $lowercase ? mb_strtolower($string) : $string;
+ }
+
+ /**
+ * Return a normalized version of a float (and optionally a unit)
+ *
+ * @param string $string string to be normalized
+ * @param string $unit will contain the unit text
+ * @return float The normalized value
+ */
+ protected function normalizeFloat(string $string, string &$unit): float
+ {
+ static $si_scale = [
+ 'T' => 12,
+ 'G' => 9,
+ 'M' => 6,
+ 'k' => 3,
+ 'h' => 2,
+ 'd' => -1,
+ 'c' => -2,
+ 'm' => -3,
+ 'µ' => -6,
+ 'μ' => -6,
+ 'n' => -9,
+ 'p' => -12
+ ];
+
+ // normalize representation
+ $string = $this->normalizeText($string, false);
+ $string = str_replace('*10^', 'e', $string);
+ $string = preg_replace_callback('/(\d+)\/(\d+)/', function($m) { return $m[1] / $m[2]; }, $string);
+ $string = strtr($string, ',', '.');
+
+ // split into value and unit
+ preg_match('/^([-+0-9.e]*)(.*)/', $string, $matches);
+ $value = (float) $matches[1];
+ $unit = trim($matches[2]);
+
+ if ($unit) {
+ $prefix = mb_substr($unit, 0, 1);
+ $letter = mb_substr($unit, 1, 1);
+
+ if (ctype_alpha($letter) && isset($si_scale[$prefix])) {
+ $value *= pow(10, $si_scale[$prefix]);
+ $unit = mb_substr($unit, 1);
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * UTF-8 compatible version of standard PHP levenshtein function.
+ */
+ protected function levenshtein(string $string1, string $string2): int
+ {
+ $mb_str1 = preg_split('//u', $string1, null, PREG_SPLIT_NO_EMPTY);
+ $mb_str2 = preg_split('//u', $string2, null, PREG_SPLIT_NO_EMPTY);
+
+ $mb_len1 = count($mb_str1);
+ $mb_len2 = count($mb_str2);
+
+ $dist = [];
+ for ($i = 0; $i <= $mb_len1; ++$i) {
+ $dist[$i][0] = $i;
+ }
+ for ($j = 0; $j <= $mb_len2; ++$j) {
+ $dist[0][$j] = $j;
+ }
+
+ for ($i = 1; $i <= $mb_len1; $i++) {
+ for ($j = 1; $j <= $mb_len2; $j++) {
+ $dist[$i][$j] = min(
+ $dist[$i-1][$j] + 1,
+ $dist[$i][$j-1] + 1,
+ $dist[$i-1][$j-1] + ($mb_str1[$i-1] !== $mb_str2[$j-1] ? 1 : 0)
+ );
+ }
+ }
+
+ return $dist[$mb_len1][$mb_len2];
+ }
+
+ /**
+ * Scan the given string or array (recursively) for referenced file URLs
+ * and rewrite those links into URNs suitable for XML export.
+ */
+ protected function rewriteLinksForExport(mixed $data): mixed
+ {
+ if (is_array($data)) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $this->rewriteLinksForExport($value);
+ }
+ } else if (is_string($data) && Studip\Markup::isHtml($data)) {
+ $data = preg_replace_callback('/"\Khttps?:[^"]*/', function($match) {
+ $url = html_entity_decode($match[0]);
+ $url = preg_replace(
+ '%/download/(?:normal|force_download)/\d/(\w+)/.+%',
+ '/sendfile.php?file_id=$1',
+ $url
+ );
+ [$url, $query] = explode('?', $url);
+
+ if (is_internal_url($url) && basename($url) === 'sendfile.php') {
+ parse_str($query, $query_params);
+ $file_id = $query_params['file_id'];
+ $file_ref = FileRef::find($file_id);
+
+ if ($file_ref && $this->folder->file_refs->find($file_id)) {
+ return 'urn:vips:file-ref:file-' . $file_ref->file_id;
+ }
+
+ if ($file_ref) {
+ $folder = $file_ref->folder->getTypedFolder();
+
+ if ($folder->isFileDownloadable($file_ref, $GLOBALS['user']->id)) {
+ if (!$this->folder->file_refs->find($file_id)) {
+ $file = $file_ref->file;
+ // $this->files->append($file);
+ }
+
+ return 'urn:vips:file-ref:file-' . $file_id->file_id;
+ }
+ }
+ }
+
+ return $match[0];
+ }, $data);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Calculate the size parameter for a flexible input element.
+ *
+ * @param string $text contents of the input
+ */
+ public function flexibleInputSize(?string $text): string
+ {
+ return str_contains($text, "\n") || Studip\Markup::isHtml($text) ? 'large' : 'small';
+ }
+
+ /**
+ * Calculate the optimal textarea height for text exercises.
+ *
+ * @param string $text contents of textarea
+ * @return int height of textarea in lines
+ */
+ public function textareaSize(?string $text): int
+ {
+ return max(substr_count($text, "\n") + 3, 5);
+ }
+}
diff --git a/lib/models/vips/MatchingTask.php b/lib/models/vips/MatchingTask.php
new file mode 100644
index 0000000..bb559e2
--- /dev/null
+++ b/lib/models/vips/MatchingTask.php
@@ -0,0 +1,341 @@
+<?php
+/*
+ * MatchingTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MatchingTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('view-list', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Zuordnung von Elementen zu Kategorien');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['groups'] = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $id = $request['id'];
+ $_id = $request['_id'];
+
+ $this->task['groups'] = [];
+ $this->task['select'] = $request['multiple'] ? 'multiple' : 'single';
+
+ foreach ($request['default'] as $i => $group) {
+ $group = self::purifyFlexibleInput($group);
+ $answers = (array) $request['answer'][$i];
+
+ if (trim($group) != '') {
+ foreach ($answers as $j => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $id[$i][$j],
+ 'text' => trim($answer),
+ 'group' => count($this->task['groups'])
+ ];
+ }
+ }
+
+ $this->task['groups'][] = trim($group);
+ }
+ }
+
+ // list of answers that must remain unassigned
+ foreach ($request['_answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $_id[$i],
+ 'text' => trim($answer),
+ 'group' => -1
+ ];
+ }
+ }
+
+ $this->createIds();
+ }
+
+ /**
+ * Genereate new IDs for all answers that do not yet have one.
+ */
+ public function createIds(): void
+ {
+ $ids = [0 => true];
+
+ foreach ($this->task['answers'] as $i => &$answer) {
+ if (empty($answer['id'])) {
+ do {
+ $answer['id'] = rand();
+ } while (isset($ids[$answer['id']]));
+ }
+
+ $ids[$answer['id']] = true;
+ }
+ }
+
+ /**
+ * Check if multiple assignment mode is enabled for this exercise.
+ */
+ public function isMultiSelect(): bool
+ {
+ return isset($this->task['select']) && $this->task['select'] === 'multiple';
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']) - count($this->correctAnswers(-1));
+ }
+
+ /**
+ * Sort the list of answers by their ids.
+ */
+ public function sortAnswersById(): void
+ {
+ usort(
+ $this->task['answers'],
+ fn($a, $b) => $a['id'] <=> $b['id']
+ );
+ }
+
+ /**
+ * Returns all the correct answers for the given group.
+ */
+ public function correctAnswers(string $group): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['group'] == $group) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+
+ /**
+ * Check if this answer is a correct assignment to the given group.
+ */
+ public function isCorrectAnswer(array $answer, string $group): bool
+ {
+ if ($answer['group'] == $group) {
+ return true;
+ }
+
+ foreach ($this->task['answers'] as $_answer) {
+ if ($_answer['group'] == $group) {
+ if ($answer['text'] === $_answer['text']) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $item_count = $this->itemCount();
+
+ foreach ($this->task['answers'] as $answer) {
+ $group = $response[$answer['id']] ?? -1;
+
+ if ($group != -1) {
+ $points = $this->isCorrectAnswer($answer, $group) ? 1 : 0;
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+ }
+
+ // assign no points for missing answers
+ while (count($result) < $item_count) {
+ $result[] = ['points' => 0, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['ZU-Frage', 'Vorgabe', 'Antwort', 'Distraktor'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Vorgabe') {
+ $group = count($this->task['groups']);
+ $this->task['groups'][] = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Antwort' && isset($group)) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'group' => $group
+ ];
+ unset($group);
+ }
+
+ if (key($tag) === 'Distraktor') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'group' => -1
+ ];
+ }
+ }
+
+ $this->createIds();
+ }
+
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ $this->task['select'] = $exercise->items->item['type'] == 'matching-multiple' ? 'multiple' : 'single';
+
+ foreach ($exercise->items->item->choices->choice as $choice) {
+ $this->task['groups'][] = Studip\Markup::purifyHtml(trim($choice));
+ }
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'group' => (int) $answer['correct']
+ ];
+ }
+
+ $this->createIds();
+ }
+
+
+
+ /**
+ * Creates a template for editing a MatchingTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): \Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ foreach (range(0, 4) as $i) {
+ $this->task['answers'][] = ['id' => '', 'text' => '', 'group' => count($this->task['groups'])];
+ $this->task['groups'][] = '';
+ }
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ // get the group the user has added this answer to
+ $result[$answer['id']] = (int) $request['answer'][$answer['id']];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ $result = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['group'] != -1) {
+ if (isset($response[$answer['id']]) && $response[$answer['id']] != -1) {
+ $result[] = $this->task['groups'][$response[$answer['id']]];
+ } else {
+ $result[] = '';
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/models/vips/MatrixChoiceTask.php b/lib/models/vips/MatrixChoiceTask.php
new file mode 100644
index 0000000..1ba2d90
--- /dev/null
+++ b/lib/models/vips/MatrixChoiceTask.php
@@ -0,0 +1,268 @@
+<?php
+/*
+ * MatrixChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MatrixChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('timetable', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Einfachauswahl pro Zeile in einer Tabelle');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task['choices'] = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task['choices'] = [];
+ $choice_index = [];
+
+ foreach ($request['choice'] as $i => $choice) {
+ if (trim($choice) != '') {
+ $this->task['choices'][] = trim($choice);
+ $choice_index[$i] = count($choice_index);
+ }
+ }
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'choice' => $choice_index[$request['correct'][$i]]
+ ];
+ }
+ }
+
+ if ($request['optional']) {
+ $this->options['optional'] = 1;
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ $random_order = range(0, $this->itemCount() - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task['answers'][$index];
+ }
+ $this->task['answers'] = $answer_temp;
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task['answers'] as $i => $answer) {
+ if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) {
+ $points = null;
+ } else {
+ $points = $response[$i] == $answer['choice'] ? 1 : 0;
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['MCO-Frage', 'Auswahl', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'choice' => 0
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'choice' => 1
+ ];
+ }
+ }
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Auswahl') {
+ [$label_yes, $label_no] = explode('/', current($tag));
+ $this->task['choices'] = [trim($label_yes), trim($label_no)];
+ }
+ }
+
+ $this->options['optional'] = 1;
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ if (isset($answer['correct'])) {
+ $choice = (int) $answer['correct'];
+ } else {
+ $choice = (int) $answer['score'] ? 0 : 1;
+ }
+
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'choice' => $choice
+ ];
+ }
+
+ foreach ($exercise->items->item->choices->choice as $choice) {
+ if ($choice['type'] == 'none') {
+ $this->options['optional'] = 1;
+ } else {
+ $this->task['choices'][] = trim($choice);
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing an exercise.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['choices']) {
+ $this->task['choices'] = [_('Ja'), _('Nein')];
+ }
+
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'choice' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate($view, $solution, $assignment, $user_id): Flexi\Template
+ {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if (isset($this->options['optional']) && $this->options['optional']) {
+ $template->optional_choice = [-1 => _('keine Antwort')];
+ } else {
+ $template->optional_choice = [];
+ }
+
+ return $template;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_map(
+ fn($a) => $a == -1 ? '' : $a,
+ $response
+ );
+ }
+}
diff --git a/lib/models/vips/MultipleChoiceTask.php b/lib/models/vips/MultipleChoiceTask.php
new file mode 100644
index 0000000..68470ef
--- /dev/null
+++ b/lib/models/vips/MultipleChoiceTask.php
@@ -0,0 +1,196 @@
+<?php
+/*
+ * MultipleChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class MultipleChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('assessment-mc', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Mehrfachauswahl aus einer Liste');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (int) $request['correct'][$i]
+ ];
+ }
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return [];
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ $random_order = range(0, $this->itemCount() - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task['answers'][$index];
+ }
+ $this->task['answers'] = $answer_temp;
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task['answers'] as $i => $answer) {
+ if (!isset($response[$i])) {
+ $points = null;
+ } else {
+ $points = (int) $response[$i] == $answer['score'] ? 1 : 0;
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['MC-Frage', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 1
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 0
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => (int) $answer['score']
+ ];
+ }
+ }
+
+ /**
+ * Creates a template for editing a MultipleChoiceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+}
diff --git a/lib/models/vips/SequenceTask.php b/lib/models/vips/SequenceTask.php
new file mode 100644
index 0000000..696fe6a
--- /dev/null
+++ b/lib/models/vips/SequenceTask.php
@@ -0,0 +1,255 @@
+<?php
+/*
+ * SequenceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2022 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class SequenceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('hamburger', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Anordnung von Elementen in einer Reihe');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'id' => (int) $request['id'][$i],
+ 'text' => trim($answer)
+ ];
+ }
+ }
+
+ $this->task['compare'] = $request['compare'];
+
+ $this->createIds();
+ }
+
+ /**
+ * Genereate new IDs for all answers that do not yet have one.
+ */
+ public function createIds(): void
+ {
+ $ids = [0 => true];
+
+ foreach ($this->task['answers'] as $i => &$answer) {
+ if (empty($answer['id'])) {
+ do {
+ $answer['id'] = rand();
+ } while (isset($ids[$answer['id']]));
+ }
+
+ $ids[$answer['id']] = true;
+ }
+ }
+
+ /**
+ * Compute the default maximum points which can be reached in this
+ * exercise, dependent on the number of answers.
+ */
+ public function itemCount(): int
+ {
+ if ($this->task['compare'] === 'sequence') {
+ return max(count($this->task['answers']) - 1, 0);
+ }
+
+ return count($this->task['answers']);
+ }
+
+ /**
+ * Return the list of answers as ordered by the student (if applicable).
+ */
+ public function orderedAnswers($response)
+ {
+ $answers = $this->task['answers'];
+ $pos = isset($response) ? array_flip($response) : [];
+
+ usort($answers, function($a, $b) use ($pos) {
+ if (isset($pos[$a['id']]) && isset($pos[$b['id']])) {
+ return $pos[$a['id']] <=> $pos[$b['id']];
+ } else if (isset($pos[$a['id']])) {
+ return -1;
+ } else if (isset($pos[$b['id']])) {
+ return 1;
+ } else {
+ return $a['id'] <=> $b['id'];
+ }
+ });
+
+ return $answers;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution object returned by getSolutionFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $item_count = $this->itemCount();
+ $answers = $this->task['answers'];
+ $pos = array_flip($response);
+
+ for ($i = 0; $i < $item_count; ++$i) {
+ if ($this->task['compare'] === 'sequence') {
+ if ($pos[$answers[$i]['id']] + 1 == $pos[$answers[$i + 1]['id']]) {
+ $points = 1;
+ } else {
+ $points = 0;
+ }
+ } else {
+ if ($pos[$answers[$i]['id']] == $i) {
+ $points = 1;
+ } else {
+ $points = 0;
+ }
+ }
+
+ if (!$this->task['compare'] && count($result)) {
+ $result[0]['points'] &= $points;
+ } else {
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer))
+ ];
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'position':
+ case 'sequence':
+ $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type'];
+ }
+ }
+
+ $this->createIds();
+ }
+
+
+
+ /**
+ * Creates a template for editing a SequenceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['id' => '', 'text' => '']);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = [];
+
+ foreach ($request['answer'] as $id) {
+ $result[] = (int) $id;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ $result = [];
+
+ foreach ($response as $id) {
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['id'] === $id) {
+ $result[] = $answer['text'];
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/lib/models/vips/SingleChoiceTask.php b/lib/models/vips/SingleChoiceTask.php
new file mode 100644
index 0000000..4029a65
--- /dev/null
+++ b/lib/models/vips/SingleChoiceTask.php
@@ -0,0 +1,279 @@
+<?php
+/*
+ * SingleChoiceTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class SingleChoiceTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('assessment', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Einfachauswahl aus einer Liste');
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (!isset($id)) {
+ $this->task = [];
+ }
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task = [];
+
+ foreach ($request['answer'] as $group => $answergroup) {
+ $task = [];
+ $description = trim($request['description'][$group]);
+ $description = Studip\Markup::purifyHtml($description);
+
+ if ($this->task && $description != '') {
+ $task['description'] = $description;
+ }
+
+ foreach ($answergroup as $i => $answer) {
+ $answer = self::purifyFlexibleInput($answer);
+
+ if (trim($answer) != '') {
+ $task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => $request['correct'][$group] == $i ? 1 : 0
+ ];
+ }
+ }
+
+ if ($task) {
+ $this->task[] = $task;
+ }
+ }
+
+ if ($request['optional']) {
+ $this->options['optional'] = 1;
+ }
+ }
+
+ /**
+ * Computes the default maximum points which can be reached in this
+ * exercise, dependent on the number of groups.
+ *
+ * @return int maximum points
+ */
+ public function itemCount(): int
+ {
+ return count($this->task);
+ }
+
+ /**
+ * Shuffle the answer alternatives.
+ *
+ * @param $user_id string used for initialising the randomizer.
+ */
+ public function shuffleAnswers(string $user_id): void
+ {
+ srand(crc32($this->id . ':' . $user_id));
+
+ for ($block = 0; $block < count($this->task); $block++) {
+ $random_order = range(0, count($this->task[$block]['answers']) - 1);
+ shuffle($random_order);
+
+ $answer_temp = [];
+ foreach ($random_order as $index) {
+ $answer_temp[$index] = $this->task[$block]['answers'][$index];
+ }
+ $this->task[$block]['answers'] = $answer_temp;
+ }
+
+ srand();
+ }
+
+ /**
+ * Returns true if this exercise type is considered as multiple choice.
+ * In this case, the evaluation mode set on the assignment is applied.
+ */
+ public function isMultipleChoice(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+
+ foreach ($this->task as $i => $task) {
+ if (!isset($response[$i]) || $response[$i] === '' || $response[$i] == -1) {
+ $points = null;
+ } else {
+ $points = $task['answers'][$response[$i]]['score'];
+ }
+
+ $result[] = ['points' => $points, 'safe' => true];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['SCO?-Frage|JN-Frage', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ $block = 0;
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Type' && current($tag) === 'SCO-Frage') {
+ $this->options['optional'] = 1;
+ }
+
+ if (key($tag) === '+Antwort' || key($tag) === 'Antwort') {
+ if (preg_match('/\n--$/', current($tag))) {
+ $text = trim(substr(current($tag), 0, -3));
+ $incr = 1;
+ } else {
+ $text = current($tag);
+ $incr = 0;
+ }
+
+ $score = key($tag) === '+Antwort' ? 1 : 0;
+
+ $this->task[$block]['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml($text),
+ 'score' => $score
+ ];
+
+ $block += $incr;
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item as $item) {
+ $task = [];
+
+ if ($item->description) {
+ $task['description'] = Studip\Markup::purifyHtml(trim($item->description->text));
+ }
+
+ foreach ($item->answers->answer as $answer) {
+ if ($answer['default'] == 'true') {
+ $this->options['optional'] = 1;
+ } else {
+ $task['answers'][] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => (int) $answer['score']
+ ];
+ }
+ }
+
+ $this->task[] = $task;
+ }
+ }
+
+ /**
+ * Creates a template for editing a SingleChoiceTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task) {
+ $this->task[0]['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if (isset($this->options['optional']) && $this->options['optional']) {
+ $template->optional_answer = [-1 => ['text' => _('keine Antwort'), 'score' => 0]];
+ } else {
+ $template->optional_answer = [];
+ }
+
+ return $template;
+ }
+
+ /**
+ * Export a response for this exercise into an array of strings.
+ */
+ public function exportResponse(array $response): array
+ {
+ return array_map(function($a) { return $a == -1 ? '' : $a; }, $response);
+ }
+}
diff --git a/lib/models/vips/TextLineTask.php b/lib/models/vips/TextLineTask.php
new file mode 100644
index 0000000..4a2e7d2
--- /dev/null
+++ b/lib/models/vips/TextLineTask.php
@@ -0,0 +1,271 @@
+<?php
+/*
+ * TextLineTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2011 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class TextLineTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('edit-line', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Kurze einzeilige Textantwort');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ foreach ($request['answer'] as $i => $answer) {
+ if (trim($answer) != '') {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (float) $request['correct'][$i]
+ ];
+ }
+ }
+
+ $this->task['compare'] = $request['compare'];
+
+ if ($this->task['compare'] === 'numeric') {
+ $this->task['epsilon'] = (float) strtr($request['epsilon'], ',', '.') / 100;
+ }
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $response = $solution->response;
+ $studentSolution = $response[0];
+
+ $similarity = 0;
+ $safe = false;
+ $studentSolution = $this->normalizeText($studentSolution, true);
+
+ if ($studentSolution === '') {
+ $result[] = ['points' => 0, 'safe' => true];
+ return $result;
+ }
+
+ foreach ($this->task['answers'] as $answer) {
+ $musterLoesung = $this->normalizeText($answer['text'], true);
+ $similarity_temp = 0;
+
+ if ($musterLoesung === $studentSolution) {
+ $similarity_temp = 1;
+ } else if ($this->task['compare'] === 'levenshtein') { // Levenshtein-Distanz
+ $string1 = mb_substr($studentSolution, 0, 255);
+ $string2 = mb_substr($musterLoesung, 0, 255);
+ $divisor = max(mb_strlen($string1), mb_strlen($string2));
+
+ $levenshtein = $this->levenshtein($string1, $string2) / $divisor;
+ $similarity_temp = 1 - $levenshtein;
+ } else if ($this->task['compare'] === 'soundex') { // Soundex-Aussprache
+ $levenshtein = levenshtein(soundex($musterLoesung), soundex($studentSolution));
+
+ if ($levenshtein == 0) {
+ $similarity_temp = 0.8;
+ } else if ($levenshtein == 1) {
+ $similarity_temp = 0.6;
+ } else if ($levenshtein == 2) {
+ $similarity_temp = 0.4;
+ } else if ($levenshtein == 3) {
+ $similarity_temp = 0.2;
+ } else {// $levenshtein == 4
+ $similarity_temp = 0;
+ }
+ } else if ($this->task['compare'] === 'numeric') {
+ $correct = $this->normalizeFloat($answer['text'], $correct_unit);
+ $student = $this->normalizeFloat($response[0], $student_unit);
+
+ if ($correct_unit === $student_unit) {
+ if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) {
+ $similarity_temp = 1;
+ } else {
+ $safe = true;
+ }
+ }
+ }
+
+ if ($answer['score'] == 1) { // correct
+ if ($similarity_temp > $similarity) {
+ $similarity = $similarity_temp;
+ $safe = $similarity_temp == 1;
+ }
+ } else if ($answer['score'] == 0.5) { // half correct
+ if ($similarity_temp > $similarity) {
+ $similarity = $similarity_temp * 0.5;
+ $safe = $similarity_temp == 1;
+ }
+ } else if ($similarity_temp == 1) { // false
+ $similarity = 0;
+ $safe = true;
+ break;
+ }
+ }
+
+ $result[] = ['points' => $similarity, 'safe' => $safe];
+
+ return $result;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['Frage', 'Eingabehilfe', 'Abgleich', '[+~]?Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Levenshtein') {
+ $this->task['compare'] = 'levenshtein';
+ } else if (current($tag) === 'Soundex') {
+ $this->task['compare'] = 'soundex';
+ }
+ }
+
+ if (key($tag) === '+Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 1
+ ];
+ } else if (key($tag) === '~Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 0.5
+ ];
+ } else if (key($tag) === 'Antwort') {
+ $this->task['answers'][] = [
+ 'text' => current($tag),
+ 'score' => 0
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ $this->task['answers'][] = [
+ 'text' => trim($answer),
+ 'score' => (float) $answer['score']
+ ];
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'levenshtein':
+ case 'soundex':
+ $this->task['compare'] = (string) $exercise->items->item->{'evaluation-hints'}->similarity['type'];
+ break;
+ case 'numeric':
+ $this->task['compare'] = 'numeric';
+ $this->task['epsilon'] = (float) $exercise->items->item->{'evaluation-hints'}->{'input-data'};
+ }
+ }
+ }
+
+ /**
+ * Creates a template for editing a TextLineTask.
+ */
+ public function getEditTemplate(?VipsAssignment $assignment): Flexi\Template
+ {
+ if (!$this->task['answers']) {
+ $this->task['answers'] = array_fill(0, 5, ['text' => '', 'score' => 0]);
+ }
+
+ return parent::getEditTemplate($assignment);
+ }
+
+ /**
+ * Create a template for viewing an exercise.
+ */
+ public function getViewTemplate(
+ string $view,
+ ?VipsSolution $solution,
+ VipsAssignment $assignment,
+ ?string $user_id
+ ): Flexi\Template {
+ $template = parent::getViewTemplate($view, $solution, $assignment, $user_id);
+
+ if ($solution && $solution->id) {
+ $template->results = $this->evaluateItems($solution);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Returns all the correct answers in an array.
+ */
+ public function correctAnswers(): array
+ {
+ $answers = [];
+
+ foreach ($this->task['answers'] as $answer) {
+ if ($answer['score'] == 1) {
+ $answers[] = $answer['text'];
+ }
+ }
+
+ return $answers;
+ }
+}
diff --git a/lib/models/vips/TextTask.php b/lib/models/vips/TextTask.php
new file mode 100644
index 0000000..5684195
--- /dev/null
+++ b/lib/models/vips/TextTask.php
@@ -0,0 +1,279 @@
+<?php
+/*
+ * TextTask.php - Vips plugin for Stud.IP
+ * Copyright (c) 2006-2009 Elmar Ludwig, Martin Schröder
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $type database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $task database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property SimpleORMapCollection|VipsTest[] $tests has_and_belongs_to_many VipsTest
+ */
+class TextTask extends Exercise
+{
+ /**
+ * Get the icon of this exercise type.
+ */
+ public static function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ return Icon::create('edit', $role);
+ }
+
+ /**
+ * Get a description of this exercise type.
+ */
+ public static function getTypeDescription(): string
+ {
+ return _('Mehrzeilige Textantwort oder Dateiabgabe');
+ }
+
+ /**
+ * Initialize this instance from the current request environment.
+ */
+ public function initFromRequest($request): void
+ {
+ parent::initFromRequest($request);
+
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(trim($request['answer_0'])),
+ 'score' => 1
+ ];
+
+ $this->task['template'] = trim($request['answer_default']);
+ $this->task['compare'] = $request['compare'];
+
+ if ($request['layout']) {
+ $this->task['layout'] = $request['layout'];
+ }
+
+ if ($request['layout'] === 'markup') {
+ $this->task['template'] = Studip\Markup::purifyHtml($this->task['template']);
+ }
+
+ if ($request['file_upload'] || $request['layout'] === 'none') {
+ $this->options['file_upload'] = 1;
+ }
+ }
+
+ /**
+ * Exercise handler to be called when a solution is corrected.
+ */
+ public function correctSolutionAction(Trails\Controller $controller, VipsSolution $solution): void
+ {
+ $commented_solution = Request::get('commented_solution');
+
+ if (isset($commented_solution)) {
+ $solution->commented_solution = Studip\Markup::purifyHtml(trim($commented_solution));
+ } else {
+ $solution->commented_solution = null;
+ }
+
+ if (Request::submitted('delete_commented_solution')) {
+ $solution->commented_solution = null;
+ $solution->store();
+
+ PageLayout::postSuccess(_('Die kommentierte Lösung wurde gelöscht.'));
+ }
+ }
+
+ /**
+ * Return the layout of this task (text, markup, code or none).
+ */
+ public function getLayout(): string
+ {
+ return $this->task['layout'] ?? 'text';
+ }
+
+ /**
+ * Evaluates a student's solution for the individual items in this
+ * exercise. Returns an array of ('points' => float, 'safe' => boolean).
+ *
+ * @param mixed $solution The solution XML string as returned by responseFromRequest().
+ */
+ public function evaluateItems($solution): array
+ {
+ $result = [];
+
+ $answerDefault = Studip\Markup::removeHtml($this->task['template']);
+ $musterLoesung = Studip\Markup::removeHtml($this->task['answers'][0]['text']);
+ $studentSolution = Studip\Markup::removeHtml($solution->response[0]);
+
+ $answerDefault = $this->normalizeText($answerDefault, true);
+ $studentSolution = $this->normalizeText($studentSolution, true);
+ $musterLoesung = $this->normalizeText($musterLoesung, true);
+
+ if ($studentSolution == '' || $studentSolution == $answerDefault) {
+ $has_files = $solution->folder && count($solution->folder->file_refs);
+ $result[] = ['points' => 0, 'safe' => !$has_files ? true : null];
+ } else if ($musterLoesung == $studentSolution) {
+ $result[] = ['points' => 1, 'safe' => true];
+ } else if ($this->task['compare'] === 'levenshtein') {
+ $string1 = mb_substr($studentSolution, 0, 500);
+ $string2 = mb_substr($musterLoesung, 0, 500);
+ $string3 = mb_substr($answerDefault, 0, 500);
+ $divisor = $this->levenshtein($string3, $string2) ?: 1;
+
+ $levenshtein = $this->levenshtein($string1, $string2) / $divisor;
+ $similarity = max(1 - $levenshtein, 0);
+ $result[] = ['points' => $similarity, 'safe' => false];
+ } else {
+ $result[] = ['points' => 0, 'safe' => null];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the default response when there is no existing solution.
+ */
+ public function defaultResponse(): array
+ {
+ return [$this->task['template']];
+ }
+
+ /**
+ * Return the solution of the student from the request POST data.
+ *
+ * @param array $request array containing the postdata for the solution.
+ * @return array containing the solutions of the student.
+ */
+ public function responseFromRequest(array|ArrayAccess $request): array
+ {
+ $result = parent::responseFromRequest($request);
+
+ if ($this->getLayout() === 'markup') {
+ $result = array_map('Studip\Markup::purifyHtml', $result);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Construct a new solution object from the request post data.
+ */
+ public function getSolutionFromRequest($request, ?array $files = null): VipsSolution
+ {
+ $solution = parent::getSolutionFromRequest($request, $files);
+ $upload = $files['upload'] ?: ['name' => []];
+ $solution_files = [];
+
+ if ($this->options['file_upload']) {
+ if ($files['upload']) {
+ $solution->options['upload'] = $files['upload'];
+ }
+
+ $solution->store();
+ $folder = Folder::findTopFolder($solution->id, 'ResponseFolder', 'response');
+
+ if (is_array($request['file_ids'])) {
+ foreach ($request['file_ids'] as $file_id) {
+ $file_ref = FileRef::find($file_id);
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent());
+ }
+ }
+
+ FileManager::handleFileUpload($upload, $folder->getTypedFolder());
+ }
+
+ return $solution;
+ }
+
+ /**
+ * Return the list of keywords used for text export. The first keyword
+ * in the list must be the keyword for the exercise type.
+ */
+ public static function getTextKeywords(): array
+ {
+ return ['Offene Frage', 'Eingabehilfe', 'Abgleich', 'Vorgabe', 'Antwort'];
+ }
+
+ /**
+ * Initialize this instance from the given text data array.
+ */
+ public function initText(array $exercise): void
+ {
+ parent::initText($exercise);
+
+ foreach ($exercise as $tag) {
+ if (key($tag) === 'Abgleich') {
+ if (current($tag) === 'Levenshtein') {
+ $this->task['compare'] = 'levenshtein';
+ }
+ }
+
+ if (key($tag) === 'Vorgabe') {
+ $this->task['template'] = Studip\Markup::purifyHtml(current($tag));
+ }
+
+ if (key($tag) === 'Antwort') {
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(current($tag)),
+ 'score' => 1
+ ];
+ }
+ }
+ }
+
+ /**
+ * Initialize this instance from the given SimpleXMLElement object.
+ */
+ public function initXML($exercise): void
+ {
+ parent::initXML($exercise);
+
+ foreach ($exercise->items->item->answers->answer as $answer) {
+ if ($answer['score'] == '1') {
+ $this->task['answers'][0] = [
+ 'text' => Studip\Markup::purifyHtml(trim($answer)),
+ 'score' => 1
+ ];
+ } else if ($answer['default'] == 'true') {
+ $this->task['template'] = Studip\Markup::purifyHtml(trim($answer));
+ }
+ }
+
+ if ($exercise->items->item->{'evaluation-hints'}) {
+ switch ($exercise->items->item->{'evaluation-hints'}->similarity['type']) {
+ case 'levenshtein':
+ $this->task['compare'] = 'levenshtein';
+ }
+ }
+
+ if ($exercise->items->item->{'submission-hints'}->input) {
+ switch ($exercise->items->item->{'submission-hints'}->input['type']) {
+ case 'markup':
+ $this->task['layout'] = 'markup';
+ break;
+ case 'code':
+ $this->task['layout'] = 'code';
+ break;
+ case 'none':
+ $this->task['layout'] = 'none';
+ }
+ }
+
+ if ($exercise->items->item->{'submission-hints'}->attachments) {
+ if ($exercise->items->item->{'submission-hints'}->attachments['upload'] == 'true') {
+ $this->options['file_upload'] = 1;
+ }
+ }
+ }
+}
diff --git a/lib/models/vips/VipsAssignment.php b/lib/models/vips/VipsAssignment.php
new file mode 100644
index 0000000..d73d62a
--- /dev/null
+++ b/lib/models/vips/VipsAssignment.php
@@ -0,0 +1,1308 @@
+<?php
+/*
+ * VipsAssignment.php - Vips test class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property int $test_id database column
+ * @property string|null $range_type database column
+ * @property string|null $range_id database column
+ * @property string $type database column
+ * @property int|null $start database column
+ * @property int|null $end database column
+ * @property int $active database column
+ * @property float $weight database column
+ * @property int|null $block_id database column
+ * @property JSONArrayObject $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property SimpleORMapCollection|VipsAssignmentAttempt[] $assignment_attempts has_many VipsAssignmentAttempt
+ * @property SimpleORMapCollection|VipsSolution[] $solutions has_many VipsSolution
+ * @property Course|null $course belongs_to Course
+ * @property VipsBlock|null $block belongs_to VipsBlock
+ * @property VipsTest $test belongs_to VipsTest
+ */
+class VipsAssignment extends SimpleORMap
+{
+ public const RELEASE_STATUS_NONE = 0;
+ public const RELEASE_STATUS_POINTS = 1;
+ public const RELEASE_STATUS_COMMENTS = 2;
+ public const RELEASE_STATUS_CORRECTIONS = 3;
+ public const RELEASE_STATUS_SAMPLE_SOLUTIONS = 4;
+
+ public const SCORING_DEFAULT = 0;
+ public const SCORING_NEGATIVE_POINTS = 1;
+ public const SCORING_ALL_OR_NOTHING = 2;
+
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_assignments';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['has_many']['assignment_attempts'] = [
+ 'class_name' => VipsAssignmentAttempt::class,
+ 'assoc_foreign_key' => 'assignment_id'
+ ];
+ $config['has_many']['solutions'] = [
+ 'class_name' => VipsSolution::class,
+ 'assoc_foreign_key' => 'assignment_id'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+ $config['belongs_to']['block'] = [
+ 'class_name' => VipsBlock::class,
+ 'foreign_key' => 'block_id'
+ ];
+ $config['belongs_to']['test'] = [
+ 'class_name' => VipsTest::class,
+ 'foreign_key' => 'test_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Initialize a new instance of this class.
+ */
+ public function __construct($id = null)
+ {
+ parent::__construct($id);
+
+ if (is_null($this->options)) {
+ $this->options = [];
+ }
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ Grading\Definition::deleteBySQL('id = ?', [$gradebook_id]);
+ }
+
+ VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$this->id]);
+
+ $ref_count = self::countBySql('test_id = ?', [$this->test_id]);
+
+ if ($ref_count === 1) {
+ $this->test->delete();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Find all assignments for a given range_id.
+ *
+ * @return VipsAssignment[]
+ */
+ public static function findByRangeId($range_id)
+ {
+ return VipsAssignment::findBySQL(
+ 'range_id = ? AND type IN (?) ORDER BY start',
+ [$range_id, ['exam', 'practice', 'selftest']]
+ );
+ }
+
+ public static function importText(
+ string $title,
+ string $string,
+ string $user_id,
+ string $course_id
+ ): VipsAssignment {
+ $duration = 7 * 24 * 60 * 60; // one week
+
+ $data_test = [
+ 'title' => $title !== '' ? $title : _('Aufgabenblatt'),
+ 'description' => '',
+ 'user_id' => $user_id
+ ];
+ $data = [
+ 'type' => 'practice',
+ 'range_id' => $course_id ?: $user_id,
+ 'range_type' => $course_id ? 'course' : 'user',
+ 'start' => strtotime(date('Y-m-d H:00:00')),
+ 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration))
+ ];
+
+ // remove comments
+ $string = preg_replace('/^#.*/m', '', $string);
+
+ // split into exercises
+ $segments = preg_split('/^Name:/m', $string);
+ array_shift($segments);
+
+ $test_obj = VipsTest::create($data_test);
+
+ $result = self::build($data);
+ $result->test = $test_obj;
+ $result->store();
+
+ foreach ($segments as $segment) {
+ try {
+ $new_exercise = Exercise::importText($segment);
+ $new_exercise->user_id = $user_id;
+ $new_exercise->store();
+ $test_obj->addExercise($new_exercise);
+ } catch (Exception $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (isset($errors)) {
+ PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
+ }
+
+ return $result;
+ }
+
+ public static function importXML(
+ string $string,
+ string $user_id,
+ string $course_id
+ ): VipsAssignment {
+ // default options
+ $options = [
+ 'evaluation_mode' => 0,
+ 'released' => 0
+ ];
+
+ $duration = 7 * 24 * 60 * 60; // one week
+
+ $data_test = [
+ 'title' => _('Aufgabenblatt'),
+ 'description' => '',
+ 'user_id' => $user_id
+ ];
+ $data = [
+ 'type' => 'practice',
+ 'range_id' => $course_id ?: $user_id,
+ 'range_type' => $course_id ? 'course' : 'user',
+ 'start' => strtotime(date('Y-m-d H:00:00')),
+ 'end' => strtotime(date('Y-m-d H:00:00', time() + $duration)),
+ 'options' => $options
+ ];
+
+ $test = new SimpleXMLElement($string, LIBXML_COMPACT | LIBXML_NOCDATA);
+ $data['type'] = (string) $test['type'];
+
+ if (trim($test->title) !== '') {
+ $data_test['title'] = trim($test->title);
+ }
+ if ($test->description) {
+ $data_test['description'] = Studip\Markup::purifyHtml(trim($test->description));
+ }
+ if ($test->notes) {
+ $data['options']['notes'] = trim($test->notes);
+ }
+
+ if ($test->limit['access-code']) {
+ $data['options']['access_code'] = (string) $test->limit['access-code'];
+ }
+ if ($test->limit['ip-ranges']) {
+ $data['options']['ip_range'] = (string) $test->limit['ip-ranges'];
+ }
+ if ($test->limit['resets']) {
+ $data['options']['resets'] = (int) $test->limit['resets'];
+ }
+ if ($test->limit['tries']) {
+ $data['options']['max_tries'] = (int) $test->limit['tries'];
+ }
+
+ if ($test->option['scoring-mode'] == 'negative_points') {
+ $data['options']['evaluation_mode'] = self::SCORING_NEGATIVE_POINTS;
+ } else if ($test->option['scoring-mode'] == 'all_or_nothing') {
+ $data['options']['evaluation_mode'] = self::SCORING_ALL_OR_NOTHING;
+ }
+ if ($test->option['shuffle-answers'] == 'true') {
+ $data['options']['shuffle_answers'] = 1;
+ }
+ if ($test->option['shuffle-exercises'] == 'true') {
+ $data['options']['shuffle_exercises'] = 1;
+ }
+
+ if ($test['start']) {
+ $data['start'] = strtotime($test['start']);
+ }
+ if ($test['end']) {
+ $data['end'] = strtotime($test['end']);
+ } else if ($data['type'] === 'selftest') {
+ $data['end'] = null;
+ }
+ if ($test['duration']) {
+ $data['options']['duration'] = (int) $test['duration'];
+ }
+ if ($test['block'] && $course_id) {
+ $block = VipsBlock::findOneBySQL('name = ? AND range_id = ?', [$test['block'], $course_id]);
+
+ if (!$block) {
+ $block = VipsBlock::create(['name' => $test['block'], 'range_id' => $course_id]);
+ }
+
+ $data['block_id'] = $block->id;
+ }
+
+ if ($test->{'feedback-items'}) {
+ foreach ($test->{'feedback-items'}->feedback as $feedback) {
+ $threshold = (int) ($feedback['score'] * 100);
+ $data['options']['feedback'][$threshold] = Studip\Markup::purifyHtml(trim($feedback));
+ }
+
+ krsort($data['options']['feedback']);
+ }
+
+ $test_obj = VipsTest::create($data_test);
+
+ $result = self::build($data);
+ $result->test = $test_obj;
+ $result->store();
+
+ if ($test->files) {
+ foreach ($test->files->file as $file) {
+ $file_id = (string) $file['id'];
+ $content = base64_decode((string) $file);
+
+ $test->registerXPathNamespace('vips', 'urn:vips:test:v1.0');
+ $file_refs = $test->xpath('vips:exercises/*/vips:file-refs/*[@ref="' . $file_id . '"]');
+
+ if ($file_refs && $content !== false) {
+ if (strlen($file_id) > 5 && str_starts_with($file_id, 'file-')) {
+ $vips_file = File::find(substr($file_id, 5));
+
+ // try to avoid reupload of identical files
+ if ($vips_file && sha1_file($vips_file->getPath()) === sha1($content)) {
+ $files[$file_id] = $vips_file;
+ continue;
+ }
+ }
+
+ $file = File::create([
+ 'user_id' => $user_id,
+ 'mime_type' => get_mime_type($file['name']),
+ 'name' => basename($file['name']),
+ 'size' => strlen($content)
+ ]);
+
+ file_put_contents($file->getPath(), $content);
+ }
+ }
+
+ if (isset($files)) {
+ $mapped = preg_replace_callback(
+ '/\burn:vips:file-ref:([A-Za-z_][\w.-]*)/',
+ function($match) use ($files) {
+ $file = $files[$match[1]];
+
+ if ($file) {
+ return htmlReady($file->getDownloadURL());
+ } else {
+ return $match[0];
+ }
+ }, $string
+ );
+ $test = new SimpleXMLElement($mapped, LIBXML_COMPACT | LIBXML_NOCDATA);
+ }
+ }
+
+ foreach ($test->exercises->exercise as $exercise) {
+ try {
+ $new_exercise = Exercise::importXML($exercise);
+ $new_exercise->user_id = $user_id;
+ $new_exercise->store();
+ $exercise_ref = $test_obj->addExercise($new_exercise);
+
+ if ($exercise['points']) {
+ $exercise_ref->points = (float) $exercise['points'];
+ $exercise_ref->store();
+ }
+
+ if ($exercise->{'file-refs'}) {
+ $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task');
+
+ foreach ($exercise->{'file-refs'}->{'file-ref'} as $file_ref) {
+ $file = $files[(string) $file_ref['ref']];
+
+ if ($file) {
+ FileRef::create([
+ 'file_id' => $file->id,
+ 'folder_id' => $folder->id,
+ 'object_id' => $new_exercise->id,
+ 'user_id' => $user_id,
+ 'name' => $file->name
+ ]);
+ }
+ }
+ }
+ } catch (Exception $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (isset($errors)) {
+ PageLayout::postError(_('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get the name of this assignment type.
+ */
+ public function getTypeName(): string
+ {
+ $assignment_types = self::getAssignmentTypes();
+
+ return $assignment_types[$this->type]['name'];
+ }
+
+ /**
+ * Get the icon of this assignment type.
+ */
+ public function getTypeIcon(string $role = Icon::DEFAULT_ROLE): Icon
+ {
+ $assignment_types = self::getAssignmentTypes();
+
+ return Icon::create(
+ $assignment_types[$this->type]['icon'],
+ $role,
+ ['aria-hidden' => 'true', 'title' => $assignment_types[$this->type]['name']]
+ );
+ }
+
+ /**
+ * Get the list of supported assignment types.
+ */
+ public static function getAssignmentTypes(): array
+ {
+ return [
+ 'practice' => ['name' => _('Übung'), 'icon' => 'file'],
+ 'selftest' => ['name' => _('Selbsttest'), 'icon' => 'check-circle'],
+ 'exam' => ['name' => _('Klausur'), 'icon' => 'doctoral_cap']
+ ];
+ }
+
+ /**
+ * Check if this assignment is locked for editing.
+ */
+ public function isLocked(): bool
+ {
+ return $this->type === 'exam' && $this->countAssignmentAttempts() > 0;
+ }
+
+ /**
+ * Check if this assignment is visible to this user.
+ */
+ public function isVisible(string $user_id): bool
+ {
+ return $this->block_id ? $this->block->isVisible($user_id) : true;
+ }
+
+ /**
+ * Check if this assignment has been started.
+ */
+ public function isStarted(): bool
+ {
+ $now = time();
+
+ return $now >= $this->start;
+ }
+
+ /**
+ * Check if this assignment is currently running.
+ *
+ * @param string|null $user_id check end time for this user id (optional)
+ */
+ public function isRunning(?string $user_id = null): bool
+ {
+ $now = time();
+ $end = $user_id ? $this->getUserEndTime($user_id) : $this->end;
+
+ return $now >= $this->start && ($end === null || $now <= $end);
+ }
+
+ /**
+ * Check if this assignment is already finished.
+ *
+ * @param string|null $user_id check end time for this user id (optional)
+ */
+ public function isFinished(?string $user_id = null): bool
+ {
+ $now = time();
+ $end = $user_id ? $this->getUserEndTime($user_id) : $this->end;
+
+ return $end && $now > $end;
+ }
+
+ /**
+ * Check if this assignment has no end date.
+ */
+ public function isUnlimited(): bool
+ {
+ return $this->type === 'selftest' && $this->end === null;
+ }
+
+ /**
+ * Check if this assignment may use self assessment features.
+ */
+ public function isSelfAssessment(): bool
+ {
+ return $this->type === 'selftest' || $this->options['self_assessment'];
+ }
+
+ /**
+ * Check if a user may reset and restart this assignment.
+ */
+ public function isResetAllowed(): bool
+ {
+ return $this->isSelfAssessment() && $this->options['resets'] !== 0;
+ }
+
+ /**
+ * Check if this assignment presents shuffled exercises.
+ */
+ public function isExerciseShuffled(): bool
+ {
+ return $this->type === 'exam' && $this->options['shuffle_exercises'];
+ }
+
+ /**
+ * Check if this assignment presents shuffled answers.
+ */
+ public function isShuffled(): bool
+ {
+ return $this->type === 'exam' && $this->options['shuffle_answers'] !== 0;
+ }
+
+ /**
+ * Check if this assignment is using group solutions.
+ */
+ public function hasGroupSolutions(): bool
+ {
+ return $this->type === 'practice' && $this->options['use_groups'] !== 0;
+ }
+
+ /**
+ * Get the number of tries allowed for exercises on this assignment.
+ */
+ public function getMaxTries(): int
+ {
+ if ($this->type === 'selftest') {
+ return $this->options['max_tries'] ?? 3;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Check whether the given exercise is part of this assignment.
+ *
+ * @param int $exercise_id exercise id
+ */
+ public function hasExercise(int $exercise_id): bool
+ {
+ return VipsExerciseRef::exists([$this->test_id, $exercise_id]);
+ }
+
+ /**
+ * Return array of exercise refs in the test of this assignment.
+ */
+ public function getExerciseRefs(?string $user_id): array
+ {
+ $result = $this->test->exercise_refs->getArrayCopy();
+
+ if ($this->isExerciseShuffled() && $user_id) {
+ srand(crc32($this->id . ':' . $user_id));
+ shuffle($result);
+ srand();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Export this assignment to XML format. Returns the XML string.
+ */
+ public function exportXML(): string
+ {
+ $files = [];
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $exercise = $exercise_ref->exercise;
+ $exercise->includeFilesForExport();
+
+ if ($exercise->folder) {
+ foreach ($exercise->folder->file_refs as $file_ref) {
+ $files[$file_ref->file_id] = $file_ref->file;
+ }
+ }
+ }
+
+ $template = VipsModule::$template_factory->open('sheets/export_assignment');
+ $template->assignment = $this;
+ $template->files = $files;
+
+ // delete all characters outside the valid character range for XML
+ // documents (#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]).
+ return preg_replace("/[^\t\n\r -\xFF]/", '', $template->render());
+ }
+
+ /**
+ * Check whether this assignment is editable by the given user.
+ *
+ * @param string|null $user_id user to check (defaults to current user)
+ */
+ public function checkEditPermission(?string $user_id = null): bool
+ {
+ if ($this->range_type === 'user') {
+ return $this->range_id === ($user_id ?: $GLOBALS['user']->id);
+ }
+
+ return $GLOBALS['perm']->have_studip_perm('tutor', $this->range_id, $user_id);
+ }
+
+ /**
+ * Check whether this assignment is viewable by the given user.
+ *
+ * @param string|null $user_id user to check (defaults to current user)
+ */
+ public function checkViewPermission(?string $user_id = null): bool
+ {
+ if ($this->range_type === 'user') {
+ return $this->range_id === ($user_id ?: $GLOBALS['user']->id);
+ }
+
+ return $GLOBALS['perm']->have_studip_perm('autor', $this->range_id, $user_id);
+ }
+
+ /**
+ * Check whether this assignment is accessible to a student. This is just
+ * a shortcut for checking: running, active, ip address and access code.
+ *
+ * @param string $user_id check end time for this user id (optional)
+ */
+ public function checkAccess($user_id = null): bool
+ {
+ return $this->isRunning($user_id)
+ && $this->active && $this->checkAccessCode()
+ && $this->checkIPAccess($_SERVER['REMOTE_ADDR']);
+ }
+
+ /**
+ * Check whether the access code provided for this assignment is valid.
+ * If $access_code is null, the code stored in the user session is used.
+ *
+ * @param string|null $access_code access code (optional)
+ */
+ public function checkAccessCode(?string $access_code = null): bool
+ {
+ if (isset($access_code)) {
+ $_SESSION['vips_access_' . $this->id] = $access_code;
+ } else if (isset($_SESSION['vips_access_' . $this->id])) {
+ $access_code = $_SESSION['vips_access_' . $this->id];
+ } else {
+ $access_code = null;
+ }
+
+ return in_array($this->options['access_code'], [null, $access_code], true);
+ }
+
+ /**
+ * Check whether the given IP address listed among the IP addresses given
+ * by the lecturer for this exam (if applicable).
+ *
+ * @param string $ip_addr IPv4 or IPv6 address
+ */
+ public function checkIPAccess(string $ip_addr): bool
+ {
+ // not an exam: user has access.
+ if ($this->type !== 'exam') {
+ return true;
+ }
+
+ $ip_addr = inet_pton($ip_addr);
+ $ip_ranges = $this->options['ip_range'];
+ $exam_rooms = Config::get()->VIPS_EXAM_ROOMS;
+
+ // expand exam room names
+ if ($exam_rooms) {
+ $ip_ranges = preg_replace_callback('/#([^ ,]+)/',
+ function($match) use ($exam_rooms) {
+ return $exam_rooms[$match[1]];
+ }, $ip_ranges);
+ }
+
+ // Explode space separated list into an array and check the resulting single IPs
+ $ip_ranges = preg_split('/[ ,]+/', $ip_ranges, -1, PREG_SPLIT_NO_EMPTY);
+
+ // No IP given: user has access.
+ if (count($ip_ranges) == 0) {
+ return true;
+ }
+
+ // One or more IPs are given and user IP matches at least one: user has access.
+ foreach ($ip_ranges as $ip_range) {
+ if (str_contains($ip_range, '/')) {
+ [$ip_range, $bits] = explode('/', $ip_range);
+ $ip_range = inet_pton($ip_range) ?: '';
+ $mask = str_repeat(chr(0), strlen($ip_range));
+
+ for ($i = 0; $i < strlen($mask); ++$i) {
+ if ($bits >= 8) {
+ $bits -= 8;
+ } else {
+ $mask[$i] = chr((1 << 8 - $bits) - 1);
+ $bits = 0;
+ }
+ }
+
+ $ip_start = $ip_range & ~$mask;
+ $ip_end = $ip_range | $mask;
+ } else {
+ if (str_contains($ip_range, '-')) {
+ [$ip_start, $ip_end] = explode('-', $ip_range);
+ } else {
+ $ip_start = $ip_end = $ip_range;
+ }
+
+ if (!str_contains($ip_range, ':')) {
+ $ip_start = implode('.', array_pad(explode('.', $ip_start), 4, 0));
+ $ip_end = implode('.', array_pad(explode('.', $ip_end), 4, 255));
+ }
+
+ $ip_start = inet_pton($ip_start);
+ $ip_end = inet_pton($ip_end);
+ }
+
+ if (strcmp($ip_start, $ip_addr) <= 0 && strcmp($ip_addr, $ip_end) <= 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the release status of this assignment for the given user.
+ *
+ * Valid values are:
+ * - 0 = not released
+ * - 1 = points
+ * - 2 = comments
+ * - 3 = corrections
+ * - 4 = sample solutions
+ *
+ * See the according constants of this class.
+ */
+ public function releaseStatus(string $user_id): int
+ {
+ if ($this->isFinished() || $this->isSelfAssessment() && $this->isFinished($user_id)) {
+ if ($this->type === 'exam') {
+ if ($this->getAssignmentAttempt($user_id)) {
+ return $this->options['released'] ?? self::RELEASE_STATUS_NONE;
+ }
+ } else {
+ if ($this->options['released'] > 0) {
+ return $this->options['released'];
+ }
+ }
+ }
+
+ return self::RELEASE_STATUS_NONE;
+ }
+
+ /**
+ * Count the number of assignment attempts for this assignment.
+ */
+ public function countAssignmentAttempts(): int
+ {
+ return VipsAssignmentAttempt::countBySql('assignment_id = ?', [$this->id]);
+ }
+
+ /**
+ * Get the assignment attempt of the given user for this assignment.
+ * Returns null if there is no assignment attempt for this user.
+ *
+ * @param string $user_id user id
+ */
+ public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ return VipsAssignmentAttempt::findOneBySQL('assignment_id = ? AND user_id = ?', [$this->id, $user_id]);
+ }
+
+ /**
+ * Record an assignment attempt for the given user for this assignment.
+ */
+ public function recordAssignmentAttempt(string $user_id): void
+ {
+ if (!$this->getAssignmentAttempt($user_id)) {
+ if ($this->type === 'exam') {
+ $end = time() + $this->options['duration'] * 60;
+ $ip_address = $_SERVER['REMOTE_ADDR'];
+ $options = ['session_id' => session_id()];
+ } else {
+ $end = null;
+ $ip_address = '';
+ $options = null;
+ }
+
+ VipsAssignmentAttempt::create([
+ 'assignment_id' => $this->id,
+ 'user_id' => $user_id,
+ 'start' => time(),
+ 'end' => $end,
+ 'ip_address' => $ip_address,
+ 'options' => $options
+ ]);
+ }
+ }
+
+ /**
+ * Finish an assignment attempt for the given user for this assignment.
+ */
+ public function finishAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ $assignment_attempt = $this->getAssignmentAttempt($user_id);
+ $now = time();
+
+ if ($assignment_attempt) {
+ if ($assignment_attempt->end === null || $assignment_attempt->end > $now) {
+ $assignment_attempt->end = $now;
+ $assignment_attempt->store();
+ }
+ }
+
+ return $assignment_attempt;
+ }
+
+ /**
+ * Get the individual end time of the given user for this assignment.
+ */
+ public function getUserEndTime(string $user_id): ?int
+ {
+ if ($this->type === 'practice') {
+ return $this->end;
+ }
+
+ $assignment_attempt = $this->getAssignmentAttempt($user_id);
+
+ if ($assignment_attempt) {
+ $start = $assignment_attempt->start;
+ } else {
+ $start = time();
+ }
+
+ if ($assignment_attempt && $assignment_attempt->end) {
+ return min($assignment_attempt->end, $this->end ?: $assignment_attempt->end);
+ } else if ($this->type === 'exam') {
+ return min($start + $this->options['duration'] * 60, $this->end);
+ } else {
+ return $this->end;
+ }
+ }
+
+ /**
+ * Get all members that were assigned to a particular group for
+ * this assignment.
+ *
+ * @param VipsGroup $group The group object
+ * @return VipsGroupMember[]
+ */
+ public function getGroupMembers($group): array
+ {
+ return VipsGroupMember::findBySQL(
+ 'group_id = ? AND start < ? AND (end > ? OR end IS NULL)',
+ [$group->id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Get the group the user was assigned to for this assignment.
+ * Returns null if there is no group assignment for this user.
+ */
+ public function getUserGroup(string $user_id): ?VipsGroup
+ {
+ if (!$this->hasGroupSolutions()) {
+ return null;
+ }
+
+ return VipsGroup::findOneBySQL(
+ 'JOIN etask_group_members ON group_id = statusgruppe_id
+ WHERE range_id = ?
+ AND user_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)',
+ [$this->range_id, $user_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Store a solution related to this assignment into the database.
+ *
+ * @param VipsSolution $solution The solution object
+ */
+ public function storeSolution(VipsSolution $solution): bool|int
+ {
+ $solution->assignment = $this;
+
+ // store some client info for exams
+ if ($this->type === 'exam') {
+ $solution->ip_address = $_SERVER['REMOTE_ADDR'];
+ $solution->options['session_id'] = session_id();
+ }
+
+ // in selftests, autocorrect solution
+ if ($this->isSelfAssessment()) {
+ $this->correctSolution($solution);
+ }
+
+ // insert new solution into etask_responses
+ return $solution->store();
+ }
+
+ /**
+ * Correct a solution and store the points for the solution in the object.
+ *
+ * @param VipsSolution $solution The solution object
+ * @param bool $corrected mark solution as corrected
+ */
+ public function correctSolution(VipsSolution $solution, bool $corrected = false): void
+ {
+ $exercise = $solution->exercise;
+ $exercise_ref = $this->test->getExerciseRef($exercise->id);
+ $max_points = (float) $exercise_ref->points;
+
+ // always set corrected to true for selftest exercises
+ $selftest = $this->type === 'selftest';
+ $evaluation = $exercise->evaluate($solution);
+ $eval_safe = $selftest ? $evaluation['safe'] !== null : $evaluation['safe'];
+
+ $reached_points = round($evaluation['percent'] * $max_points * 2) / 2;
+ $corrected = (int) ($corrected || $eval_safe);
+
+ // insert solution points
+ $solution->state = $corrected;
+ $solution->points = $reached_points;
+ $solution->chdate = time();
+
+ if ($selftest && $evaluation['percent'] != 1 && isset($exercise->options['feedback'])) {
+ $solution->feedback = $exercise->options['feedback'];
+ }
+ }
+
+ /**
+ * Restores an archived solution as the current solution.
+ *
+ * @param VipsSolution $solution The solution object
+ */
+ public function restoreSolution(VipsSolution $solution): void
+ {
+ if ($solution->isArchived() && $solution->assignment_id == $this->id) {
+ $new_solution = VipsSolution::build($solution);
+ $new_solution->id = 0;
+
+ if ($solution->folder) {
+ $new_solution->store();
+ $folder = Folder::findTopFolder($new_solution->id, 'ResponseFolder', 'response');
+
+ foreach ($solution->folder->file_refs as $file_ref) {
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), $file_ref->user);
+ }
+ }
+
+ $this->storeSolution($new_solution);
+ }
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedGroupSolutions(string $group_id, int $exercise_id): array
+ {
+ return VipsSolution::findBySQL(
+ 'JOIN etask_group_members USING(user_id)
+ WHERE task_id = ?
+ AND assignment_id = ?
+ AND group_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)
+ ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $group_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * NOTE: This method will NOT check the group solutions, if applicable.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedUserSolutions(string $user_id, int $exercise_id): array
+ {
+ return VipsSolution::findBySQL(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $user_id]
+ );
+ }
+
+ /**
+ * Fetch archived solutions related to this assignment from the database.
+ * Returns empty list if there are no archived solutions for this exercise.
+ *
+ * @return VipsSolution[]
+ */
+ public function getArchivedSolutions(string $user_id, int $exercise_id): array
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ return $this->getArchivedGroupSolutions($group->id, $exercise_id);
+ }
+
+ return $this->getArchivedUserSolutions($user_id, $exercise_id);
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getGroupSolution(string $group_id, int $exercise_id): ?VipsSolution
+ {
+ return VipsSolution::findOneBySQL(
+ 'JOIN etask_group_members USING(user_id)
+ WHERE task_id = ?
+ AND assignment_id = ?
+ AND group_id = ?
+ AND start < ?
+ AND (end > ? OR end IS NULL)
+ ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $group_id, $this->end, $this->end]
+ );
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * NOTE: This method will NOT check the group solution, if applicable.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getUserSolution(string $user_id, int $exercise_id): ?VipsSolution
+ {
+ return VipsSolution::findOneBySQL(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY mkdate DESC',
+ [$exercise_id, $this->id, $user_id]
+ );
+ }
+
+ /**
+ * Fetch a solution related to this assignment from the database.
+ * Returns null if there is no solution for this exercise yet.
+ */
+ public function getSolution(string $user_id, int $exercise_id): ?VipsSolution
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ return $this->getGroupSolution($group->id, $exercise_id);
+ }
+
+ return $this->getUserSolution($user_id, $exercise_id);
+ }
+
+ /**
+ * Delete all solutions of the given user for a single exercise of
+ * this test from the DB.
+ */
+ public function deleteSolution(string $user_id, int $exercise_id): void
+ {
+ $sql = 'task_id = ? AND assignment_id = ? AND user_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$exercise_id, $this->id, $user_id]);
+ }
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries($user_id);
+ }
+
+ /**
+ * Delete all solutions of the given user for this test from the DB.
+ */
+ public function deleteSolutions(string $user_id): void
+ {
+ $sql = 'assignment_id = ? AND user_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$this->id, $user_id]);
+ }
+
+ // delete start times
+ VipsAssignmentAttempt::deleteBySQL($sql, [$this->id, $user_id]);
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries($user_id);
+ }
+
+ /**
+ * Delete all solutions of all users for this test from the DB.
+ */
+ public function deleteAllSolutions(): void
+ {
+ $sql = 'assignment_id = ?';
+
+ if ($this->isSelfAssessment()) {
+ // delete in etask_responses
+ VipsSolution::deleteBySQL($sql, [$this->id]);
+ }
+
+ // delete start times
+ VipsAssignmentAttempt::deleteBySQL($sql, [$this->id]);
+
+ // update gradebook if necessary
+ $this->updateGradebookEntries();
+ }
+
+ /**
+ * Count the number of solutions of the given user for this test.
+ */
+ public function countSolutions(string $user_id): int
+ {
+ $solutions = 0;
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ if ($this->getSolution($user_id, $exercise_ref->task_id)) {
+ ++$solutions;
+ }
+ }
+
+ return $solutions;
+ }
+
+ /**
+ * Return the points a user has reached in all exercises in this assignment.
+ */
+ public function getUserPoints(string $user_id): float|int
+ {
+ $group = $this->getUserGroup($user_id);
+
+ if ($group) {
+ $user_ids = array_column($this->getGroupMembers($group), 'user_id');
+ } else {
+ $user_ids = [$user_id];
+ }
+
+ $solutions = $this->solutions->findBy('user_id', $user_ids)->orderBy('mkdate');
+ $points = [];
+
+ foreach ($solutions as $solution) {
+ $points[$solution->task_id] = (float) $solution->points;
+ }
+
+ return max(array_sum($points), 0);
+ }
+
+ /**
+ * Return the progress a user has achieved on this assignment (range 0..1).
+ */
+ public function getUserProgress(string $user_id): float|int
+ {
+ $group = $this->getUserGroup($user_id);
+ $max_points = 0;
+ $progress = 0;
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $max_points += $exercise_ref->points;
+
+ if ($group) {
+ $solution = $this->getGroupSolution($group->id, $exercise_ref->task_id);
+ } else {
+ $solution = $this->getUserSolution($user_id, $exercise_ref->task_id);
+ }
+
+ if ($solution) {
+ $progress += $exercise_ref->points;
+ }
+ }
+
+ return $max_points ? $progress / $max_points : 0;
+ }
+
+ /**
+ * Return the individual feedback text for the given user in this assignment.
+ */
+ public function getUserFeedback(string $user_id): ?string
+ {
+ if (isset($this->options['feedback'])) {
+ $user_points = $this->getUserPoints($user_id);
+ $max_points = $this->test->getTotalPoints();
+ $percent = $user_points / $max_points * 100;
+
+ foreach ($this->options['feedback'] as $threshold => $feedback) {
+ if ($percent >= $threshold) {
+ return $feedback;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Copy this assignment into the given course. Returns the new assignment.
+ */
+ public function copyIntoCourse(string $course_id, string $range_type = 'course'): ?VipsAssignment
+ {
+ // determine title of new assignment
+ if ($this->range_id === $course_id) {
+ $title = sprintf(_('Kopie von %s'), $this->test->title);
+ } else {
+ $title = $this->test->title;
+ }
+
+ // reset released option for new assignment
+ $options = $this->options;
+ unset($options['released']);
+ unset($options['stopdate']);
+ unset($options['gradebook_id']);
+
+ $new_test = VipsTest::create([
+ 'title' => $title,
+ 'description' => $this->test->description,
+ 'user_id' => $GLOBALS['user']->id
+ ]);
+
+ $new_assignment = VipsAssignment::create([
+ 'test_id' => $new_test->id,
+ 'range_id' => $course_id,
+ 'range_type' => $range_type,
+ 'type' => $this->type,
+ 'start' => $this->start,
+ 'end' => $this->end,
+ 'options' => $options
+ ]);
+
+ foreach ($this->test->exercise_refs as $exercise_ref) {
+ $exercise_ref->copyIntoTest($new_test->id, $exercise_ref->position);
+ }
+
+ return $new_assignment;
+ }
+
+ /**
+ * Move this assignment into the given course.
+ */
+ public function moveIntoCourse(string $course_id, string $range_type = 'course'): void
+ {
+ if ($this->range_id !== $course_id) {
+ $this->range_id = $course_id;
+ $this->range_type = $range_type;
+ $this->block_id = null;
+ $this->removeFromGradebook();
+ $this->store();
+ }
+ }
+
+ /**
+ * Insert this assignment into the gradebook of its course.
+ *
+ * @param string $title gradebook title
+ * @param float $weight gradebook weight
+ */
+ public function insertIntoGradebook(string $title, float $weight = 1): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if (!$gradebook_id) {
+ $definition = Grading\Definition::create([
+ 'course_id' => $this->range_id,
+ 'item' => $this->id,
+ 'name' => $title,
+ 'tool' => _('Aufgaben'),
+ 'category' => $this->getTypeName(),
+ 'position' => $this->start,
+ 'weight' => $weight
+ ]);
+
+ $this->options['gradebook_id'] = $definition->id;
+ $this->store();
+ }
+ }
+
+ /**
+ * Remove this assignment from the gradebook of its course.
+ */
+ public function removeFromGradebook(): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ Grading\Definition::find($gradebook_id)->delete();
+
+ unset($this->options['gradebook_id']);
+ $this->store();
+ }
+ }
+
+ /**
+ * Update some or all gradebook entries of this assignment. If the
+ * user_id is specified, only update entries related to this user.
+ *
+ * @param string|null $user_id user id
+ */
+ public function updateGradebookEntries(?string $user_id = null): void
+ {
+ $gradebook_id = $this->options['gradebook_id'];
+
+ if ($gradebook_id) {
+ $max_points = $this->test->getTotalPoints() ?: 1;
+
+ if ($user_id) {
+ $group = $this->getUserGroup($user_id);
+ }
+
+ if ($group) {
+ $members = $this->getGroupMembers($group);
+ } else if ($user_id) {
+ $members = [(object) compact('user_id')];
+ } else {
+ $members = $this->course->members->findBy('status', 'autor');
+ }
+
+ foreach ($members as $member) {
+ $reached_points = $this->getUserPoints($member->user_id);
+ $entry = new Grading\Instance([$gradebook_id, $member->user_id]);
+
+ if ($reached_points) {
+ $entry->rawgrade = $reached_points / $max_points;
+ $entry->store();
+ } else {
+ $entry->delete();
+ }
+ }
+ }
+ }
+}
diff --git a/lib/models/vips/VipsAssignmentAttempt.php b/lib/models/vips/VipsAssignmentAttempt.php
new file mode 100644
index 0000000..9eba371
--- /dev/null
+++ b/lib/models/vips/VipsAssignmentAttempt.php
@@ -0,0 +1,99 @@
+<?php
+/*
+ * VipsAssignmentAttempt.php - Vips test attempt class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property int $assignment_id database column
+ * @property string $user_id database column
+ * @property int|null $start database column
+ * @property int|null $end database column
+ * @property string $ip_address database column
+ * @property JSONArrayObject|null $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property VipsAssignment $assignment belongs_to VipsAssignment
+ * @property User $user belongs_to User
+ */
+class VipsAssignmentAttempt extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_assignment_attempts';
+
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['belongs_to']['assignment'] = [
+ 'class_name' => VipsAssignment::class,
+ 'foreign_key' => 'assignment_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Return a student's event log for the assignment as a data array.
+ */
+ public function getLogEntries(): array
+ {
+ $assignment = $this->assignment;
+ $user_id = $this->user_id;
+ $end_time = min($this->end, $assignment->end);
+
+ $solutions = VipsSolution::findBySQL('assignment_id = ? AND user_id = ?', [$assignment->id, $user_id]);
+
+ foreach ($assignment->test->exercise_refs as $exercise_ref) {
+ $position[$exercise_ref->task_id] = $exercise_ref->position;
+ }
+
+ $logs[] = [
+ 'label' => _('Beginn der Klausur'),
+ 'time' => $this->start,
+ 'ip_address' => $this->ip_address,
+ 'session_id' => $this->options['session_id'],
+ 'archived' => false
+ ];
+
+ foreach ($solutions as $solution) {
+ if ($solution->isSubmitted()) {
+ $logs[] = [
+ 'label' => sprintf(_('Abgabe Aufgabe %d'), $position[$solution->task_id]),
+ 'time' => $solution->mkdate,
+ 'ip_address' => $solution->ip_address,
+ 'session_id' => $solution->options['session_id'],
+ 'archived' => $solution->isArchived(),
+ ];
+ }
+ }
+
+ if ($end_time && $end_time < date('Y-m-d H:i:s')) {
+ $logs[] = [
+ 'label' => _('Ende der Klausur'),
+ 'time' => $end_time,
+ 'ip_address' => '',
+ 'session_id' => '',
+ 'archived' => false
+ ];
+ }
+
+ usort($logs, fn($a, $b) => $a['time'] <=> $b['time']);
+
+ return $logs;
+ }
+}
diff --git a/lib/models/vips/VipsBlock.php b/lib/models/vips/VipsBlock.php
new file mode 100644
index 0000000..2179254
--- /dev/null
+++ b/lib/models/vips/VipsBlock.php
@@ -0,0 +1,92 @@
+<?php
+/*
+ * VipsBlock.php - Vips block class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $name database column
+ * @property string $range_id database column
+ * @property string|null $group_id database column
+ * @property int $visible database column
+ * @property float|null $weight database column
+ * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment
+ * @property Course $course belongs_to Course
+ * @property Statusgruppen|null $group belongs_to Statusgruppen
+ */
+class VipsBlock extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_blocks';
+
+ $config['has_many']['assignments'] = [
+ 'class_name' => VipsAssignment::class,
+ 'assoc_foreign_key' => 'block_id'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+ $config['belongs_to']['group'] = [
+ 'class_name' => Statusgruppen::class,
+ 'foreign_key' => 'group_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ foreach ($this->assignments as $assignment) {
+ $assignment->block_id = null;
+ $assignment->store();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Check if this block is visible to this user.
+ */
+ public function isVisible(string $user_id): bool
+ {
+ $visible = $this->visible;
+
+ if ($visible && $this->group_id) {
+ $visible = StatusgruppeUser::exists([$this->group_id, $user_id]);
+ }
+
+ return $visible;
+ }
+
+ /**
+ * Get the first assignment attempt of the given user for this block.
+ * Returns null if there is no assignment attempt for this user.
+ *
+ * @param string $user_id user id
+ */
+ public function getAssignmentAttempt(string $user_id): ?VipsAssignmentAttempt
+ {
+ $assignment_ids = $this->assignments->pluck('id');
+
+ return VipsAssignmentAttempt::findOneBySQL(
+ 'assignment_id IN (?) AND user_id = ? ORDER BY start', [$assignment_ids, $user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsExerciseRef.php b/lib/models/vips/VipsExerciseRef.php
new file mode 100644
index 0000000..3255e27
--- /dev/null
+++ b/lib/models/vips/VipsExerciseRef.php
@@ -0,0 +1,137 @@
+<?php
+/*
+ * VipsExerciseRef.php - Vips exercise reference class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property array $id alias for pk
+ * @property int $test_id database column
+ * @property int $task_id database column
+ * @property int $position database column
+ * @property int $part database column
+ * @property float|null $points database column
+ * @property string $options database column
+ * @property int|null $mkdate database column
+ * @property int|null $chdate database column
+ * @property Exercise $exercise belongs_to Exercise
+ * @property VipsTest $test belongs_to VipsTest
+ */
+class VipsExerciseRef extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_test_tasks';
+
+ $config['belongs_to']['exercise'] = [
+ 'class_name' => Exercise::class,
+ 'foreign_key' => 'task_id'
+ ];
+ $config['belongs_to']['test'] = [
+ 'class_name' => VipsTest::class,
+ 'foreign_key' => 'test_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Set value for the "exercise" relation (to avoid SORM errors).
+ */
+ public function setExercise(Exercise $exercise): void
+ {
+ $this->task_id = $exercise->id;
+ $this->relations['exercise'] = $exercise;
+ }
+
+ /**
+ * Delete entry from the database.
+ */
+ public function delete()
+ {
+ $ref_count = self::countBySql('task_id = ?', [$this->task_id]);
+
+ if ($ref_count == 1) {
+ $this->exercise->delete();
+ }
+
+ return parent::delete();
+ }
+
+ /**
+ * Copy the referenced exercise into the given test at the specified
+ * position (or at the end). Returns the new exercise reference.
+ *
+ * @param string $test_id test id
+ * @param int $position exercise position (optional)
+ */
+ public function copyIntoTest(string $test_id, ?int $position = null): VipsExerciseRef
+ {
+ $db = DBManager::get();
+
+ if ($position === null) {
+ $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?');
+ $stmt->execute([$test_id]);
+ $position = $stmt->fetchColumn() + 1;
+ }
+
+ $new_exercise = Exercise::create([
+ 'type' => $this->exercise->type,
+ 'title' => $this->exercise->title,
+ 'description' => $this->exercise->description,
+ 'task' => $this->exercise->task,
+ 'options' => $this->exercise->options,
+ 'user_id' => $GLOBALS['user']->id
+ ]);
+
+ if ($this->exercise->folder) {
+ $folder = Folder::findTopFolder($new_exercise->id, 'ExerciseFolder', 'task');
+
+ foreach ($this->exercise->folder->file_refs as $file_ref) {
+ FileManager::copyFile($file_ref->getFileType(), $folder->getTypedFolder(), User::findCurrent());
+ }
+ }
+
+ return VipsExerciseRef::create([
+ 'task_id' => $new_exercise->id,
+ 'test_id' => $test_id,
+ 'points' => $this->points,
+ 'position' => $position
+ ]);
+ }
+
+ /**
+ * Move the referenced exercise into the given test (at the end).
+ *
+ * @param string $test_id test id
+ */
+ public function moveIntoTest(string $test_id): void
+ {
+ $db = DBManager::get();
+ $old_test_id = $this->test_id;
+ $old_position = $this->position;
+
+ if ($old_test_id != $test_id) {
+ $stmt = $db->prepare('SELECT MAX(position) FROM etask_test_tasks WHERE test_id = ?');
+ $stmt->execute([$test_id]);
+ $this->position = $stmt->fetchColumn() + 1;
+ $this->test_id = $test_id;
+ $this->store();
+
+ // renumber following exercises
+ $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?';
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$old_test_id, $old_position]);
+ }
+ }
+}
diff --git a/lib/models/vips/VipsGroup.php b/lib/models/vips/VipsGroup.php
new file mode 100644
index 0000000..8b43c2e
--- /dev/null
+++ b/lib/models/vips/VipsGroup.php
@@ -0,0 +1,79 @@
+<?php
+/*
+ * VipsGroup.php - Vips group class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property string $id alias column for statusgruppe_id
+ * @property string $statusgruppe_id database column
+ * @property string $name database column
+ * @property string|null $description database column
+ * @property string $range_id database column
+ * @property int $position database column
+ * @property int $size database column
+ * @property int $selfassign database column
+ * @property int $selfassign_start database column
+ * @property int $selfassign_end database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property int $calendar_group database column
+ * @property string|null $name_w database column
+ * @property string|null $name_m database column
+ * @property SimpleORMapCollection|VipsGroupMember[] $members has_many VipsGroupMember
+ * @property SimpleORMapCollection|VipsGroupMember[] $current_members has_many VipsGroupMember
+ * @property Course $course belongs_to Course
+ */
+class VipsGroup extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'statusgruppen';
+
+ $config['has_many']['members'] = [
+ 'class_name' => VipsGroupMember::class,
+ 'assoc_foreign_key' => 'group_id',
+ 'on_delete' => 'delete'
+ ];
+ $config['has_many']['current_members'] = [
+ 'class_name' => VipsGroupMember::class,
+ 'assoc_foreign_key' => 'group_id',
+ 'order_by' => 'AND end IS NULL'
+ ];
+
+ $config['belongs_to']['course'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Get the group the user is currently assigned to in a course.
+ * Returns null if there is no group assignment for this user.
+ *
+ * @param string $user_id user id
+ * @param string $course_id course id
+ */
+ public static function getUserGroup(string $user_id, string $course_id): ?VipsGroup
+ {
+ return self::findOneBySQL(
+ 'JOIN etask_group_members ON group_id = statusgruppe_id
+ WHERE range_id = ?
+ AND user_id = ?
+ AND end IS NULL',
+ [$course_id, $user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsGroupMember.php b/lib/models/vips/VipsGroupMember.php
new file mode 100644
index 0000000..c6be629
--- /dev/null
+++ b/lib/models/vips/VipsGroupMember.php
@@ -0,0 +1,50 @@
+<?php
+/*
+ * VipsGroupMember.php - Vips group member class for Stud.IP
+ * Copyright (c) 2016 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property array $id alias for pk
+ * @property string $group_id database column
+ * @property string $user_id database column
+ * @property int $start database column
+ * @property int|null $end database column
+ * @property VipsGroup $group belongs_to VipsGroup
+ * @property User $user belongs_to User
+ * @property mixed $vorname additional field
+ * @property mixed $nachname additional field
+ * @property mixed $username additional field
+ */
+class VipsGroupMember extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_group_members';
+
+ $config['additional_fields']['vorname'] = ['user', 'vorname'];
+ $config['additional_fields']['nachname'] = ['user', 'nachname'];
+ $config['additional_fields']['username'] = ['user', 'username'];
+
+ $config['belongs_to']['group'] = [
+ 'class_name' => VipsGroup::class,
+ 'foreign_key' => 'group_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+}
diff --git a/lib/models/vips/VipsSolution.php b/lib/models/vips/VipsSolution.php
new file mode 100644
index 0000000..14b9826
--- /dev/null
+++ b/lib/models/vips/VipsSolution.php
@@ -0,0 +1,160 @@
+<?php
+/*
+ * VipsSolution.php - Vips solution class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ *
+ * @property int $id database column
+ * @property int $assignment_id database column
+ * @property int $task_id database column
+ * @property string $user_id database column
+ * @property JSONArrayObject $response database column
+ * @property string|null $student_comment database column
+ * @property string $ip_address database column
+ * @property int|null $state database column
+ * @property float|null $points database column
+ * @property string|null $feedback database column
+ * @property string|null $commented_solution database column
+ * @property string|null $grader_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property JSONArrayObject $options database column
+ * @property Exercise $exercise belongs_to Exercise
+ * @property VipsAssignment $assignment belongs_to VipsAssignment
+ * @property User $user belongs_to User
+ * @property Folder $folder has_one Folder
+ * @property Folder $feedback_folder has_one Folder
+ */
+class VipsSolution extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_responses';
+
+ $config['serialized_fields']['response'] = JSONArrayObject::class;
+ $config['serialized_fields']['options'] = JSONArrayObject::class;
+
+ $config['registered_callbacks']['after_store'][] = 'after_store';
+
+ $config['has_one']['folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->getId(), 'ResponseFolder'],
+ 'on_delete' => 'delete'
+ ];
+ $config['has_one']['feedback_folder'] = [
+ 'class_name' => Folder::class,
+ 'assoc_foreign_key' => 'range_id',
+ 'assoc_func' => 'findByRangeIdAndFolderType',
+ 'foreign_key' => fn($record) => [$record->getId(), 'FeedbackFolder'],
+ 'on_delete' => 'delete'
+ ];
+
+ $config['belongs_to']['exercise'] = [
+ 'class_name' => Exercise::class,
+ 'foreign_key' => 'task_id'
+ ];
+ $config['belongs_to']['assignment'] = [
+ 'class_name' => VipsAssignment::class,
+ 'foreign_key' => 'assignment_id'
+ ];
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ /**
+ * Update the gradebook entry.
+ */
+ public function after_store(): void
+ {
+ $this->assignment->updateGradebookEntries($this->user_id);
+ }
+
+ /**
+ * Set value for the "exercise" relation (to avoid SORM errors).
+ */
+ public function setExercise(Exercise $exercise): void
+ {
+ $this->task_id = $exercise->id;
+ $this->relations['exercise'] = $exercise;
+ }
+
+ /**
+ * Get array of submitted answers for this solution (PHP array).
+ */
+ public function getResponse(): array
+ {
+ return $this->content['response']->getArrayCopy();
+ }
+
+ /**
+ * Check if this solution is archived.
+ */
+ public function isArchived(): bool
+ {
+ $solution = VipsSolution::findOneBySql(
+ 'task_id = ? AND assignment_id = ? AND user_id = ? ORDER BY id DESC',
+ [$this->task_id, $this->assignment_id, $this->user_id]
+ );
+
+ return $solution && $this->id != $solution->id;
+ }
+
+ /**
+ * Check if this solution is empty (default response and no files).
+ */
+ public function isEmpty(): bool
+ {
+ return $this->response == $this->exercise->defaultResponse()
+ && $this->student_comment == ''
+ && (!$this->folder || count($this->folder->file_refs) === 0);
+ }
+
+ /**
+ * Check if this solution has been submitted (is not a dummy solution).
+ */
+ public function isSubmitted(): bool
+ {
+ return $this->id && !$this->mkdate;
+ }
+
+ /**
+ * Check if this solution has any corrector feedback (text or files).
+ */
+ public function hasFeedback()
+ {
+ return $this->feedback
+ || ($this->feedback_folder && count($this->feedback_folder->file_refs) > 0);
+ }
+
+ /**
+ * Return the total number of solutions (including archived ones)
+ * submitted by the same user for this exercise.
+ */
+ public function countTries(): int
+ {
+ if ($this->isNew()) {
+ return 0;
+ }
+
+ return VipsSolution::countBySql(
+ 'task_id = ? AND assignment_id = ? AND user_id = ?',
+ [$this->task_id, $this->assignment_id, $this->user_id]
+ );
+ }
+}
diff --git a/lib/models/vips/VipsTest.php b/lib/models/vips/VipsTest.php
new file mode 100644
index 0000000..178b352
--- /dev/null
+++ b/lib/models/vips/VipsTest.php
@@ -0,0 +1,121 @@
+<?php
+/*
+ * VipsTest.php - Vips test class for Stud.IP
+ * Copyright (c) 2014 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+/**
+ * @license GPL2 or any later version
+ *
+ * @property int $id database column
+ * @property string $title database column
+ * @property string $description database column
+ * @property string $user_id database column
+ * @property int $mkdate database column
+ * @property int $chdate database column
+ * @property string|null $options database column
+ * @property SimpleORMapCollection|VipsAssignment[] $assignments has_many VipsAssignment
+ * @property SimpleORMapCollection|VipsExerciseRef[] $exercise_refs has_many VipsExerciseRef
+ * @property User $user belongs_to User
+ * @property SimpleORMapCollection|Exercise[] $exercises has_and_belongs_to_many Exercise
+ */
+class VipsTest extends SimpleORMap
+{
+ /**
+ * Configure the database mapping.
+ */
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'etask_tests';
+
+ // $config['serialized_fields']['options'] = 'JSONArrayObject';
+
+ $config['has_and_belongs_to_many']['exercises'] = [
+ 'class_name' => Exercise::class,
+ 'assoc_foreign_key' => 'id',
+ 'thru_table' => 'etask_test_tasks',
+ 'thru_key' => 'test_id',
+ 'thru_assoc_key' => 'task_id',
+ 'order_by' => 'ORDER BY position'
+ ];
+
+ $config['has_many']['assignments'] = [
+ 'class_name' => VipsAssignment::class,
+ 'assoc_foreign_key' => 'test_id'
+ ];
+ $config['has_many']['exercise_refs'] = [
+ 'class_name' => VipsExerciseRef::class,
+ 'assoc_foreign_key' => 'test_id',
+ 'on_delete' => 'delete',
+ 'order_by' => 'ORDER BY position'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ public function addExercise(Exercise $exercise): VipsExerciseRef
+ {
+ $attributes = [
+ 'task_id' => $exercise->id,
+ 'test_id' => $this->id,
+ 'position' => count($this->exercise_refs) + 1,
+ 'points' => $exercise->itemCount()
+ ];
+
+ $exercise_ref = VipsExerciseRef::create($attributes);
+
+ $this->resetRelation('exercises');
+ $this->resetRelation('exercise_refs');
+
+ return $exercise_ref;
+ }
+
+ public function removeExercise(int $exercise_id): void
+ {
+ $db = DBManager::get();
+
+ $exercise_ref = VipsExerciseRef::find([$this->id, $exercise_id]);
+ $position = $exercise_ref->position;
+
+ if ($exercise_ref->delete()) {
+ // renumber following exercises
+ $sql = 'UPDATE etask_test_tasks SET position = position - 1 WHERE test_id = ? AND position > ?';
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$this->id, $position]);
+ }
+
+ $this->resetRelation('exercises');
+ $this->resetRelation('exercise_refs');
+ }
+
+ public function getExerciseRef(int $exercise_id): ?VipsExerciseRef
+ {
+ return $this->exercise_refs->findOneBy('task_id', $exercise_id);
+ }
+
+ /**
+ * Return the maximum number of points a person can get on this test.
+ *
+ * @return integer number of maximum points
+ */
+ public function getTotalPoints(): int
+ {
+ $points = 0;
+
+ foreach ($this->exercise_refs as $exercise_ref) {
+ $points += $exercise_ref->points;
+ }
+
+ return $points;
+ }
+}
diff --git a/lib/modules/VipsModule.php b/lib/modules/VipsModule.php
new file mode 100644
index 0000000..9c37c3a
--- /dev/null
+++ b/lib/modules/VipsModule.php
@@ -0,0 +1,471 @@
+<?php
+/*
+ * VipsModule.php - Vips plugin class for Stud.IP
+ * Copyright (c) 2007-2021 Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+use Courseware\CoursewarePlugin;
+
+/**
+ * Vips plugin class for Stud.IP
+ */
+class VipsModule extends CorePlugin implements StudipModule, SystemPlugin, PrivacyPlugin, CoursewarePlugin
+{
+ public static ?bool $exam_mode = null;
+ public static ?VipsModule $instance = null;
+ public static ?Flexi\Factory $template_factory = null;
+
+ public function __construct()
+ {
+ global $perm, $user;
+
+ parent::__construct();
+
+ self::$instance = $this;
+ self::$template_factory = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/app/views/vips');
+
+ NotificationCenter::addObserver($this, 'userDidDelete', 'UserDidDelete');
+ NotificationCenter::addObserver($this, 'courseDidDelete', 'CourseDidDelete');
+ NotificationCenter::addObserver($this, 'userDidLeaveCourse', 'UserDidLeaveCourse');
+ NotificationCenter::addObserver($this, 'userDidMigrate', 'UserDidMigrate');
+ NotificationCenter::addObserver($this, 'statusgruppeUserDidCreate', 'StatusgruppeUserDidCreate');
+ NotificationCenter::addObserver($this, 'statusgruppeUserDidDelete', 'StatusgruppeUserDidDelete');
+
+ Exercise::addExerciseType(_('Single Choice'), SingleChoiceTask::class, ['choice-single', '']);
+ Exercise::addExerciseType(_('Multiple Choice'), MultipleChoiceTask::class, 'choice-multiple');
+ Exercise::addExerciseType(_('Multiple Choice Matrix'), MatrixChoiceTask::class, 'choice-matrix');
+ Exercise::addExerciseType(_('Freie Antwort'), TextLineTask::class, 'text-line');
+ Exercise::addExerciseType(_('Textaufgabe'), TextTask::class, 'text-area');
+ Exercise::addExerciseType(_('Lückentext'), ClozeTask::class, ['cloze-input', 'cloze-select', 'cloze-drag']);
+ Exercise::addExerciseType(_('Zuordnung'), MatchingTask::class, ['matching', 'matching-multiple']);
+ Exercise::addExerciseType(_('Reihenfolge'), SequenceTask::class, 'sequence');
+
+ if ($perm->have_perm('root')) {
+ $nav_item = new Navigation(_('Klausuren'), 'dispatch.php/vips/config');
+ Navigation::addItem('/admin/config/vips', $nav_item);
+ }
+
+ if (Navigation::hasItem('/contents')) {
+ $nav_item = new Navigation(_('Aufgaben'));
+ $nav_item->setImage(Icon::create('vips'));
+ $nav_item->setDescription(_('Erstellen und Verwalten von Aufgabenblättern'));
+ Navigation::addItem('/contents/vips', $nav_item);
+
+ $sub_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/pool/assignments');
+ $nav_item->addSubNavigation('assignments', $sub_item);
+
+ $sub_item = new Navigation(_('Aufgaben'), 'dispatch.php/vips/pool/exercises');
+ $nav_item->addSubNavigation('exercises', $sub_item);
+ }
+
+ // check for running exams
+ if (Config::get()->VIPS_EXAM_RESTRICTIONS && !isset(self::$exam_mode)) {
+ $courses = self::getCoursesWithRunningExams($user->id);
+ self::$exam_mode = count($courses) > 0;
+
+ if (self::$exam_mode) {
+ $page = basename($_SERVER['PHP_SELF']);
+ $path_info = Request::pathInfo();
+ $course_id = Context::getId();
+
+ // redirect page calls if necessary
+ if (match_route('dispatch.php/jsupdater/get')) {
+ // always allow jsupdater calls
+ UpdateInformation::setInformation('vips', ['exam_mode' => true]);
+ } else if (isset($course_id, $courses[$course_id])) {
+ // course with running exam is selected, allow all exam actions
+ if (!match_route('dispatch.php/vips/sheets')) {
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets'));
+ sess()->save();
+ die();
+ }
+ } else if (count($courses) === 1) {
+ // only one course with running exam, redirect there
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/sheets', ['cid' => key($courses)]));
+ sess()->save();
+
+ die();
+ } else if (!match_route('dispatch.php/vips/exam_mode')) {
+ // forward to overview of all running courses with exams
+ header('Location: ' . URLHelper::getURL('dispatch.php/vips/exam_mode'));
+ sess()->save();
+ die();
+ }
+ } else {
+ PageLayout::addHeadElement(
+ 'script',
+ [],
+ 'STUDIP.JSUpdater.register("vips", () => location.reload());'
+ );
+ }
+ }
+ }
+
+ /**
+ * Return whether or not the current user has the given status in a course.
+ *
+ * @param string $status status name: 'autor', 'tutor' or 'dozent'
+ * @param string $course_id course to check
+ */
+ public static function hasStatus(string $status, string $course_id): bool
+ {
+ return $course_id && $GLOBALS['perm']->have_studip_perm($status, $course_id);
+ }
+
+ /**
+ * Check whether or not the current user has the required status in a course.
+ *
+ * @param string $status required status: 'autor', 'tutor' or 'dozent'
+ * @param string $course_id course to check
+ * @throws AccessDeniedException if the requirement is not met, an exception is thrown
+ */
+ public static function requireStatus(string $status, string $course_id): void
+ {
+ if (!VipsModule::hasStatus($status, $course_id)) {
+ throw new AccessDeniedException(_('Sie verfügen nicht über die notwendigen Rechte für diese Aktion.'));
+ }
+ }
+
+ /**
+ * Checks whether or not the current user may view an assignment.
+ *
+ * @param VipsAssignment|null $assignment assignment to check
+ * @param int|null $exercise_id check that this exercise is on the assignment (optional)
+ * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown
+ */
+ public static function requireViewPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void
+ {
+ if (!$assignment || !$assignment->checkViewPermission()) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!'));
+ }
+
+ if ($exercise_id && !$assignment->hasExercise($exercise_id)) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!'));
+ }
+ }
+
+ /**
+ * Checks whether or not the current user may edit an assignment.
+ *
+ * @param VipsAssignment|null $assignment assignment to check
+ * @param int|null $exercise_id check that this exercise is on the assignment (optional)
+ * @throws AccessDeniedException If the current user doesn't have access, an exception is thrown
+ */
+ public static function requireEditPermission(?VipsAssignment $assignment, ?int $exercise_id = null): void
+ {
+ if (!$assignment || !$assignment->checkEditPermission()) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf dieses Aufgabenblatt!'));
+ }
+
+ if ($exercise_id && !$assignment->hasExercise($exercise_id)) {
+ throw new AccessDeniedException(_('Sie haben keinen Zugriff auf diese Aufgabe!'));
+ }
+ }
+
+ /**
+ * Get all courses where the user is at least tutor and Vips is activated.
+ *
+ * @return array with all course ids, null if no courses
+ */
+ public static function getActiveCourses(string $user_id): array
+ {
+ $plugin_manager = PluginManager::getInstance();
+ $vips_plugin_id = VipsModule::$instance->getPluginId();
+
+ $sql = "JOIN seminar_user USING(Seminar_id)
+ WHERE user_id = ? AND seminar_user.status IN ('dozent', 'tutor')
+ ORDER BY (SELECT MIN(beginn) FROM semester_data
+ JOIN semester_courses USING(semester_id)
+ WHERE course_id = Seminar_id) DESC, Name";
+ $courses = Course::findBySQL($sql, [$user_id]);
+
+ // remove courses where Vips is not active
+ foreach ($courses as $key => $course) {
+ if (!$plugin_manager->isPluginActivated($vips_plugin_id, $course->id)) {
+ unset($courses[$key]);
+ }
+ }
+
+ return $courses;
+ }
+
+ /**
+ * Get all courses with currently running exams for the given user.
+ *
+ * @param string $user_id The user id
+ *
+ * @return array associative array of course ids and course names
+ */
+ public static function getCoursesWithRunningExams(string $user_id): array
+ {
+ $db = DBManager::get();
+
+ $courses = [];
+
+ $sql = "SELECT DISTINCT seminare.Seminar_id, seminare.Name, etask_assignments.id
+ FROM etask_assignments
+ JOIN seminar_user ON seminar_user.Seminar_id = etask_assignments.range_id
+ JOIN seminare USING(Seminar_id)
+ WHERE etask_assignments.type = 'exam'
+ AND etask_assignments.start <= UNIX_TIMESTAMP()
+ AND etask_assignments.end > UNIX_TIMESTAMP()
+ AND seminar_user.user_id = ?
+ AND seminar_user.status = 'autor'
+ ORDER BY seminare.Name";
+ $stmt = $db->prepare($sql);
+ $stmt->execute([$user_id]);
+
+ foreach ($stmt as $row) {
+ $assignment = VipsAssignment::find($row['id']);
+ $ip_range = $assignment->options['ip_range'];
+
+ if ($assignment->isVisible($user_id)) {
+ if (strlen($ip_range) > 0 && $assignment->checkIPAccess($_SERVER['REMOTE_ADDR'])) {
+ $courses[$row['Seminar_id']] = $row['Name'];
+ }
+ }
+ }
+
+ return $courses;
+ }
+
+ public function setupExamNavigation()
+ {
+ $navigation = new Navigation('');
+
+ $start = Navigation::getItem('/start');
+ $start->setURL('dispatch.php/vips/exam_mode');
+ $navigation->addSubNavigation('start', $start);
+
+ $course = new Navigation(_('Veranstaltung'));
+ $navigation->addSubNavigation('course', $course);
+
+ $vips = new Navigation($this->getPluginName());
+ $vips->setImage(Icon::create('vips'));
+ $course->addSubNavigation('vips', $vips);
+
+ $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets');
+ $vips->addSubNavigation('sheets', $nav_item);
+
+ $links = new Navigation('Links');
+ $links->addSubNavigation('logout', new Navigation(_('Logout'), 'logout.php'));
+ $navigation->addSubNavigation('links', $links);
+
+ Config::get()->PERSONAL_NOTIFICATIONS_ACTIVATED = 0;
+ PageLayout::addStyle('#navigation-level-1, #navigation-level-2, #context-title { display: none; }');
+ PageLayout::addCustomQuicksearch('<div style="width: 64px;"></div>');
+ Navigation::setRootNavigation($navigation);
+ }
+
+ public function getIconNavigation($course_id, $last_visit, $user_id)
+ {
+ if (VipsModule::hasStatus('tutor', $course_id)) {
+ // find all uncorrected exercises in finished assignments in this course
+ // Added JOIN with seminar_user to filter out lecturer/tutor solutions.
+ $new_items = VipsSolution::countBySql(
+ "JOIN etask_assignments ON etask_responses.assignment_id = etask_assignments.id
+ LEFT JOIN seminar_user
+ ON seminar_user.Seminar_id = etask_assignments.range_id
+ AND seminar_user.user_id = etask_responses.user_id
+ WHERE etask_assignments.range_id = ?
+ AND etask_assignments.type IN ('exam', 'practice', 'selftest')
+ AND etask_assignments.end <= UNIX_TIMESTAMP()
+ AND etask_responses.state = 0
+ AND IFNULL(seminar_user.status, 'autor') = 'autor'",
+ [$course_id]
+ );
+
+ $message = ngettext('%d unkorrigierte Lösung', '%d unkorrigierte Lösungen', $new_items);
+ } else {
+ // find all active assignments not yet seen by the student
+ $assignments = VipsAssignment::findBySQL(
+ "LEFT JOIN etask_assignment_attempts
+ ON etask_assignment_attempts.assignment_id = etask_assignments.id
+ AND etask_assignment_attempts.user_id = ?
+ WHERE etask_assignments.range_id = ?
+ AND etask_assignments.type IN ('exam', 'practice', 'selftest')
+ AND etask_assignments.start <= UNIX_TIMESTAMP()
+ AND (etask_assignments.end IS NULL OR etask_assignments.end > UNIX_TIMESTAMP())
+ AND etask_assignment_attempts.user_id IS NULL",
+ [$user_id, $course_id]
+ );
+
+ $new_items = 0;
+
+ foreach ($assignments as $assignment) {
+ if ($assignment->isVisible($user_id)) {
+ ++$new_items;
+ }
+ }
+
+ $message = ngettext('%d neues Aufgabenblatt', '%d neue Aufgabenblätter', $new_items);
+ }
+
+ $overview_message = $this->getPluginName();
+ $icon = Icon::create('vips');
+
+ if ($new_items > 0) {
+ $overview_message = sprintf($message, $new_items);
+ $icon = Icon::create('vips', Icon::ROLE_NEW);
+ }
+
+ $icon_navigation = new Navigation($this->getPluginName(), 'dispatch.php/vips/sheets');
+ $icon_navigation->setImage($icon->copyWithAttributes(['title' => $overview_message]));
+
+ return $icon_navigation;
+ }
+
+ public function getInfoTemplate($course_id)
+ {
+ return null;
+ }
+
+ public function getTabNavigation($course_id)
+ {
+ $navigation = new Navigation($this->getPluginName());
+ $navigation->setImage(Icon::create('vips'));
+
+ $nav_item = new Navigation(_('Aufgabenblätter'), 'dispatch.php/vips/sheets');
+ $navigation->addSubNavigation('sheets', $nav_item);
+
+ $nav_item = new Navigation(_('Ergebnisse'), 'dispatch.php/vips/solutions');
+ $navigation->addSubNavigation('solutions', $nav_item);
+
+ return ['vips' => $navigation];
+ }
+
+ public function getMetadata()
+ {
+ $metadata['category'] = _('Inhalte und Aufgabenstellungen');
+ $metadata['displayname'] = _('Aufgaben und Prüfungen');
+ $metadata['summary'] =
+ _('Erstellung und Durchführung von Übungen, Tests und Klausuren');
+ $metadata['description'] =
+ _('Mit diesem Werkzeug können Übungen, Tests und Klausuren online vorbereitet und durchgeführt werden. ' .
+ 'Die Lehrenden erhalten eine Übersicht darüber, welche Teilnehmenden eine Übung oder einen ' .
+ 'Test mit welchem Ergebnis abgeschlossen haben. Im Gegensatz zu herkömmlichen Übungszetteln ' .
+ 'oder Klausurbögen sind in Stud.IP alle Texte gut lesbar und sortiert abgelegt. Lehrende ' .
+ 'erhalten sofort einen Überblick darüber, was noch zu korrigieren ist. Neben allgemein ' .
+ 'üblichen Fragetypen wie Multiple Choice und Freitextantwort verfügt das Werkzeug auch über ' .
+ 'ungewöhnlichere, aber didaktisch durchaus sinnvolle Fragetypen wie Lückentext und Zuordnung.');
+ $metadata['keywords'] =
+ _('Einsatz bei Hausaufgaben und Präsenzprüfungen; Reduzierter Arbeitsaufwand bei der Auswertung; ' .
+ 'Sortierte Übersicht der eingereichten Ergebnisse; Single-, Multiple-Choice- und Textaufgaben, ' .
+ 'Lückentexte und Zuordnungen; Notwendige Korrekturen und erzielte Punktzahlen auf einen Blick');
+ $metadata['icon'] = Icon::create('vips');
+
+ return $metadata;
+ }
+
+ public function userDidDelete($event, $user)
+ {
+ // delete all personal assignments
+ VipsAssignment::deleteBySQL('range_id = ?', [$user->id]);
+
+ // delete in etask_responses
+ VipsSolution::deleteBySQL('user_id = ?', [$user->id]);
+
+ // delete start times and group memberships
+ VipsAssignmentAttempt::deleteBySQL('user_id = ?', [$user->id]);
+ VipsGroupMember::deleteBySQL('user_id = ?', [$user->id]);
+ }
+
+ public function courseDidDelete($event, $course)
+ {
+ // delete all assignments in course
+ VipsAssignment::deleteBySQL('range_id = ?', [$course->id]);
+
+ // delete other course related info
+ VipsBlock::deleteBySQL('range_id = ?', [$course->id]);
+ }
+
+ public function userDidLeaveCourse($event, $course_id, $user_id)
+ {
+ // terminate group membership when leaving a course
+ $group_member = VipsGroupMember::findOneBySQL(
+ 'JOIN statusgruppen ON statusgruppe_id = group_id WHERE range_id = ? AND user_id = ? AND end IS NULL',
+ [$course_id, $user_id]
+ );
+
+ if ($group_member) {
+ $group_member->end = time();
+ $group_member->store();
+ }
+ }
+
+ public function userDidMigrate($event, $user_id, $new_id)
+ {
+ $db = DBManager::get();
+
+ $db->execute('UPDATE IGNORE etask_assignment_attempts SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ $db->execute('UPDATE etask_tasks SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+
+ $db->execute('UPDATE IGNORE etask_responses SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ $db->execute('UPDATE etask_tests SET user_id = ? WHERE user_id = ?', [$new_id, $user_id]);
+ }
+
+ public function statusgruppeUserDidCreate($event, $statusgruppe_user)
+ {
+ VipsGroupMember::create([
+ 'group_id' => $statusgruppe_user->statusgruppe_id,
+ 'user_id' => $statusgruppe_user->user_id,
+ 'start' => time()
+ ]);
+ }
+
+ public function statusgruppeUserDidDelete($event, $statusgruppe_user)
+ {
+ $member = VipsGroupMember::findOneBySQL(
+ 'group_id = ? AND user_id = ? AND end IS NULL',
+ [$statusgruppe_user->statusgruppe_id, $statusgruppe_user->user_id]
+ );
+
+ if ($member) {
+ $member->end = time();
+ $member->store();
+ }
+ }
+
+ /**
+ * Export available data of a given user into a storage object
+ * (an instance of the StoredUserData class) for that user.
+ *
+ * @param StoredUserData $store object to store data into
+ */
+ public function exportUserData(StoredUserData $store)
+ {
+ $db = DBManager::get();
+
+ $data = $db->fetchAll('SELECT * FROM etask_group_members WHERE user_id = ?', [$store->user_id]);
+ $store->addTabularData(_('Aufgaben-Gruppenzuordnung'), 'etask_group_members', $data);
+ }
+
+ /**
+ * Implement this method to register more block types.
+ *
+ * You get the current list of block types and return an updated list
+ * containing your own block types.
+ */
+ public function registerBlockTypes(array $otherBlockTypes): array
+ {
+ $otherBlockTypes[] = Courseware\BlockTypes\TestBlock::class;
+
+ return $otherBlockTypes;
+ }
+
+ /**
+ * Implement this method to register more container types.
+ *
+ * You get the current list of container types and return an updated list
+ * containing your own container types.
+ */
+ public function registerContainerTypes(array $otherContainerTypes): array
+ {
+ return $otherContainerTypes;
+ }
+}