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