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