Skip to content
Snippets Groups Projects
Forked from Stud.IP / Stud.IP
276 commits behind the upstream repository.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Exercise.php 25.16 KiB
<?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);
    }
}