Skip to content

Commit

Permalink
groups of questions
Browse files Browse the repository at this point in the history
Questions are now organised in groups, so each group can pick its
questions separately. You could use this to offer a variety of similar
questions, only showing one to the student.

There's an option to show the names of question groups in the navigation
menu and in the score breakdown on the results page.

see numbas/editor#315
  • Loading branch information
christianp committed Nov 15, 2016
1 parent 3657f22 commit 10b244f
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 81 deletions.
62 changes: 46 additions & 16 deletions bin/exam.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,6 @@ class Exam:
name = '' #title of exam
duration = 0 #allowed time for exam, in seconds
percentPass = 0 #percentage classified as a pass
shuffleQuestions = False #randomise question order?
allQuestions = True #use all questions?
pickQuestions = 0 #if not using all questions, how many questions to use
showactualmark = True #show student's score to student?
showtotalmark = True #show total marks available to student?
showanswerstate = True #show right/wrong on questions?
Expand All @@ -131,6 +128,7 @@ class Exam:
adviceGlobalThreshold = 0 #reveal advice if student scores less than this percentage
intro = '' #text shown on the front page
feedbackMessages = []
showQuestionGroupNames = False # show the names of question groups?


def __init__(self,name='Untitled Exam'):
Expand All @@ -156,7 +154,7 @@ def __init__(self,name='Untitled Exam'):
self.functions = []
self.variables = []

self.questions = []
self.question_groups = []

self.resources = []
self.extensions = []
Expand All @@ -170,7 +168,7 @@ def fromstring(string):
@staticmethod
def fromDATA(data):
exam = Exam()
tryLoad(data,['name','duration','percentPass','shuffleQuestions','allQuestions','pickQuestions','resources','extensions'],exam)
tryLoad(data,['name','duration','percentPass','resources','extensions','showQuestionGroupNames'],exam)

if haskey(data,'navigation'):
nav = data['navigation']
Expand Down Expand Up @@ -215,9 +213,10 @@ def fromDATA(data):
variables = data['variables']
for variable in variables.keys():
exam.variables.append(Variable(variables[variable]))
if haskey(data,'questions'):
for question in data['questions']:
exam.questions.append(Question.fromDATA(question))
if haskey(data,'question_groups'):
print(data['question_groups'])
for question in data['question_groups']:
exam.question_groups.append(QuestionGroup.fromDATA(question))

return exam

Expand All @@ -236,7 +235,7 @@ def toxml(self):
],
['functions'],
['variables'],
['questions']
['question_groups'],
])
root.attrib = {
'name': strcons(self.name),
Expand Down Expand Up @@ -296,15 +295,13 @@ def toxml(self):
for function in self.functions:
functions.append(function.toxml())

questions = root.find('questions')
questions.attrib = {
'shuffle': strcons_fix(self.shuffleQuestions),
'all': strcons_fix(self.allQuestions),
'pick': strcons_fix(self.pickQuestions),
question_groups = root.find('question_groups')
question_groups.attrib = {
'showQuestionGroupNames': strcons(self.showQuestionGroupNames),
}

for q in self.questions:
questions.append(q.toxml())
for qg in self.question_groups:
question_groups.append(qg.toxml())

return root

Expand Down Expand Up @@ -376,6 +373,39 @@ def toxml(self):

return feedbackmessage

class QuestionGroup:
name = ''
pickingStrategy = 'all-ordered' # 'all-ordered', ''all-shuffled', 'random-subset'
pickQuestions = 0

def __init__(self):
self.questions = []

@staticmethod
def fromDATA(data):
qg = QuestionGroup()
print(data)
tryLoad(data,['name','pickingStrategy','pickQuestions'],qg)

if 'questions' in data:
for q in data['questions']:
qg.questions.append(Question.fromDATA(q))

return qg

def toxml(self):
qg = makeTree(['question_group',['questions']])
qg.attrib = {
'name': strcons(self.name),
'pickingStrategy': strcons(self.pickingStrategy),
'pickQuestions': strcons(self.pickQuestions),
}
questions = qg.find('questions')
for q in self.questions:
questions.append(q.toxml())

return qg

class Question:
name = 'Untitled Question'
statement =''
Expand Down
22 changes: 22 additions & 0 deletions bin/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,25 @@ def show_precision_hint(part):

if part['type']=='numberentry':
part['showPrecisionHint'] = False

@migration(version_from='show_precision_hint')
def exam_question_groups(data):
allQuestions = data.get('allQuestions',True)
pickQuestions = data.get('pickQuestions',0)
shuffleQuestions = data.get('shuffleQuestions',False)
if shuffleQuestions:
if pickQuestions>0:
pickingStrategy = 'random-subset'
else:
pickingStrategy = 'all-shuffled'
else:
pickingStrategy = 'all-ordered'

data['showQuestionGroupNames'] = False

data['question_groups'] = [{
'name': '',
'pickingStrategy': pickingStrategy,
'pickQuestions': pickQuestions,
'questions': data.get('questions',[]),
}]
139 changes: 101 additions & 38 deletions runtime/scripts/exam.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Copyright 2011-14 Newcastle University

Numbas.queueScript('exam',['base','timing','util','xml','display','schedule','storage','scorm-storage','math','question','jme-variables','jme-display','jme'],function() {
var job = Numbas.schedule.add;
var util = Numbas.util;

/** Keeps track of all info we need to know while exam is running.
*
Expand Down Expand Up @@ -93,8 +94,6 @@ function Exam()
}
this.feedbackMessages.sort(function(a,b){ var ta = a.threshold, tb = b.threshold; return ta>tb ? 1 : ta<tb ? -1 : 0});

settings.numQuestions = xml.selectNodes('questions/question').length;

var scopes = [
Numbas.jme.builtinScope
];
Expand Down Expand Up @@ -150,6 +149,15 @@ function Exam()
this.scope.rulesets[name] = Numbas.jme.collectRuleset(sets[name],this.scope.rulesets);
}

// question groups
tryGetAttribute(settings,xml,'question_groups',['showQuestionGroupNames']);
var groupNodes = this.xml.selectNodes('question_groups/question_group');
this.question_groups = [];
for(var i=0;i<groupNodes.length;i++) {
this.question_groups.push(new QuestionGroup(this,groupNodes[i]));
}


//initialise display
this.display = new Numbas.display.ExamDisplay(this);

Expand All @@ -176,6 +184,7 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
* @property {boolean} showAnswerState - tell student if answer is correct/wrong/partial?
* @property {boolean} allowRevealAnswer - allow 'reveal answer' button?
* @property {number} adviceGlobalThreshold - if student scores lower than this percentage on a question, the advice is displayed
* @property {boolean} showQuestionGroupNames - show the names of question groups?
*/
settings: {

Expand All @@ -198,6 +207,7 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
showAnswerState: false,
allowRevealAnswer: false,
adviceGlobalThreshold: 0,
showQuestionGroupNames: false
},

/** Base node of exam XML
Expand Down Expand Up @@ -261,6 +271,11 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
* @type Numbas.Question
*/
currentQuestion: undefined,

/** Groups of questions in the exam
* @type QuestionGroup[]
*/
question_groups: [],

/**
* Which questions are used?
Expand Down Expand Up @@ -326,6 +341,7 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
var variablesTodo = Numbas.xml.loadVariables(exam.xml,exam.scope);
var result = Numbas.jme.variables.makeVariables(variablesTodo,exam.scope);
exam.scope.variables = result.variables;

job(exam.chooseQuestionSubset,exam); //choose questions to use
job(exam.makeQuestionList,exam); //create question objects
job(Numbas.store.init,Numbas.store,exam); //initialise storage
Expand All @@ -339,8 +355,14 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
var suspendData = Numbas.store.load(this); //get saved info from storage

job(function() {
this.questionSubset = suspendData.questionSubset;
this.settings.numQuestions = this.questionSubset.length;
var e = this;
var numQuestions = 0;
suspendData.questionSubsets.forEach(function(subset,i) {
e.question_groups[i].questionSubset = subset;
numQuestions += subset.length;
});
this.settings.numQuestions = numQuestions;

this.start = new Date(suspendData.start);
if(this.settings.allowPause) {
this.timeRemaining = this.settings.duration - (suspendData.duration-suspendData.timeRemaining);
Expand All @@ -361,31 +383,20 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
},this);
},


/** Decide which questions to use and in what order */
/** Decide which questions to use and in what order
* @see Numbas.QuestionGroup#chooseQuestionSubset
*/
chooseQuestionSubset: function()
{
//get all questions out of XML
var tmpQuestionList = new Array();

//shuffle questions?
this.questionSubset = [];
if(this.settings.shuffleQuestions)
{
this.questionSubset = Numbas.math.deal(this.settings.numQuestions);
}
else //otherwise just pick required number of questions from beginning of list
{
this.questionSubset = Numbas.math.range(this.settings.numQuestions);
}
if(!this.settings.allQuestions) {
this.questionSubset = this.questionSubset.slice(0,this.settings.pickQuestions);
this.settings.numQuestions = this.settings.pickQuestions;
}

if(this.questionSubset.length==0)
{
Numbas.display.showAlert("This exam contains no questions! Check the .exam file for errors.");
var numQuestions = 0;
this.question_groups.forEach(function(group) {
group.chooseQuestionSubset();
numQuestions += group.questionSubset.length;
});
this.settings.numQuestions = numQuestions;

if(numQuestions==0) {
throw(new Numbas.Error('exam.changeQuestion.no questions'));
}
},

Expand All @@ -394,23 +405,29 @@ Exam.prototype = /** @lends Numbas.Exam.prototype */ {
*
* If loading, need to restore randomised variables instead of generating anew
*
* @param {boolean} [loading=true]
* @param {boolean} lo
*/
makeQuestionList: function(loading)
{
var exam = this;
this.questionList = [];
var questionAcc = 0;

this.question_groups.forEach(function(group) {
group.questionList = [];
var questionNodes = group.xml.selectNodes("questions/question");
group.questionSubset.forEach(function(n) {
job(function(n) {
var question = new Numbas.Question( exam, group, questionNodes[n], questionAcc++, loading, exam.scope );
exam.questionList.push(question);
group.questionList.push(question);
},group,n);
});
});

var questions = this.xml.selectNodes("questions/question");
for(var i = 0; i<this.questionSubset.length; i++)
{
job(function(i)
{
var question = new Numbas.Question( this, questions[this.questionSubset[i]], i, loading, this.scope );
this.questionList.push(question);
},this,i);
}

job(function() {
this.settings.numQuestions = this.questionList.length;

//register questions with exam display
this.display.initQuestionList();

Expand Down Expand Up @@ -814,4 +831,50 @@ ExamEvent.prototype = /** @lends Numbas.ExamEvent.prototype */ {
message: ''
};

/** Represents a group of questions
*
* @constructor
* @property {Numbas.Exam} exam - the exam this group belongs to
* @property {Element} xml
* @property {number[]} questionSubset - the indices of the picked questions, in the order they should appear to the student
* @property {Question[]} questionList
* @memberof Numbas
*/
function QuestionGroup(exam, groupNode) {
this.exam = exam;
this.xml = groupNode;

this.settings = util.copyobj(this.settings);
Numbas.xml.tryGetAttribute(this.settings,this.xml,'.',['name','pickingStrategy','pickQuestions']);
}
QuestionGroup.prototype = {
/** Settings for this group
* @property {string} name
* @property {string} pickingStrategy - how to pick the list of questions: 'all-ordered', 'all-shuffled' or 'random-subset'
* @property {number} pickQuestions - if `pickingStrategy` is 'random-subset', how many questions to pick
*/
settings: {
name: '',
pickingStrategy: 'all-ordered',
pickQuestions: 1
},

/** Decide which questions to use and in what order */
chooseQuestionSubset: function() {
var questionNodes = this.xml.selectNodes('questions/question');
var numQuestions = questionNodes.length;
switch(this.settings.pickingStrategy) {
case 'all-ordered':
this.questionSubset = Numbas.math.range(numQuestions);
break;
case 'all-shuffled':
this.questionSubset = Numbas.math.deal(numQuestions);
break;
case 'random-subset':
this.questionSubset = Numbas.math.deal(numQuestions).slice(0,this.settings.pickQuestions);
break;
}
}
}

});
4 changes: 3 additions & 1 deletion runtime/scripts/question.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,18 @@ var tryGetAttribute = Numbas.xml.tryGetAttribute;
* @constructor
* @memberof Numbas
* @param {Numbas.Exam} exam - parent exam
* @param {Numbas.QuestionGroup} group - group this question belongs to
* @param {Element} xml
* @param {number} number - index of this question in the exam (starting at 0)
* @param {boolean} loading - is this question being resumed from an existing session?
* @param {Numbas.jme.Scope} gscope - global JME scope
*/
var Question = Numbas.Question = function( exam, xml, number, loading, gscope)
var Question = Numbas.Question = function( exam, group, xml, number, loading, gscope)
{
var question = this;
var q = question;
q.exam = exam;
q.group = group;
q.adviceThreshold = q.exam.adviceGlobalThreshold;
q.xml = xml;
q.originalXML = q.xml;
Expand Down
Loading

0 comments on commit 10b244f

Please sign in to comment.