Skip to content
Snippets Groups Projects
Select Git revision
  • ec43f9bf751f22d391fb3a5014905667ba00e158
  • main default protected
  • studip-rector
  • ci-opt
  • course-members-export-as-word
  • data-vue-app
  • pipeline-improvements
  • webpack-optimizations
  • rector
  • icon-renewal
  • http-client-and-factories
  • jsonapi-atomic-operations
  • vueify-messages
  • tic-2341
  • 135-translatable-study-areas
  • extensible-sorm-action-parameters
  • sorm-configuration-trait
  • jsonapi-mvv-routes
  • docblocks-for-magic-methods
19 results

MyRealmModel.php

Blame
  • Forked from Stud.IP / Stud.IP
    Source project has a limited visibility.
    Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    Exercise.php 20.91 KiB
    <?php
    /*
     * Exercise.php - base class for all exercise types
     * Copyright (c) 2003-2005  Erik Schmitt, Philipp Hügelmeyer
     * Copyright (c) 2005-2006  Christa Deiwiks
     * 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
    {
        public $task = [];
    
        private static $exercise_types = [];
    
        /**
         * Configure the database mapping.
         */
        protected static function configure($config = [])
        {
            $config['db_table'] = 'vips_exercise';
    
            $config['serialized_fields']['options'] = 'JSONArrayObject';
    
            $config['has_and_belongs_to_many']['tests'] = [
                'class_name'        => 'VipsTest',
                'thru_table'        => 'vips_exercise_ref',
                'thru_key'          => 'exercise_id',
                'thru_assoc_key'    => 'test_id'
            ];
            $config['has_and_belongs_to_many']['files'] = [
                'class_name'        => 'VipsFile',
                'thru_table'        => 'vips_file_ref',
                'thru_key'          => 'object_id',
                'thru_assoc_key'    => 'file_id',
                'order_by'          => "AND type = 'exercise' ORDER BY name"
            ];
    
            $config['has_many']['exercise_refs'] = [
                'class_name'        => 'VipsExerciseRef',
                'assoc_foreign_key' => 'exercise_id',
                'on_delete'         => 'delete'
            ];
            $config['has_many']['file_refs'] = [
                'class_name'        => 'VipsFileRef',
                'assoc_foreign_key' => 'object_id',
                'on_delete'         => 'delete',
                'order_by'          => "AND type = 'exercise'"
            ];
            $config['has_many']['solutions'] = [
                'class_name'        => 'VipsSolution',
                'assoc_foreign_key' => 'exercise_id',
                'on_delete'         => 'delete'
            ];
            $config['has_many']['old_solutions'] = [
                'class_name'        => 'VipsSolutionArchive',
                'assoc_foreign_key' => 'exercise_id',
                'on_delete'         => 'delete'
            ];
    
            $config['belongs_to']['user'] = [
                'class_name'  => 'User',
                '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)
        {
            $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 = _vips('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['files_visible']) {
                $this->options['files_hidden'] = 1;
            }
        }
    
        /**
         * Filter input from flexible input with HTMLPurifier (if required).
         */
        public static function purifyFlexibleInput($html)
        {
            if (Studip\Markup::isHtml($html)) {
                if (preg_match('/<.*</', $html)) {
                    $html = Studip\Markup::purifyHtml($html);
                } else {
                    $html = Studip\Markup::removeHtml($html);
                }
            }
    
            return $html;
        }
    
        /**
         * Load a specific exercise from the database.
         */
        public static function find($id)
        {
            $db = DBManager::get();
    
            $stmt = $db->prepare('SELECT * FROM vips_exercise 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 parameters 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 vips_exercise.* FROM vips_exercise ' . $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 value of foreign key to find related records
         * @param array 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` = vips_exercise.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';
    
            if (static::class === 'Exercise') {
                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';
    
            return $class::build($data, false);
        }
    
        /**
         * Initialize task structure from JSON string.
         */
        public function setTask_json($value)
        {
            $this->content['task_json'] = $value;
            // FIXME this will override defaults set in __construct()
            $this->task = studip_json_decode($value) ?: $this->task;
        }
    
        /**
         * Store this exercise into the database.
         */
        public function store()
        {
            $this->content['task_json'] = studip_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()
        {
            return 1;
        }
    
        /**
         * Overwrite this function for each exercise type where shuffling answer
         * alternatives makes sense.
         *
         * @param $user_id  A value for initialising the randomizer.
         */
        public function shuffleAnswers($user_id)
        {
        }
    
        /**
         * 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()
        {
            return false;
        }
    
        /**
         * Evaluates a student's solution for the individual items in this
         * exercise. Returns an array of ('points' => float, 'safe' => boolean).
         *
         * @param solution The solution object returned by getSolutionFromRequest().
         */
        public abstract function evaluateItems($solution);
    
        /**
         * Evaluates a student's solution.
         *
         * @param solution The solution object returned by getSolutionFromRequest().
         */
        public function evaluate($solution)
        {
            $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;
                }
    
                $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) {
                    $points = $points - $malus;
                } else if ($mc_mode == 3 && $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()
        {
            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.
         * @return array containing the solutions of the student.
         */
        public function responseFromRequest($request)
        {
            $result = [];
    
            for ($i = 0; $i < $this->itemCount(); ++$i) {
                $result[] = trim($request['answer'][$i]);
            }
    
            return $result;
        }
    
        /**
         * Export this exercise to plain text format.
         */
        public function exportText($exercise_tag = NULL)
        {
            if ($exercise_tag === NULL) {
                return sprintf(_vips('# Aufgaben des Typs "%s" können nicht exportiert werden.'), $this->type)."\n";
            }
    
            $result = 'Name: '.$this->title."\n";
            $result .= $exercise_tag.': '.$this->description."\n";
    
            if ($this->options['hint'] != '') {
                $result .= "Tipp:\n";
                $result .= $this->options['hint']."\n";
                $result .= "\\Tipp\n";
            }
    
            return $result;
        }
    
        /**
         * Export this exercise to Vips XML format.
         */
        public function getXMLTemplate($assignment)
        {
            return $this->getViewTemplate('xml', null, $assignment, null);
        }
    
        /**
         * Exercise handler to be called when a solution is submitted.
         */
        public function submitSolutionAction($controller, $solution)
        {
        }
    
        /**
         * Exercise handler to be called when a solution is corrected.
         */
        public function correctSolutionAction($controller, $solution)
        {
        }
    
        /**
         * Return a URL to a specified route in this exercise class.
         * $params can contain optional additional parameters.
         */
        public function url_for($path, $params = [])
        {
            $params['exercise_id'] = $this->id;
    
            return VipsPlugin::$instance->url_for('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 = [])
        {
            return htmlReady($this->url_for($path, $params));
        }
    
        /**
         * Create a template for editing an exercise.
         *
         * @return The template
         */
        public function getEditTemplate($assignment)
        {
            $template = VipsPlugin::$template_factory->open('exercises/edit_' . $this->type);
            $template->exercise = $this;
            $template->available_character_sets = CharacterPicker::availableCharacterSets();
    
            return $template;
        }
    
        /**
         * Create a template for viewing an exercise.
         *
         * @return The template
         */
        public function getViewTemplate($view, $solution, $assignment, $user_id)
        {
            if ($assignment->isShuffled() && $user_id) {
                $this->shuffleAnswers($user_id);
            }
    
            $template = VipsPlugin::$template_factory->open('exercises/' . $view . '_' . $this->type);
            $template->exercise = $this;
            $template->solution = $solution;
            $template->response = $solution->response;
            $template->evaluation_mode = $assignment->options['evaluation_mode'];
    
            return $template;
        }
    
        /**
         * Create a template for solving an exercise.
         *
         * @return The template
         */
        function getSolveTemplate($solution, $assignment, $user_id)
        {
            return $this->getViewTemplate('solve', $solution, $assignment, $user_id);
        }
    
        /**
         * Create a template for correcting an exercise.
         *
         * @return The template
         */
        function getCorrectionTemplate($solution)
        {
            return $this->getViewTemplate('correct', $solution, $solution->assignment, $solution->user_id);
        }
    
        /**
         * Create a template for printing an exercise.
         *
         * @return The template
         */
        function getPrintTemplate($solution, $assignment, $user_id)
        {
            return $this->getViewTemplate('print', $solution, $assignment, $user_id);
        }
    
        /**
         * Get the name of this exercise type.
         */
        public function getTypeName()
        {
            return self::$exercise_types[$this->type]['name'];
        }
    
        /**
         * Get the list of supported exercise types.
         */
        public static function getExerciseTypes()
        {
            return self::$exercise_types;
        }
    
        /**
         * Register a new exercise type and class.
         */
        public static function addExerciseType($name, $class, $type = NULL)
        {
            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()
        {
            return [];
        }
    
        /**
         * Import a new exercise from text data array.
         */
        public static function importText($segment)
        {
            $all_keywords = ['Tipp'];
    
            foreach (self::$exercise_types as $key => $value) {
                $keywords = $key::getTextKeywords();
    
                if ($keywords) {
                    $all_keywords = array_merge($all_keywords, $keywords);
                    $types[$key] = array_shift($keywords);
                }
            }
    
            $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(_vips('Unbekannter Aufgabentyp: ') . $type);
            }
    
            $result = new $exercise_type();
            $result->initText($exercise);
            return $result;
        }
    
        /**
         * Import a new exercise from Vips XML format.
         */
        public static function importXML($exercise)
        {
            $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(_vips('Unbekannter Aufgabentyp: ') . $type);
            }
    
            if ($exercise_type == 'mc_exercise' && $exercise->items->item[0]->choices) {
                $exercise_type = 'mco_exercise';
            }
    
            $result = new $exercise_type();
            $result->initXML($exercise);
            return $result;
        }
    
        /**
         * Initialize this instance from the given text data array.
         */
        public function initText($exercise)
        {
            foreach ($exercise as $tag) {
                if (key($tag) === 'Name') {
                    $this->title = current($tag) ?: _vips('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)
        {
            $this->title = trim($exercise->title);
    
            if ($this->title === '') {
                $this->title = _vips('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, $files = NULL)
        {
            $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()
        {
            if (count($this->files) == 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);
        }
    
        /**
         * Scan the given string or array (recursively) for referenced file URLs
         * and rewrite those links into URNs suitable for XML export.
         */
        protected function rewriteLinksForExport($data)
        {
            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) {
                    list($url, $query) = explode('?', html_entity_decode($match[0]));
    
                    if (is_internal_url($url) && basename($url) === 'sendfile.php') {
                        parse_str($query, $query_params);
                        $file_id = $query_params['file_id'];
                        $file = VipsFile::find($file_id);
    
                        if ($file && $this->files->find($file_id)) {
                            return 'urn:vips:file-ref:file-' . $file_id;
                        }
    
                        if ($file_ref = FileRef::find($file_id)) {
                            $file_id = $file_ref->file_id;
                            $folder = $file_ref->folder->getTypedFolder();
    
                            if ($folder->isFileDownloadable($file_ref, $GLOBALS['user']->id)) {
                                if (!$this->files->find($file_id)) {
                                    $file = VipsFile::wrapStudipFile($file_ref->file);
                                    $this->files->append($file);
                                }
    
                                return 'urn:vips:file-ref:file-' . $file_id;
                            }
                        }
                    }
    
                    return $match[0];
                }, $data);
            }
    
            return $data;
        }
    }