Fix exam mode
[qomet.git] / public / javascripts / assessment.js
CommitLineData
cc7c0f5e
BA
1// TODO: if display == "all", les envois devraient être non définitifs (possibilité de corriger)
2// Et, blur sur une (sous-)question devrait envoyer la version courante de la sous-question
3
e99c53fb
BA
4let socket = null; //monitor answers in real time
5
cc7c0f5e
BA
6if (assessment.mode == "secure" && !checkWindowSize())
7 document.location.href= "/fullscreen";
e99c53fb 8
cc7c0f5e 9function checkWindowSize()
e99c53fb 10{
cc7c0f5e
BA
11 // NOTE: temporarily accept smartphone (security hole: pretend being a smartphone on desktop browser...)
12 if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry)/))
13 return true;
f03a2ad9
BA
14 // 3 is arbitrary, but a small tolerance is required (e.g. in Firefox)
15 return window.innerWidth >= screen.width-3 && window.innerHeight >= screen.height-3;
e99c53fb
BA
16};
17
cc7c0f5e 18new Vue({
e99c53fb
BA
19 el: "#assessment",
20 data: {
21 assessment: assessment,
22 inputs: [ ], //student's answers
23 student: { }, //filled later
24 // Stage 0: unauthenticated (number),
25 // 1: authenticated (got a name, unvalidated)
26 // 2: locked: password set, exam started
27 // 3: completed
28 // 4: show answers
29 stage: assessment.mode != "open" ? 0 : 1,
30 remainingTime: 0, //global, in seconds
f03a2ad9 31 warnMsg: "",
e99c53fb
BA
32 },
33 components: {
34 "statements": {
35 props: ['assessment','inputs','student','stage'],
e99c53fb
BA
36 // TODO: general render function for nested exercises
37 // TODO: with answer if stage==4 : class "wrong" if ticked AND stage==4 AND received answers
38 // class "right" if stage == 4 AND received answers (background-color: red / green)
39 // There should be a questions navigator below, or next (visible if display=='all')
40 // Full questions tree is rendered, but some parts hidden depending on display settings
41 render(h) {
42 let self = this;
f03a2ad9 43 let questions = (assessment.questions || [ ]).map( (q,i) => {
e99c53fb
BA
44 let questionContent = [ ];
45 questionContent.push(
46 h(
47 "div",
48 {
49 "class": {
cc7c0f5e 50 wording: true,
e99c53fb
BA
51 },
52 domProps: {
53 innerHTML: q.wording,
54 },
55 }
56 )
57 );
58 let optionsOrder = _.range(q.options.length);
59 if (!q.fixed)
60 optionsOrder = _.shuffle(optionsOrder);
61 let optionList = [ ];
62 optionsOrder.forEach( idx => {
63 let option = [ ];
64 option.push(
65 h(
66 "input",
67 {
68 domProps: {
f03a2ad9 69 checked: this.inputs.length > 0 && this.inputs[i][idx],
e99c53fb
BA
70 },
71 attrs: {
72 id: this.inputId(i,idx),
73 type: "checkbox",
74 },
75 on: {
76 change: e => { this.inputs[i][idx] = e.target.checked; },
77 },
78 },
79 )
80 );
81 option.push(
82 h(
83 "label",
84 {
85 domProps: {
86 innerHTML: q.options[idx],
87 },
88 attrs: {
89 "for": this.inputId(i,idx),
90 },
91 }
92 )
93 );
94 optionList.push(
95 h(
96 "div",
97 {
98 "class": {
99 option: true,
100 choiceCorrect: this.stage == 4 && assessment.questions[i].answer.includes(idx),
101 choiceWrong: this.stage == 4 && this.inputs[i][idx] && !assessment.questions[i].answer.includes(idx),
102 },
103 },
104 option
105 )
106 );
107 });
108 questionContent.push(
109 h(
110 "div",
111 {
112 "class": {
113 optionList: true,
114 },
115 },
116 optionList
117 )
118 );
119 return h(
120 "div",
121 {
122 "class": {
123 "question": true,
f03a2ad9 124 "hide": this.stage == 2 && assessment.display == 'one' && assessment.indices[assessment.index] != i,
e99c53fb
BA
125 },
126 },
127 questionContent
128 );
129 });
130 if (this.stage == 2)
131 {
e99c53fb
BA
132 questions.unshift(
133 h(
134 "button",
135 {
136 "class": {
137 "waves-effect": true,
138 "waves-light": true,
139 "btn": true,
140 },
cc7c0f5e
BA
141 style: {
142 "display": "block",
143 "margin-left": "auto",
144 "margin-right": "auto",
145 },
e99c53fb 146 on: {
f03a2ad9 147 click: () => this.sendAnswer(assessment.indices[assessment.index]),
e99c53fb
BA
148 },
149 },
150 "Send"
151 )
152 );
153 }
154 return h(
155 "div",
156 {
157 attrs: {
158 id: "statements",
159 },
160 },
161 questions
162 );
163 },
cc7c0f5e
BA
164 mounted: function() {
165 if (assessment.mode != "secure")
166 return;
167 window.addEventListener("keydown", e => {
168 // (Try to) Ignore F11 + F12 (avoid accidental window resize)
169 // NOTE: in Chromium at least, exiting fullscreen mode with F11 cannot be prevented.
170 // Workaround: disable key at higher level. Possible xbindkey config:
171 // "false"
172 // m:0x10 + c:95
173 // Mod2 + F11
174 if ([122,123].includes(e.keyCode))
175 e.preventDefault();
176 }, false);
177 window.addEventListener("blur", () => {
178 this.trySendCurrentAnswer();
179 document.location.href= "/noblur";
180 }, false);
181 window.addEventListener("resize", e => {
182 this.trySendCurrentAnswer();
183 document.location.href= "/fullscreen";
184 }, false);
185 },
e99c53fb 186 methods: {
e99c53fb
BA
187 inputId: function(i,j) {
188 return "q" + i + "_" + "input" + j;
189 },
cc7c0f5e
BA
190 trySendCurrentAnswer: function() {
191 if (this.stage == 2)
f03a2ad9 192 this.sendAnswer(assessment.indices[assessment.index]);
e99c53fb
BA
193 },
194 // stage 2
195 sendAnswer: function(realIndex) {
2c545c26
BA
196 let gotoNext = () => {
197 if (assessment.index == assessment.questions.length - 1)
198 this.$emit("gameover");
199 else
200 assessment.index++;
201 this.$forceUpdate(); //TODO: shouldn't be required
202 };
e99c53fb 203 if (assessment.mode == "open")
2c545c26 204 return gotoNext(); //only local
e99c53fb
BA
205 let answerData = {
206 aid: assessment._id,
207 answer: JSON.stringify({
208 index:realIndex.toString(),
209 input:this.inputs[realIndex]
210 .map( (tf,i) => { return {val:tf,idx:i}; } )
211 .filter( item => { return item.val; })
212 .map( item => { return item.idx; })
213 }),
214 number: this.student.number,
215 password: this.student.password,
216 };
217 $.ajax("/send/answer", {
218 method: "GET",
219 data: answerData,
220 dataType: "json",
221 success: ret => {
222 if (!!ret.errmsg)
f03a2ad9 223 return this.$emit("warning", ret.errmsg);
2c545c26
BA
224 else
225 gotoNext();
e99c53fb
BA
226 //socket.emit(message.newAnswer, answer);
227 },
228 });
229 },
e99c53fb
BA
230 },
231 },
232 },
e99c53fb
BA
233 computed: {
234 countdown: function() {
235 let seconds = this.remainingTime % 60;
236 let minutes = Math.floor(this.remainingTime / 60);
237 return this.padWithZero(minutes) + ":" + this.padWithZero(seconds);
238 },
239 },
f03a2ad9
BA
240 mounted: function() {
241 $(".modal").modal();
242 },
e99c53fb 243 methods: {
f03a2ad9
BA
244 // In case of AJAX errors
245 warning: function(message) {
246 this.warnMsg = message;
247 $("#warning").modal("open");
248 },
e99c53fb
BA
249 padWithZero: function(x) {
250 if (x < 10)
251 return "0" + x;
252 return x;
253 },
254 // stage 0 --> 1
255 getStudent: function(cb) {
256 $.ajax("/get/student", {
257 method: "GET",
258 data: {
259 number: this.student.number,
260 cid: assessment.cid,
261 },
262 dataType: "json",
263 success: s => {
264 if (!!s.errmsg)
f03a2ad9 265 return this.warning(s.errmsg);
e99c53fb
BA
266 this.stage = 1;
267 this.student = s.student;
268 Vue.nextTick( () => { Materialize.updateTextFields(); });
269 if (!!cb)
270 cb();
271 },
272 });
273 },
274 // stage 1 --> 0
275 cancelStudent: function() {
276 this.stage = 0;
277 },
278 // stage 1 --> 2 (get all questions, set password)
279 startAssessment: function() {
f03a2ad9 280 let initializeStage2 = (questions,paper) => {
e99c53fb
BA
281 $("#leftButton, #rightButton").hide();
282 if (assessment.time > 0)
283 {
f03a2ad9 284 this.remainingTime = assessment.time * 60 - (!!paper ? paper.startTime/1000 : 0);
e99c53fb
BA
285 this.runTimer();
286 }
287 // Initialize structured answer(s) based on questions type and nesting (TODO: more general)
288 if (!!questions)
289 assessment.questions = questions;
290 for (let q of assessment.questions)
291 this.inputs.push( _(q.options.length).times( _.constant(false) ) );
f03a2ad9
BA
292 if (!paper)
293 {
294 assessment.indices = assessment.fixed
295 ? _.range(assessment.questions.length)
296 : _.shuffle( _.range(assessment.questions.length) );
297 }
298 else
299 {
300 // Resuming
301 let indices = paper.inputs.map( input => { return input.index; });
302 let remainingIndices = _.difference(_.range(assessment.questions.length), indices);
303 assessment.indices = indices.concat( _.shuffle(remainingIndices) );
304 }
305 assessment.index = !!paper ? paper.inputs.length : 0;
e99c53fb 306 this.stage = 2;
cc7c0f5e
BA
307 Vue.nextTick( () => {
308 // Run Prism + MathJax on questions text
309 $("#statements").find("code[class^=language-]").each( (i,elem) => {
310 Prism.highlightElement(elem);
311 });
312 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"statements"]);
313 });
e99c53fb
BA
314 };
315 if (assessment.mode == "open")
316 return initializeStage2();
317 $.ajax("/start/assessment", {
318 method: "GET",
319 data: {
320 number: this.student.number,
321 aid: assessment._id
322 },
323 dataType: "json",
324 success: s => {
325 if (!!s.errmsg)
f03a2ad9
BA
326 return this.warning(s.errmsg);
327 if (!!s.paper)
328 {
329 // Resuming: receive stored answers + startTime
330 this.student.password = s.paper.password;
331 this.inputs = s.paper.inputs.map( inp => { return inp.input; });
332 }
333 else
334 {
335 this.student.password = s.password;
336 // Got password: students answers locked to this page until potential teacher
337 // action (power failure, computer down, ...)
338 }
e99c53fb
BA
339 // TODO: password also exchanged by sockets to check identity
340 //socket = io.connect("/" + assessment.name, {
341 // query: "number=" + this.student.number + "&password=" + this.password
342 //});
343 //socket.on(message.allAnswers, this.setAnswers);
cc7c0f5e 344 //socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red), redirect
f03a2ad9 345 initializeStage2(s.questions, s.paper);
e99c53fb
BA
346 },
347 });
348 },
349 // stage 2
350 runTimer: function() {
351 if (assessment.time <= 0)
352 return;
353 let self = this;
354 setInterval( function() {
355 self.remainingTime--;
356 if (self.remainingTime <= 0 || self.stage >= 4)
357 self.endAssessment();
358 clearInterval(this);
359 }, 1000);
360 },
e99c53fb 361 // stage 2 --> 3 (or 4)
cc7c0f5e 362 // from a message by statements component, or time over
e99c53fb 363 endAssessment: function() {
cc7c0f5e 364 // Set endTime, destroy password
e99c53fb 365 $("#leftButton, #rightButton").show();
cc7c0f5e 366 if (assessment.mode == "open")
e99c53fb 367 {
e99c53fb 368 this.stage = 4;
cc7c0f5e
BA
369 return;
370 }
371 $.ajax("/end/assessment", {
372 method: "GET",
373 data: {
374 aid: assessment._id,
375 number: this.student.number,
376 password: this.student.password,
377 },
378 dataType: "json",
379 success: ret => {
380 if (!!ret.errmsg)
f03a2ad9 381 return this.warning(ret.errmsg);
cc7c0f5e
BA
382 assessment.conclusion = ret.conclusion;
383 this.stage = 3;
384 delete this.student["password"]; //unable to send new answers now
385 //socket.disconnect();
386 //socket = null;
387 },
388 });
e99c53fb
BA
389 },
390 // stage 3 --> 4 (on socket message "feedback")
391 setAnswers: function(answers) {
392 for (let i=0; i<answers.length; i++)
393 assessment.questions[i].answer = answers[i];
394 this.stage = 4;
395 },
396 },
397});