Select Git revision
Exercise.php
Forked from
Uni Osnabrück / Plugins / Vips
Source project has a limited visibility.
-
Elmar Ludwig authoredElmar Ludwig authored
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Exercise.php 20.57 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
{
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'
];
$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['file_ids'] && !$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 = json_decode($value, true) ?: $this->task;
}
/**
* Store this exercise into the database.
*/
public function store()
{
$this->content['task_json'] = 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 && $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 a response for this exercise into an array of strings.
*/
public function exportResponse($response)
{
return array_values($response);
}
/**
* 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 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/' . $this->type . '/edit');
$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/' . $this->type . '/' . $view);
$template->exercise = $this;
$template->solution = $solution;
$template->response = $solution->response;
$template->evaluation_mode = $assignment->options['evaluation_mode'];
return $template;
}
/**
* Return a template for solving an exercise.
*/
public function getSolveTemplate($solution, $assignment, $user_id)
{
return $this->getViewTemplate('solve', $solution, $assignment, $user_id);
}
/**
* Return a template for correcting an exercise.
*/
public function getCorrectionTemplate($solution)
{
return $this->getViewTemplate('correct', $solution, $solution->assignment, $solution->user_id);
}
/**
* Return a template for printing an exercise.
*/
public 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 icon of this exercise type.
*/
public static function getTypeIcon($role = Icon::DEFAULT_ROLE)
{
return Icon::create('question-circle', $role);
}
/**
* Get a description of this exercise type.
*/
public static function getTypeDescription()
{
return '';
}
/**
* 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) {
$url = html_entity_decode($match[0]);
$url = preg_replace('%/download/(?:normal|force_download)/\d/(\w+)/.+%',
'/sendfile.php?file_id=$1', $url);
list($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 = 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;
}
}