diff options
Diffstat (limited to 'lib')
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 „%s“ mehrfach eingetragen.'), + $alternative['index'] + 1, htmlReady($alternative['text'])); + PageLayout::postWarning($message); + } + + return parent::getEditTemplate($assignment); + } + + /** + * Create a template for viewing an exercise. + */ + public function getViewTemplate($view, $solution, $assignment, $user_id): \Flexi\Template + { + $template = parent::getViewTemplate($view, $solution, $assignment, $user_id); + + if ($solution && $solution->id) { + $template->results = $this->evaluateItems($solution); + } + + return $template; + } + + /** + * Return the interaction type of this task (input, select or drag). + */ + public function interactionType(): string + { + return $this->task['layout'] ?? 'input'; + } + + /** + * Check if selection should be offered for the given item. + */ + public function isSelect(string $item, bool $use_default = true): bool + { + if ($use_default && $this->interactionType() === 'select') { + return true; + } + + if (isset($this->task['select'])) { + return in_array($item, $this->task['select']); + } + + return false; + } + + /** + * Returns all currently unassigned answers for the given solution. + */ + public function availableAnswers(?VipsSolution $solution): array + { + $answers = []; + $response = $solution->response ?? []; + + foreach ($this->task['answers'] as $answer) { + foreach ($answer as $option) { + $i = array_search($option['text'], $response); + + if ($i !== false) { + unset($response[$i]); + } else if ($option['text'] !== '') { + $answers[] = $option['text']; + } + } + } + + sort($answers, SORT_LOCALE_STRING); + return $answers; + } + + /** + * Returns all the correct answers for an item in an array. + */ + public function correctAnswers($item): array + { + $answers = []; + + foreach ($this->task['answers'][$item] as $answer) { + if ($answer['score'] == 1) { + $answers[] = $answer['text']; + } + } + + return $answers; + } + + /** + * Calculate the optimal input field size for text exercises. + * + * @param int $item item number + * @return int length of input field in characters + */ + public function getInputWidth($item): int + { + if (isset($this->task['input_width'])) { + return 5 << $this->task['input_width']; + } + + $max = 0; + + foreach ($this->task['answers'][$item] as $option) { + $length = mb_strlen($option['text']); + + if ($length > $max) { + $max = $length; + } + } + + $length = $max ? min(max($max, 6), 48) : 12; + + // possible sizes: 5, 10, 20, 40 + return 5 << ceil(log($length / 6) / log(2)); + } + + /** + * Evaluates a student's solution for the individual items in this + * exercise. Returns an array of ('points' => float, 'safe' => boolean). + * + * @param mixed $solution The solution XML string as returned by responseFromRequest(). + */ + public function evaluateItems($solution): array + { + $result = []; + + $response = $solution->response; + $ignorecase = isset($this->task['compare']) && $this->task['compare'] === 'ignorecase'; + $numeric = isset($this->task['compare']) && $this->task['compare'] === 'numeric'; + + foreach ($this->task['answers'] as $blank => $answer) { + $student_answer = $this->normalizeText($response[$blank] ?? '', $ignorecase); + $options = ['' => 0]; + $points = 0; + $safe = $this->interactionType() !== 'input'; + + foreach ($answer as $option) { // different answer options + if ($numeric && $student_answer !== '') { + $correct_unit = $student_unit = null; + $correct = $this->normalizeFloat($option['text'], $correct_unit); + $student = $this->normalizeFloat($response[$blank], $student_unit); + + if ($correct_unit === $student_unit) { + if (abs($correct - $student) <= abs($correct * $this->task['epsilon'])) { + $options[$student_answer] = max($option['score'], $options[$student_answer]); + } else { + $safe = true; + } + } + } else { + $content = $this->normalizeText($option['text'], $ignorecase); + $options[$content] = $option['score']; + } + } + + if (isset($options[$student_answer])) { + $points = $options[$student_answer]; + $safe = true; + } + + $result[] = ['points' => $points, 'safe' => $safe]; + } + + return $result; + } + + + + ####################################### + # # + # h e l p e r f u n c t i o n s # + # # + ####################################### + + + + /** + * Returns the exercise for the lecturer. Clozes are represented by square brackets. + */ + public function getClozeText(): string + { + $is_html = Studip\Markup::isHtml($this->task['text']); + $result = ''; + + foreach (explode('[[]]', $this->task['text']) as $blank => $text) { + $result .= $text; + + if (isset($this->task['answers'][$blank])) { // blank + $answers = []; + $select = $this->isSelect($blank, false) ? ':' : ''; + + foreach ($this->task['answers'][$blank] as $answer) { + $answer_text = $answer['text']; + + if (preg_match('/^$|^[":*~ ]|\||\]\]|[] ]$/', $answer_text)) { + $answer_text = '"' . $answer_text . '"'; + } + + if ($answer['score'] == 0) { + $answers[] = '*' . $answer_text; + } else if ($answer['score'] == 0.5) { + $answers[] = '~' . $answer_text; + } else { + $answers[] = $answer_text; + } + } + + $blank = '[[' . $select . implode('|', $answers) . ']]'; + + if ($is_html) { + $blank = htmlReady($blank); + } + + $result .= $blank; + } + } + + return $result; + } + + + + /** + * Converts plain text ("foo bar [blank] text...") to array. + */ + public function parseClozeText(string $question): void + { + $is_html = Studip\Markup::isHtml($question); + $question = Studip\Markup::purifyHtml($question); + $this->task['text'] = ''; + + // $question_array contains text elements and blanks (surrounded by [[ and ]]). + $parts = preg_split('/(\[\[(?:".*?"|.)*?\]\])/s', $question, -1, PREG_SPLIT_DELIM_CAPTURE); + $select = null; + + foreach ($parts as $part) { + if (preg_match('/^\[\[(.*)\]\]$/s', $part, $matches)) { + $part = preg_replace("/[\t\n\r\xA0]/", ' ', $matches[1]); + $answers = []; + + if ($is_html) { + $part = Studip\Markup::markAsHtml($part); + $part = Studip\Markup::removeHtml($part); + } + + if ($part[0] === ':') { + $select[] = $this->itemCount(); + $part = substr($part, 1); + } + + if ($part !== '') { + preg_match_all('/((?:".*?"|[^|])*)\|/', $part . '|', $matches); + + foreach ($matches[1] as $answer) { + $answer = trim($answer); + $points = 1; + + if ($answer !== '') { + if ($answer[0] === '*') { + $points = 0; + $answer = substr($answer, 1); + } else if ($answer[0] === '~') { + $points = 0.5; + $answer = substr($answer, 1); + } + } + + if (preg_match('/^"(.*)"$/', $answer, $matches)) { + $answer = $matches[1]; + } + + $answers[] = ['text' => $answer, 'score' => $points]; + } + } + + $this->task['answers'][] = $answers; + $this->task['text'] .= '[[]]'; + } else { + $this->task['text'] .= $part; + } + } + + $this->task['select'] = $select; + } + + /** + * Searches in each cloze if an answer alternative is given repatedly. + * + * @return array Either an empty array or an array of arrays, each containing the + * elements 'index' (index of the cloze where the duplicate + * entry was found) and 'text' (text of the duplicate entry). + */ + private function findDuplicateAlternatives(): array + { + $duplicate_alternatives = []; + + foreach ($this->task['answers'] as $index => $answers) { + $alternatives = []; + + foreach ($answers as $answer) { + if (in_array($answer['text'], $alternatives, true)) { + $duplicate_alternatives[] = [ + 'index' => $index, + 'text' => $answer['text'] + ]; + } + + $alternatives[] = $answer['text']; + } + } + + return $duplicate_alternatives; + } +} diff --git a/lib/models/vips/DummyExercise.php b/lib/models/vips/DummyExercise.php new file mode 100644 index 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; + } +} |
