Adding nanogong question type in exercises see BT#3593

skala
Julio Montoya 14 years ago
parent 45c979e3e6
commit 528cfd47c2
  1. 21
      main/css/base.css
  2. 7
      main/exercice/answer_admin.inc.php
  3. 69
      main/exercice/exercise.class.php
  4. 38
      main/exercice/exercise.lib.php
  5. 9
      main/exercice/exercise_show.php
  6. 1
      main/exercice/export/qti/qti_classes.php
  7. 1
      main/exercice/export/qti2/qti2_classes.php
  8. 1
      main/exercice/export/qti2/qti2_export.php
  9. 1
      main/exercice/export/scorm/scorm_classes.php
  10. 1
      main/exercice/export/scorm/scorm_export.php
  11. 1
      main/exercice/mark_free_answer.php
  12. 75
      main/exercice/oral_expression.class.php
  13. 105
      main/exercice/question.class.php
  14. 2
      main/exercice/question_admin.inc.php
  15. 11
      main/exercice/question_create.php
  16. 1
      main/exercice/question_list_admin.inc.php
  17. 24
      main/exercice/question_pool.php
  18. BIN
      main/img/audio_question.png
  19. BIN
      main/img/icons/32/audio_question.png
  20. 2
      main/inc/lib/add_course.lib.inc.php
  21. 14
      main/inc/lib/events.lib.inc.php
  22. 36
      main/inc/lib/exercise_show_functions.lib.php
  23. 656
      main/inc/lib/nanogong.lib.php

@ -4303,3 +4303,24 @@ a:active{
margin-right: auto;
width: 120px;
}
/* Nanogong - exercise player*/
.nanogong_player_container {
width:370px;
margin: 0 auto;
}
.nanogong_player {
float:left;
}
.action_player {
float:right;
width:120px;
}
.audio_preview_container {
margin-left: 50px;
}

@ -85,6 +85,13 @@ if($modifyIn)
$free_comment=$comment;
$weighting=unserialize($weighting);
}
elseif($answerType == ORAL_EXPRESSION )
{
$reponse=unserialize($reponse);
$comment=unserialize($comment);
$free_comment=$comment;
$weighting=unserialize($weighting);
}
elseif ($answerType == HOT_SPOT || $answerType == HOT_SPOT_ORDER || $answerType == HOT_SPOT_DELINEATION)
{

@ -1867,9 +1867,33 @@ class Exercise {
if ($debug) error_log('$answerType : '.$answerType);
if ($answerType == FREE_ANSWER) {
if ($answerType == FREE_ANSWER || $answerType == ORAL_EXPRESSION) {
$nbrAnswers = 1;
}
$nano = null;
if ($answerType == ORAL_EXPRESSION) {
require_once api_get_path(LIBRARY_PATH).'nanogong.lib.php';
$exe_info = get_exercise_results_by_attempt($exeId);
$exe_info = $exe_info[$exeId];
$params = array();
$params['course_id'] = api_get_course_int_id();
$params['session_id'] = api_get_session_id();
$params['user_id'] = isset($exe_info['exe_user_id'])? $exe_info['exe_user_id'] : api_get_user_id();
$params['exercise_id'] = isset($exe_info['exe_exo_id'])? $exe_info['exe_exo_id'] : $this->id;
$params['question_id'] = $questionId;
$params['exe_id'] = isset($exe_info['exe_id']) ? $exe_info['exe_id'] : $exeId;
$nano = new Nanogong($params);
//probably this attempt came in an exercise all question by page
if ($feedback_type == 0) {
$nano->replace_with_real_exe($exeId);
}
}
$user_answer = '';
// Get answer list for matching
@ -2012,7 +2036,6 @@ class Exercise {
$real_answers[$answerId] = false;
}
}
break;
case MULTIPLE_ANSWER_COMBINATION:
if ($from_database) {
@ -2218,6 +2241,31 @@ class Exercise {
}
}
break;
case ORAL_EXPRESSION :
if ($from_database) {
$query = "SELECT answer, marks FROM ".$TBL_TRACK_ATTEMPT." WHERE exe_id = '".$exeId."' AND question_id= '".$questionId."'";
$resq = Database::query($query);
$choice = Database::result($resq,0,'answer');
$choice = str_replace('\r\n', '', $choice);
$choice = stripslashes($choice);
$questionScore = Database::result($resq,0,"marks");
if ($questionScore==-1) {
$totalScore+=0;
} else {
$totalScore+=$questionScore;
}
$arrques[] = $questionName;
$arrans[] = $choice;
} else {
$studentChoice = $choice;
if ($studentChoice) {
//Fixing negative puntation see #2193
$questionScore = 0;
$totalScore += 0;
}
}
break;
// for matching
case MATCHING :
if ($from_database) {
@ -2384,6 +2432,13 @@ class Exercise {
if($origin != 'learnpath') {
ExerciseShowFunctions::display_free_answer($choice,0,0);
}
} elseif($answerType == ORAL_EXPRESSION) {
// to store the details of open questions in an array to be used in mail
$arrques[] = $questionName;
$arrans[] = $choice;
if($origin != 'learnpath') {
ExerciseShowFunctions::display_oral_expression_answer($choice, 0, 0, $nano);
}
} elseif($answerType == HOT_SPOT) {
if ($origin != 'learnpath') {
ExerciseShowFunctions::display_hotspot_answer($answerId, $answer, $studentChoice, $answerComment);
@ -2593,6 +2648,12 @@ class Exercise {
</tr>
</table>';
break;
case ORAL_EXPRESSION:
echo '<tr>
<td valign="top">'.ExerciseShowFunctions::display_oral_expression_answer($choice, $exeId, $questionId, $nano).'</td>
</tr>
</table>';
break;
case HOT_SPOT:
ExerciseShowFunctions::display_hotspot_answer($answerId, $answer, $studentChoice, $answerComment);
break;
@ -2999,6 +3060,10 @@ class Exercise {
} elseif ($answerType == FREE_ANSWER) {
$answer = $choice;
exercise_attempt($questionScore, $answer, $quesId, $exeId, 0, $this->id);
} elseif ($answerType == ORAL_EXPRESSION) {
$answer = $choice;
exercise_attempt($questionScore, $answer, $quesId, $exeId, 0, $this->id, $nano);
} elseif ($answerType == UNIQUE_ANSWER || $answerType == UNIQUE_ANSWER_NO_OPTION) {
$answer = $choice;
exercise_attempt($questionScore, $answer, $quesId, $exeId, 0, $this->id);

@ -143,7 +143,43 @@ function showQuestion($questionId, $only_questions = false, $origin = false, $cu
$s .= '<tr><td colspan="3">';
$s .= $oFCKeditor->CreateHtml();
$s .= '</td></tr>';
}
} elseif ($answerType == ORAL_EXPRESSION) {
//Add nanog
if (api_get_setting('enable_nanogong') == 'true') {
require_once api_get_path(LIBRARY_PATH).'nanogong.lib.php';
//@todo pass this as a parameter
global $exercise_stat_info, $exerciseId,$exe_id;
if (!empty($exercise_stat_info)) {
$params = array(
'exercise_id' => $exercise_stat_info['exe_exo_id'],
'exe_id' => $exercise_stat_info['exe_id'],
'question_id' => $questionId
);
} else {
$params = array(
'exercise_id' => $exerciseId,
'exe_id' => 'temp_exe',
'question_id' => $questionId
);
}
$nano = new Nanogong($params);
echo $nano->show_button();
}
$oFCKeditor = new FCKeditor("choice[".$questionId."]") ;
$oFCKeditor->ToolbarSet = 'TestFreeAnswer';
$oFCKeditor->Width = '100%';
$oFCKeditor->Height = '150';
$oFCKeditor->ToolbarStartExpanded = false;
$oFCKeditor->Value = '' ;
$s .= '<tr><td colspan="3">';
$s .= $oFCKeditor->CreateHtml();
$s .= '</td></tr>';
}
// Now navigate through the possible answers, using the max number of
// answers for the question as a limiter

@ -318,6 +318,11 @@ foreach ($questionList as $questionId) {
$question_result = $objExercise->manage_answer($id, $questionId, $choice,'exercise_show', array(), false, true, $show_results, $objExercise->selectPropagateNeg());
$questionScore = $question_result['score'];
$totalScore += $question_result['score'];
} elseif ($answerType == ORAL_EXPRESSION) {
$answer = $str;
$question_result = $objExercise->manage_answer($id, $questionId, $choice,'exercise_show', array(), false, true, $show_results, $objExercise->selectPropagateNeg());
$questionScore = $question_result['score'];
$totalScore += $question_result['score'];
} elseif ($answerType == MATCHING) {
$question_result = $objExercise->manage_answer($id, $questionId, $choice,'exercise_show', array(), false, true, $show_results, $objExercise->selectPropagateNeg());
$questionScore = $question_result['score'];
@ -500,7 +505,7 @@ foreach ($questionList as $questionId) {
<br />
<a href="javascript://" onclick="showfck('<?php echo $name; ?>','<?php echo $marksname; ?>');">
<?php
if ($answerType == FREE_ANSWER) {
if (in_array($answerType, array(FREE_ANSWER, ORAL_EXPRESSION))) {
echo get_lang('EditCommentsAndMarks');
} else {
if ($action=='edit') {
@ -543,7 +548,7 @@ foreach ($questionList as $questionId) {
}
if ($is_allowedToEdit) {
if ($answerType == FREE_ANSWER) {
if (in_array($answerType, array(FREE_ANSWER, ORAL_EXPRESSION))) {
$marksname = "marksName".$questionId;
?>
<div id="<?php echo $marksname; ?>" style="display:none">

@ -36,6 +36,7 @@ define('FIB', 3);
define('MATCHING', 4);
define('FREE_ANSWER', 5);
define('HOTSPOT', 6);
define('ORAL_EXPRESSION', 13);
/**
*
* @package chamilo.exercise

@ -20,6 +20,7 @@ define('FIB', 3);
define('MATCHING', 4);
define('FREE_ANSWER', 5);
define('HOTSPOT', 6);
define('ORAL_EXPRESSION', 13);
if (!function_exists('mime_content_type')) {
require_once api_get_path(LIBRARY_PATH).'document.lib.php';

@ -21,6 +21,7 @@ define(MATCHING, 4);
define(FREE_ANSWER, 5);
define(HOT_SPOT, 6);
define(HOT_SPOT_ORDER, 7);
define('ORAL_EXPRESSION', 13);
/**
* An IMS/QTI item. It corresponds to a single question.
* This class allows export from Claroline to IMS/QTI2.0 XML format of a single question.

@ -36,6 +36,7 @@ define('HOTSPOT', 6);
define('HOT_SPOT_ORDER', 7);
define('HOT_SPOT_DELINEATION', 8);
define('MULTIPLE_ANSWER_COMBINATION', 9);
define('ORAL_EXPRESSION', 13);
/**

@ -21,6 +21,7 @@ define(MATCHING, 4);
define(FREE_ANSWER, 5);
define(HOT_SPOT, 6);
define(HOT_SPOT_ORDER, 7);
define('ORAL_EXPRESSION', 13);
/**
* A SCORM item. It corresponds to a single question.
* This class allows export from Dokeos SCORM 1.2 format of a single question.

@ -30,6 +30,7 @@ define('FILL_IN_BLANKS', 3);
define('MATCHING', 4);
define('FREE_ANSWER', 5);
define('MULTIPLE_ANSWER_COMBINATION', 9);
define('ORAL_EXPRESSION', 13);
//debug param. 0: no display - 1: debug display
$debug=0;

@ -0,0 +1,75 @@
<?php
/* For licensing terms, see /license.txt */
/**
* File containing the FreeAnswer class.
* This class allows to instantiate an object of type FREE_ANSWER,
* extending the class question
* @package chamilo.exercise
* @author Eric Marguin
* @version $Id: admin.php 10680 2007-01-11 21:26:23Z pcool $
*/
/**
* Code
*/
if(!class_exists('OralExpression')):
/**
* @package chamilo.exercise
*/
class OralExpression extends Question {
static $typePicture = 'audio_question.png';
static $explanationLangVar = 'OralExpression';
/**
* Constructor
*/
function OralExpression(){
parent::question();
$this -> type = ORAL_EXPRESSION;
$this -> isContent = $this-> getIsContent();
}
/**
* function which redifines Question::createAnswersForm
* @param the formvalidator instance
*/
function createAnswersForm ($form) {
$form -> addElement('text','weighting',get_lang('Weighting'),'size="5"');
global $text, $class;
// setting the save button here and not in the question class.php
$form->addElement('style_submit_button','submitQuestion',$text, 'class="'.$class.'"');
if (!empty($this->id)) {
$form -> setDefaults(array('weighting' => float_format($this->weighting, 1)));
} else {
if ($this -> isContent == 1) {
$form -> setDefaults(array('weighting' => '10'));
}
}
}
/**
* abstract function which creates the form to create / edit the answers of the question
* @param the formvalidator instance
*/
function processAnswersCreation($form) {
$this -> weighting = $form -> getSubmitValue('weighting');
$this->save();
}
function return_header($feedback_type, $counter = null) {
parent::return_header($feedback_type, $counter);
$header = '<table width="100%" border="0" cellspacing="3" cellpadding="3">
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td><i>'.get_lang("Answer").'</i> </td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>';
return $header;
}
}
endif;

@ -26,6 +26,7 @@ define('MULTIPLE_ANSWER_COMBINATION', 9);
define('UNIQUE_ANSWER_NO_OPTION', 10);
define('MULTIPLE_ANSWER_TRUE_FALSE', 11);
define('MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE', 12);
define('ORAL_EXPRESSION', 13);
if (!class_exists('Category')) include_once("testcategory.class.php"); // hub 12-10-2011
@ -61,13 +62,13 @@ abstract class Question
FILL_IN_BLANKS => array('fill_blanks.class.php' , 'FillBlanks'),
MATCHING => array('matching.class.php' , 'Matching'),
FREE_ANSWER => array('freeanswer.class.php' , 'FreeAnswer'),
ORAL_EXPRESSION => array('oral_expression.class.php' , 'OralExpression'),
HOT_SPOT => array('hotspot.class.php' , 'HotSpot'),
HOT_SPOT_DELINEATION => array('hotspot.class.php' , 'HotspotDelineation'),
MULTIPLE_ANSWER_COMBINATION => array('multiple_answer_combination.class.php' , 'MultipleAnswerCombination'),
UNIQUE_ANSWER_NO_OPTION => array('unique_answer_no_option.class.php' , 'UniqueAnswerNoOption'),
MULTIPLE_ANSWER_TRUE_FALSE => array('multiple_answer_true_false.class.php' , 'MultipleAnswerTrueFalse'),
MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE => array('multiple_answer_combination_true_false.class.php' , 'MultipleAnswerCombinationTrueFalse'),
MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE => array('multiple_answer_combination_true_false.class.php' , 'MultipleAnswerCombinationTrueFalse')
);
/**
@ -115,7 +116,6 @@ abstract class Question
$course_id = $course_info['real_id'];
$TBL_EXERCICES = Database::get_course_table(TABLE_QUIZ_TEST);
$TBL_QUESTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION);
$TBL_EXERCICE_QUESTION = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
@ -127,29 +127,31 @@ abstract class Question
if ($object = Database::fetch_object($result)) {
$objQuestion = Question::getInstance($object->type);
if (!empty($objQuestion)) {
$objQuestion->id = $id;
$objQuestion->question = $object->question;
$objQuestion->description = $object->description;
$objQuestion->weighting = $object->ponderation;
$objQuestion->position = $object->position;
$objQuestion->type = $object->type;
$objQuestion->picture = $object->picture;
$objQuestion->level = (int) $object->level;
$objQuestion->extra = $object->extra;
$objQuestion->course = $course_info;
$objQuestion->category = Testcategory::getCategoryForQuestion($id); // hub 12-10-2011
$sql = "SELECT exercice_id FROM $TBL_EXERCICE_QUESTION WHERE c_id = $course_id AND question_id = $id";
$result_exercise_list = Database::query($sql);
// fills the array with the exercises which this question is in
if ($result_exercise_list) {
while ($obj = Database::fetch_object($result_exercise_list)) {
$objQuestion->exerciseList[] = $obj->exercice_id;
}
}
return $objQuestion;
$objQuestion->id = $id;
$objQuestion->question = $object->question;
$objQuestion->description = $object->description;
$objQuestion->weighting = $object->ponderation;
$objQuestion->position = $object->position;
$objQuestion->type = $object->type;
$objQuestion->picture = $object->picture;
$objQuestion->level = (int) $object->level;
$objQuestion->extra = $object->extra;
$objQuestion->course = $course_info;
$objQuestion->category = Testcategory::getCategoryForQuestion($id); // hub 12-10-2011
$sql = "SELECT exercice_id FROM $TBL_EXERCICE_QUESTION WHERE c_id = $course_id AND question_id = $id";
$result_exercise_list = Database::query($sql);
// fills the array with the exercises which this question is in
if ($result_exercise_list) {
while ($obj = Database::fetch_object($result_exercise_list)) {
$objQuestion->exerciseList[] = $obj->exercice_id;
}
}
return $objQuestion;
}
}
// question not found
return false;
@ -1039,23 +1041,39 @@ abstract class Question
$this->exportPicture($new_question_id, $course_info);
return $new_question_id;
}
function get_question_type($type) {
if ($type == ORAL_EXPRESSION && api_get_setting('enable_nanogong') != 'true') {
return null;
}
return self::$questionTypes[$type];
}
function get_question_type_list() {
if (api_get_setting('enable_nanogong') != 'true') {
self::$questionTypes[ORAL_EXPRESSION] = null;
unset(self::$questionTypes[ORAL_EXPRESSION]);
}
return self::$questionTypes;
}
/**
* Returns an instance of the class corresponding to the type
* @param integer $type the type of the question
* @return an instance of a Question subclass (or of Questionc class by default)
*/
static function getInstance ($type) {
if (!is_null($type)) {
list($file_name,$class_name) = self::$questionTypes[$type];
include_once($file_name);
if (class_exists($class_name)) {
return new $class_name();
} else {
echo 'Can\'t instanciate class '.$class_name.' of type '.$type;
return null;
}
static function getInstance($type) {
if (!is_null($type)) {
list($file_name, $class_name) = self::get_question_type($type);
if (!empty($file_name)) {
include_once $file_name;
if (class_exists($class_name)) {
return new $class_name();
} else {
echo 'Can\'t instanciate class '.$class_name.' of type '.$type;
}
}
}
return null;
}
/**
@ -1254,7 +1272,7 @@ abstract class Question
$course_id = api_get_course_int_id();
// 1. by default we show all the question types
$question_type_custom_list = self::$questionTypes;
$question_type_custom_list = self::get_question_type_list();
if (!isset($feedbacktype)) $feedbacktype=0;
if ($feedbacktype==1) {
@ -1320,14 +1338,6 @@ abstract class Question
echo '</div></li>';
echo '</ul>';
}
static function get_types_information(){
return self::$questionTypes;
}
static function updateId() {
return self::$questionTypes;
}
static function saveQuestionOption($question_id, $name, $course_id, $position = 0) {
$TBL_EXERCICE_QUESTION_OPTION = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
@ -1419,9 +1429,10 @@ abstract class Question
*
*/
public function get_type_icon_html() {
$type = $this->selectType();
$tabQuestionList = Question::get_types_information(); // [0]=file to include [1]=type name
require_once($tabQuestionList[$type][0]);
$type = $this->selectType();
$tabQuestionList = Question::get_question_type_list(); // [0]=file to include [1]=type name
require_once $tabQuestionList[$type][0];
eval('$img = '.$tabQuestionList[$type][1].'::$typePicture;');
eval('$explanation = get_lang('.$tabQuestionList[$type][1].'::$explanationLangVar);');
return array($img, $explanation);

@ -54,7 +54,7 @@ if(is_object($objQuestion)) {
$type = $_REQUEST['answerType'];
}
$types_information = $objQuestion->get_types_information();
$types_information = $objQuestion->get_question_type_list();
$form_title_extra = get_lang($types_information[$type][1]);
// form title

@ -69,8 +69,8 @@ $form->addRule('question_type_hidden', get_lang('InvalidQuestionType'), 'validqu
if ($form->validate()) {
$values = $form->exportValues();
foreach (Question::$questionTypes as $question_type_id => $question_type_class_and_name) {
$question_list = Question::get_question_type_list();
foreach ($question_list as $question_type_id => $question_type_class_and_name) {
if (get_lang($question_type_class_and_name[1]) == $values['question_type_hidden']) {
$answer_type = $question_type_id;
}
@ -125,8 +125,10 @@ $pictures_question_types[9] = 'mcmac.gif';
$pictures_question_types[10] = 'mcuao.gif';
$pictures_question_types[11] = 'mcmao.gif';
$pictures_question_types[12] = 'mcmaco.gif';
$pictures_question_types[13] = 'audio_question.png';
$question_list = Question::get_question_type_list();
foreach (Question::$questionTypes as $key=>$value) {
foreach ($question_list as $key=>$value) {
if ($key != HOT_SPOT_DELINEATION ) { // DELINEATION hide
?>
ddlObj1.addItem('<table width="100%"><tr><td style="width: 37px;" valign="top"><?php Display::display_icon($pictures_question_types[$key],addslashes(get_lang($value[1])),array('height'=>'40px;', 'style' => 'vertical-align:top; cursor:hand;')); ?></td><td><span class="thistext" style="cursor:hand"><?php echo addslashes(get_lang($value[1])); ?></span><br/><sub><?php /*echo addslashes(get_lang($value[1].'Comment'));*/ ?></sub></td></tr></table>','');
@ -137,7 +139,8 @@ foreach (Question::$questionTypes as $key=>$value) {
</script>
<?php
function check_question_type($parameter) {
foreach (Question::$questionTypes as $key=>$value) {
$question_list = Question::get_question_type_list();
foreach ($question_list as $key=>$value) {
$valid_question_types[] = get_lang($value[1]);
//$valid_question_types[] = trim($value[1]);
}

@ -203,7 +203,6 @@ if (!$inATest) {
// Question type
$tabQuestionList = Question::get_types_information();
list($typeImg, $typeExpl) = $objQuestionTmp->get_type_icon_html();
$questionType = Display::tag('div', Display::return_icon($typeImg, $typeExpl, array(), 32), array('style'=>$styleType));

@ -374,7 +374,8 @@ echo Display::form_row(get_lang('Difficulty'), $select_difficulty_html);
// Answer type
$question_list = Question::get_types_information();
$question_list = Question::get_question_type_list();
$new_question_list = array();
$new_question_list['-1'] = get_lang('All');
$objExercise = new Exercise();
@ -617,8 +618,16 @@ $data = array();
foreach ($main_question_list as $tabQuestion) {
$row = array();
//This function checks if the question can be read
$question_type = get_question_type_for_question($selected_course, $tabQuestion['id']);
if (empty($question_type)) {
continue;
}
$row[] = get_a_tag_for_question($questionTagA, $fromExercise, $tabQuestion['id'], $tabQuestion['type'], $tabQuestion['question']);
$row[] = get_question_type_for_question($selected_course, $tabQuestion['id']);
$row[] = $question_type;
$row[] = get_question_categorie_for_question($selected_course, $tabQuestion['id']);
$row[] = $tabQuestion['level'];
$row[] = get_action_icon_for_question($actionIcon1, $fromExercise, $tabQuestion['id'], $tabQuestion['type'],
@ -763,10 +772,13 @@ function get_action_icon_for_question($in_action, $from_exercice, $in_questionid
// return the icon for the question type
// hubert.borderiou 13-10-2011
function get_question_type_for_question($in_selectedcourse, $in_questionid) {
$myObjQuestion = Question::read($in_questionid, $in_selectedcourse);
list($typeImg, $typeExpl) = $myObjQuestion->get_type_icon_html();
$questionType = Display::tag('div', Display::return_icon($typeImg, $typeExpl, array(), 32), array());
unset($myObjQuestion);
$myObjQuestion = Question::read($in_questionid, $in_selectedcourse);
$questionType = null;
if (!empty($myObjQuestion)) {
list($typeImg, $typeExpl) = $myObjQuestion->get_type_icon_html();
$questionType = Display::tag('div', Display::return_icon($typeImg, $typeExpl, array(), 32), array());
unset($myObjQuestion);
}
return $questionType;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -147,6 +147,8 @@ function prepare_course_repository($course_repository, $course_code) {
mkdir(api_get_path(SYS_COURSE_PATH).$course_repository . '/work', $perm);
mkdir(api_get_path(SYS_COURSE_PATH).$course_repository . '/upload/announcements', $perm);
mkdir(api_get_path(SYS_COURSE_PATH).$course_repository . '/upload/announcements/images', $perm);
//Oral expression question type
mkdir(api_get_path(SYS_COURSE_PATH).$course_repository . '/exercises', $perm);
// Create .htaccess in the dropbox directory.
$fp = fopen(api_get_path(SYS_COURSE_PATH).$course_repository . '/dropbox/.htaccess', 'w');

@ -448,7 +448,7 @@ function create_event_exercice($exo_id) {
* @param integer Position
* @return boolean Result of the insert query
*/
function exercise_attempt($score, $answer, $quesId, $exeId, $j, $exercise_id = 0) {
function exercise_attempt($score, $answer, $quesId, $exeId, $j, $exercise_id = 0, $nano = null) {
require_once api_get_path(SYS_CODE_PATH).'exercice/exercise.lib.php';
$score = Database::escape_string($score);
$answer = Database::escape_string($answer);
@ -472,9 +472,14 @@ function exercise_attempt($score, $answer, $quesId, $exeId, $j, $exercise_id = 0
} else {
// anonymous
$user_id = api_get_anonymous_id();
}
$file = '';
if (isset($nano)) {
$file = basename($nano->load_filename_if_exists(false));
}
$sql = "INSERT INTO $TBL_TRACK_ATTEMPT (exe_id, user_id, question_id, answer, marks, course_code, session_id, position, tms )
$sql = "INSERT INTO $TBL_TRACK_ATTEMPT (exe_id, user_id, question_id, answer, marks, course_code, session_id, position, tms, filename)
VALUES (
".$exeId.",
".$user_id.",
@ -484,8 +489,9 @@ function exercise_attempt($score, $answer, $quesId, $exeId, $j, $exercise_id = 0
'".api_get_course_id()."',
'".api_get_session_id()."',
'".$j."',
'".$reallyNow."'
)";
'".$reallyNow."',
'".$file."'
)";
//error_log($sql);
if (!empty($quesId) && !empty($exeId) && !empty($user_id)) {
$res = Database::query($sql);

@ -87,6 +87,42 @@ class ExerciseShowFunctions {
echo '</tr>';
}
}
function display_oral_expression_answer($answer,$id,$questionId, $nano = null) {
global $feedback_type;
if (isset($nano)) {
echo $nano->show_audio_file();
}
if (empty($id)) {
echo '<tr>';
echo Display::tag('td',nl2br(Security::remove_XSS($answer,COURSEMANAGERLOWSECURITY)), array('width'=>'55%'));
echo '</tr>';
if ($feedback_type != EXERCISE_FEEDBACK_TYPE_EXAM) {
echo '<tr>';
echo Display::tag('td',get_lang('notCorrectedYet'), array('width'=>'45%'));
echo '</tr>';
} else {
echo '<tr><td>&nbsp;</td></tr>';
}
} else {
echo '<tr>';
echo '<td>';
if (!empty($answer)) {
echo nl2br(Security::remove_XSS($answer,COURSEMANAGERLOWSECURITY));
}
echo '</td>';
if(!api_is_allowed_to_edit(null,true) && $feedback_type != EXERCISE_FEEDBACK_TYPE_EXAM) {
echo '<td>';
$comm = get_comments($id,$questionId);
echo '</td>';
}
echo '</tr>';
}
}
/**
* Displays the answer to a hotspot question

@ -0,0 +1,656 @@
<?php
/* For licensing terms, see /license.txt */
/*
*
* Files are saved in the path:
*
* courses/XXX/exercises/(session_id)/(exercise_id)/(question_id)/(user_id)/
*
* The file name is composed with
*
* (course_id)/(session_id)/(user_id)/(exercise_id)/(question_id)/(exe_id).wav|mp3|ogg
*
*
*/
class Nanogong {
var $filename;
var $store_filename;
var $store_path;
var $params;
var $can_edit = false;
/* Files allowed to upload */
var $available_extensions = array('mp3', 'wav', 'ogg');
public function __construct($params = array()) {
$this->set_parameters($params);
}
function create_user_folder() {
//COURSE123/exercises/session_id/exercise_id/question_id/user_id
if (empty($this->store_path)) {
return false;
}
//@todo use an array to create folders
$folders_to_create = array();
//Trying to create the courses/COURSE123/exercises/ dir just in case
if (!is_dir($this->store_path)) {
mkdir($this->store_path);
}
if (!is_dir($this->store_path.$this->session_id)) {
mkdir($this->store_path.$this->session_id);
}
if (!empty($this->exercise_id) && !is_dir($this->store_path.$this->session_id.'/'.$this->exercise_id)) {
mkdir($this->store_path.$this->session_id.'/'.$this->exercise_id);
}
if (!empty($this->question_id) && !is_dir($this->store_path.$this->session_id.'/'.$this->exercise_id.'/'.$this->question_id)) {
mkdir($this->store_path.$this->session_id.'/'.$this->exercise_id.'/'.$this->question_id);
}
if (!empty($this->user_id) && !is_dir($this->store_path.$this->session_id.'/'.$this->exercise_id.'/'.$this->question_id.'/'.$this->user_id)) {
mkdir($this->store_path.$this->session_id.'/'.$this->exercise_id.'/'.$this->question_id.'/'.$this->user_id);
}
}
/**
* Setting parameters: course id, session id, etc
* @param array
*/
function set_parameters($params = array()) {
//Setting course id
if (isset($params['course_id'])) {
$this->course_id = intval($params['course_id']);
} else {
$this->course_id = $params['course_id'] = api_get_course_int_id();
}
//Setting course info
if (isset($this->course_id)) {
$this->course_info = api_get_course_info_by_id($this->course_id);
}
//Setting session id
if (isset($params['session_id'])) {
$this->session_id = intval($params['session_id']);
} else {
$this->session_id = $params['session_id'] = api_get_session_id();
}
//Setting user ids
if (isset($params['user_id'])) {
$this->user_id = intval($params['user_id']);
} else {
$this->user_id = $params['user_id'] = api_get_user_id();
}
//Setting user ids
if (isset($params['exercise_id'])) {
$this->exercise_id = intval($params['exercise_id']);
} else {
$this->exercise_id = 0;
}
//Setting user ids
if (isset($params['question_id'])) {
$this->question_id = intval($params['question_id']);
} else {
$this->question_id = 0;
}
$this->can_edit = false;
if (api_is_allowed_to_edit()) {
$this->can_edit = true;
} else {
if ($this->user_id == api_get_user_id()) {
$this->can_edit = true;
}
}
//Settings the params array
$this->params = $params;
$this->store_path = api_get_path(SYS_COURSE_PATH).$this->course_info['path'].'/exercises/';
$this->create_user_folder();
$this->store_path = $this->store_path.implode('/', array($this->session_id, $this->exercise_id, $this->question_id, $this->user_id)).'/';
$this->filename = $this->generate_filename();
$this->store_filename = $this->store_path.$this->filename;
}
/**
* Generates the filename with the next format:
* (course_id)/(session_id)/(user_id)/(exercise_id)/(question_id)/(exe_id)
*
* @return string
*/
function generate_filename() {
if (!empty($this->params)) {
//filename
//course_id/session_id/user_id/exercise_id/question_id/exe_id
$filename_array = array($this->params['course_id'], $this->params['session_id'], $this->params['user_id'], $this->params['exercise_id'], $this->params['question_id'], $this->params['exe_id']);
return implode('-', $filename_array);
} else {
return api_get_unique_id();
}
}
/**
* Delete audio file
* @return number
*/
function delete_files() {
$delete_found = 0;
if ($this->can_edit) {
$file = $this->load_filename_if_exists();
$path_info = pathinfo($file);
foreach($this->available_extensions as $extension) {
$file_to_delete = $path_info['dirname'].'/'.$path_info['filename'].'.'.$extension;
if (is_file($file_to_delete)) {
unlink($file_to_delete);
$delete_found = 1;
}
}
}
return $delete_found;
}
/**
*
* Tricky stuff to deal with the feedback = 0 in exercises (all question per page)
* @param unknown_type $exe_id
*/
function replace_with_real_exe($exe_id) {
$filename = null;
//@ugly fix
foreach($this->available_extensions as $extension) {
$items = explode('-', $this->filename);
$items[5] = 'temp_exe';
$filename = implode('-', $items);
if (is_file($this->store_path.$filename.'.'.$extension)) {
$old_name = $this->store_path.$filename.'.'.$extension;
$items = explode('-', $this->filename);
$items[5] = $exe_id;
$filename = $filename = implode('-', $items);
$new_name = $this->store_path.$filename.'.'.$extension;
//var_dump($old_name, $new_name);
rename($old_name, $new_name);
break;
}
}
}
function load_filename_if_exists($load_from_database = false) {
$filename = null;
//@ugly fix
foreach($this->available_extensions as $extension) {
if (is_file($this->store_path.$this->filename.'.'.$extension)) {
$filename = $this->filename.'.'.$extension;
break;
}
}
//temp_exe
if ($load_from_database) {
//Load the real filename just if exists
if (isset($this->params['exe_id']) && isset($this->params['user_id']) && isset($this->params['question_id']) && isset($this->params['session_id']) && isset($this->params['course_id'])) {
$attempt_table = Database::get_statistic_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
$sql = "SELECT filename FROM $attempt_table
WHERE exe_id = ".$this->params['exe_id']." AND
user_id = ".$this->params['user_id']." AND
question_id = ".$this->params['question_id']." AND
session_id = ".$this->params['session_id']." AND
course_code = '".$this->course_info['code']."' LIMIT 1";
$result = Database::query($sql);
$result = Database::fetch_row($result,'ASSOC');
if (isset($result) && isset($result[0]) && !empty($result[0])) {
$filename = $result[0];
}
}
}
if (is_file($this->store_path.$filename)) {
return $this->store_path.$filename;
}
return null;
}
/**
*
* Get the URL of the file
* path courses/XXX/exercises/(session_id)/(exercise_id)/(question_id)/(user_id)/
*
* @return string
*/
function get_public_url($force_download = 0) {
$params = $this->get_params(true);
$url = api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=get_file&download='.$force_download.'&'.$params;
$params = $this->get_params();
$filename = basename($this->load_filename_if_exists());
$url = api_get_path(WEB_COURSE_PATH).$this->course_info['code'].'/exercises/'.
$params['session_id'].'/'.$params['exercise_id'].'/'.$params['question_id'].'/'.$params['user_id'].'/'.$filename;
return $url;
}
/**
* Uploads the nanogong wav file
*/
public function upload_file($is_nano = false) {
require_once api_get_path(LIBRARY_PATH).'fileDisplay.lib.php';
require_once api_get_path(LIBRARY_PATH).'document.lib.php';
require_once api_get_path(LIBRARY_PATH).'fileUpload.lib.php';
if (!empty($_FILES)) {
$upload_ok = process_uploaded_file($_FILES['file'], false);
if (!is_uploaded_file($_FILES['file']['tmp_name'])) {
return 0;
}
if ($upload_ok) {
// Check if there is enough space to save the file
if (!DocumentManager::enough_space($_FILES['file']['size'], DocumentManager::get_course_quota())) {
return 0;
}
//first we delete everything before uploading the file
$this->delete_files();
//Reload the filename variable
$file_name = add_ext_on_mime($_FILES['file']['name'], $_FILES['file']['type']);
$file_name = strtolower($file_name);
$file_info = pathinfo($file_name);
if ($is_nano == true) {
$file_info['extension'] = 'wav';
}
$file_name = $this->filename.'.'.$file_info['extension'];
if (in_array($file_info['extension'], $this->available_extensions)) {
if (move_uploaded_file($_FILES['file']['tmp_name'], $this->store_path.$file_name)) {
$this->store_filename = $this->store_path.$file_name;
//error_log('saved');
return 1;
}
}
}
}
return 0;
}
/**
* Show the audio file + a button to download
*
*/
public function show_audio_file($show_delete_button = false) {
$html = '&nbsp;&nbsp;';
$file_path = $this->load_filename_if_exists();
if (!empty($file_path)) {
$url = $this->get_public_url(true);
$actions = Display::url(Display::return_icon('save.png', get_lang('Download'), array(), 22), $url, array('target'=>'_blank'));
$download_button = Display::url(get_lang('Download'), $url, array('class' =>'a_button gray medium'));
if ($show_delete_button) {
$actions .= ' '.Display::url(Display::return_icon('delete.png', get_lang('Delete'), array(), 22), "#", array('onclick'=>'delete_file();'));
}
$basename = basename($file_path);
$path_info = pathinfo($basename);
if ($path_info['extension'] == 'wav') {
$html .= '<script>
$(document).ready( function() {
var java_enabled = navigator.javaEnabled();
if (java_enabled) {
$("#nanogong_warning").hide();
$("#nanogong_player_id").show();
} else {
$("#nanogong_warning").show();
$("#nanogong_player_id").hide();
}
});
</script>';
$html .= '<div id="nanogong_player_id" class="nanogong_player_container">';
$html .= '<div class="action_player">'.$actions.'</div>';
$html .= '<div class="nanogong_player">';
$html .= '<applet id="nanogong_player" archive="'.api_get_path(WEB_LIBRARY_PATH).'nanogong/nanogong.jar" code="gong.NanoGong" width="250" height="40" ALIGN="middle">';
$html .= '<param name="ShowRecordButton" value="false" />'; // default true
$html .= '<param name="ShowSaveButton" value="false" />'; //you can save in local computer | (default true)
//echo '<param name="ShowSpeedButton" value="false" />'; // default true
//echo '<param name="ShowAudioLevel" value="false" />'; // it displays the audiometer | (default true)
$html .= '<param name="ShowTime" value="true" />'; // default false
$html .= '<param name="Color" value="#FFFFFF" />';
//echo '<param name="StartTime" value="10.5" />';
//echo '<param name="EndTime" value="65" />';
$html .= '<param name="AudioFormat" value="ImaADPCM" />';// ImaADPCM (more speed), Speex (more compression)|(default Speex)
//$html .= '<param name="AudioFormat" value="Speex" />';// ImaADPCM (more speed), Speex (more compression)|(default Speex)
//Quality for ImaADPCM (low 8000, medium 11025, normal 22050, hight 44100) OR Quality for Speex (low 8000, medium 16000, normal 32000, hight 44100) | (default 44100)
//echo '<param name="SamplingRate" value="32000" />';
//echo '<param name="MaxDuration" value="60" />';
$html .= '<param name="SoundFileURL" value="'.$url.'" />';//load a file |(default "")
$html .= '</applet>';
$html .= '</div>';
$html .= '</div>';
$html .= '<div id="nanogong_warning">'.Display::return_message(get_lang('BrowserNotSupportNanogongListen'),'warning').$download_button.'</div>';
} elseif(in_array($path_info['extension'],array('mp3', 'ogg','wav'))) {
$js_path = api_get_path(WEB_LIBRARY_PATH).'javascript/';
$html .= '<link rel="stylesheet" href="'.$js_path.'jquery-jplayer/skins/blue/jplayer.blue.monday.css" type="text/css">';
$html .= '<script type="text/javascript" src="'.$js_path.'jquery-jplayer/jquery.jplayer.min.js"></script>';
$html .= '<div class="nanogong_player"></div>';
$html .= '<br /><div class="action_player">'.$actions.'</div><br /><br /><br />';
$jquery .= ' $("#audio_preview").jPlayer({
ready: function() {
$(this).jPlayer("setMedia", {
'.$path_info['extension'].' : "'.$url.'"
});
},
swfPath: "'.$js_path.'jquery-jplayer",
supplied: "mp3, ogg, oga, wav",
solution: "flash, html", // Do not change this setting otherwise
width:0,
height:0,
});';
$html .= '<script type="text/javascript">
$(document).ready( function() {
//Experimental changes to preview mp3, ogg files
'.$jquery.'
});
</script>';
//@todo fix this
$html .= '
<div id="audio_preview" class="jp-jplayer"></div>
<div class="jp-audio audio_preview_container">
<div class="jp-type-single">
<div id="jp_interface_1" class="jp-interface">
<ul class="jp-controls">
<li><a href="#" class="jp-play" tabindex="1">play</a></li>
<li><a href="#" class="jp-pause" tabindex="1">pause</a></li>
<li><a href="#" class="jp-stop" tabindex="1">stop</a></li>
<li><a href="#" class="jp-mute" tabindex="1">mute</a></li>
<li><a href="#" class="jp-unmute" tabindex="1">unmute</a></li>
</ul>
<div class="jp-progress">
<div class="jp-seek-bar">
<div class="jp-play-bar"></div>
</div>
</div>
<div class="jp-volume-bar">
<div class="jp-volume-bar-value"></div>
</div>
<div class="jp-current-time"></div>
<div class="jp-duration"></div>
</div>
<div id="jp_playlist_1" class="jp-playlist">
</div>
</div>
</div>
<br />';
}
return $html;
}
}
/*
var filename = document.getElementById("audio_title").value+".wav";
var filename = filename.replace(/\s/g, "_");//replace spaces by _
var filename = encodeURIComponent(filename);
var filepath="'.urlencode($filepath).'";
var dir="'.urlencode($dir).'";
var course_code="'.urlencode($course_code).'";
var urlnanogong="'.$url.'?filename="+filename+"&filepath="+filepath+"&dir="+dir+"&course_code="+course_code;
*/
/**
* Returns the nanogong javascript code
* @return string
*/
function return_js() {
$params = $this->get_params(true);
$url = api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=save_file&'.$params.'&is_nano=1';
$url_load_file = api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=show_audio&'.$params;
$url_delete = api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=delete&'.$params;
$js = '<script language="javascript">
//lang vars
var lang_no_applet = "'.get_lang('NanogongNoApplet').'";
var lang_record_before_save = "'.get_lang('NanogongRecordBeforeSave').'";
var lang_give_a_title = "'.get_lang('NanogongGiveTitle').'";
var lang_failed_to_submit = "'.get_lang('NanogongFailledToSubmit').'";
var lang_submitted = "'.get_lang('NanogongSubmitted').'";
var lang_deleted = "'.get_lang('Deleted').'";
var is_nano = 0;
function check_gong() {
//var record = document.getElementById("nanogong");
var recorder;
var java_enabled = navigator.javaEnabled()
return java_enabled;
}
$(document).ready( function() {
$("#no_nanogong_div").hide();
$("#nanogong_div").hide();
var check_js = check_gong();
if (check_js == true) {
$("#nanogong_div").show();
$("#no_nanogong_div").hide();
is_nano = 1;
$(".nanogong_player").show();
} else {
$("#no_nanogong_div").show();
$("#nanogong_div").hide();
$(".nanogong_player").hide();
}
//show always the mp3/ogg upload form (for dev purposes)
//$("#no_nanogong_div").show();
//$("#nanogong_div").hide();
});
function delete_file() {
$.ajax({
url: "'.$url_delete.'",
success:function(data) {
$("#status_warning").hide();
$("#status_ok").hide();
$("#messages").html(data);
$("#messages").show();
$("#preview").hide();
}
});
}
function upload_file() {
$("#form_nanogong_simple").submit();
}
function send_voice() {
$("#status_warning").hide();
$("#status_ok").hide();
$("#messages").hide();
var check_js = check_gong();
//check
if (!check_js) {
$("#status_warning").html(lang_no_applet);
$("#status_warning").show();
return false;
}
var recorder = document.getElementById("nanogong");
if (!recorder || !check_js) {
//alert(lang_no_applet)
$("#status_warning").html(lang_no_applet);
$("#status_warning").show();
return false;
}
var duration = parseInt(recorder.sendGongRequest("GetMediaDuration", "audio")) || 0;
if (duration <= 0) {
$("#status_warning").html(lang_record_before_save);
$("#status_warning").show();
return false;
}
var applet = document.getElementById("nanogong");
var ret = applet.sendGongRequest("PostToForm", "'.$url.'", "file", "", "temp"); // PostToForm, postURL, inputname, cookie, filename
if (ret == 1) {
$("#status_ok").html(lang_submitted);
$("#status_ok").show();
$.ajax({
url:"'.$url_load_file.'&is_nano="+is_nano,
success: function(data){
$("#preview").html(data);
$("#preview").show();
}
});
} else {
//alert(lang_submitted+"\n"+ret);
$("#status_warning").html(lang_failed_to_submit);
$("#status_warning").show();
}
return false;
}
</script>';
return $js;
}
/**
* Returns the HTML form to upload a nano file or upload a file
*/
function return_form($message = null) {
$params = $this->get_params(true);
$url = api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=save_file&'.$params;
//check browser support and load form
$array_browser = api_browser_support('check_browser');
$preview_file = $this->show_audio_file(true, true);
$preview_file = Display::div($preview_file, array('id' => 'preview', 'style' => 'text-align:center;'));
$html .= '<center>';
//Use normal upload file
$html .= Display::return_icon('microphone.png', get_lang('PressRecordButton'),'','128');
$html .='<br />';
$html .= '<div id="no_nanogong_div">';
$html .= Display::return_message(get_lang('BrowserNotSupportNanogongSend'), 'warning');
$html .= '<form id="form_nanogong_simple" action="'.$url.'" name="form_nanogong" method="POST" enctype="multipart/form-data">';
$html .= '<input type="file" name="file">';
$html .= '<a href="#" class="a_button white medium" onclick="upload_file()" />'.get_lang('UploadFile').'</a>';
$html .= '</form></div>';
$html .= '<div id="nanogong_div">';
$html .= '<applet id="nanogong" archive="'.api_get_path(WEB_LIBRARY_PATH).'nanogong/nanogong.jar" code="gong.NanoGong" width="250" height="40" align="middle">';
//echo '<param name="ShowRecordButton" value="false" />'; // default true
// echo '<param name="ShowSaveButton" value="false" />'; //you can save in local computer | (default true)
//echo '<param name="ShowSpeedButton" value="false" />'; // default true
//echo '<param name="ShowAudioLevel" value="false" />'; // it displays the audiometer | (default true)
$html .= '<param name="ShowTime" value="true" />'; // default false
$html .= '<param name="Color" value="#FFFFFF" />'; // default #FFFFFF
//echo '<param name="StartTime" value="10.5" />';
//echo '<param name="EndTime" value="65" />';
$html .= '<param name="AudioFormat" value="ImaADPCM" />';// ImaADPCM (more speed), Speex (more compression)|(default Speex)
//$html .= '<param name="AudioFormat" value="Speex" />';// ImaADPCM (more speed), Speex (more compression)|(default Speex)
//echo '<param name="SamplingRate" value="32000" />';//Quality for ImaADPCM (low 8000, medium 11025, normal 22050, hight 44100) OR Quality for Speex (low 8000, medium 16000, normal 32000, hight 44100) | (default 44100)
//echo '<param name="MaxDuration" value="60" />';
//echo '<param name="SoundFileURL" value="http://somewhere.com/mysoundfile.wav" />';//load a file |(default "")
$html .= '</applet>';
$html .= '<br /><br /><br /><form name="form_nanogong_advanced">';
$html .= '<input type="hidden" name="is_nano" value="1">';
$html .= '<a href="#" class="a_button white medium" onclick="send_voice()" />'.get_lang('SendRecord').'</a>';
$html .= '</form></div>';
$html .= '</center>';
$html .= '<div style="display:none" id="status_ok" class="confirmation-message"></div><div style="display:none" id="status_warning" class="warning-message"></div>';
$html .= '<div id="messages">'.$message.'</div>';
$html .= $preview_file;
return $html;
}
function get_params($return_as_query = false) {
if (empty($this->params)) {
return false;
}
if ($return_as_query) {
return http_build_query($this->params);
}
return $this->params;
}
function get_param_value($attribute) {
if (isset($this->params[$attribute])) {
return $this->params[$attribute];
}
}
/**
* Show a button to load the form
* @return string
*/
function show_button() {
$params_string = $this->get_params(true);
$html .= '<br />'.Display::url(get_lang('RecordAnswer'),api_get_path(WEB_AJAX_PATH).'nanogong.ajax.php?a=show_form&'.$params_string.'&TB_iframe=true&height=350&width=500',
array('class'=>'a_button white thickbox'));
$html .= '<br /><br />'.Display::return_message(get_lang('UseTheMessageBelowToAddSomeComments'));
return $html;
}
}
Loading…
Cancel
Save