Fix timing when reloading exam
[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 new Vue({
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
31 warnMsg: "",
32 },
33 components: {
34 "statements": {
35 props: ['assessment','inputs','student','stage'],
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;
43 let questions = (assessment.questions || [ ]).map( (q,i) => {
44 let questionContent = [ ];
45 questionContent.push(
46 h(
47 "div",
48 {
49 "class": {
50 wording: true,
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: {
69 checked: this.inputs.length > 0 && this.inputs[i][idx],
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,
124 "hide": this.stage == 2 && assessment.display == 'one' && assessment.indices[assessment.index] != i,
125 },
126 },
127 questionContent
128 );
129 });
130 if (this.stage == 2)
131 {
132 questions.unshift(
133 h(
134 "button",
135 {
136 "class": {
137 "waves-effect": true,
138 "waves-light": true,
139 "btn": true,
140 },
141 style: {
142 "display": "block",
143 "margin-left": "auto",
144 "margin-right": "auto",
145 },
146 on: {
147 click: () => this.sendAnswer(assessment.indices[assessment.index]),
148 },
149 },
150 "Send"
151 )
152 );
153 }
154 return h(
155 "div",
156 {
157 attrs: {
158 id: "statements",
159 },
160 },
161 questions
162 );
163 },
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 },
186 methods: {
187 inputId: function(i,j) {
188 return "q" + i + "_" + "input" + j;
189 },
190 trySendCurrentAnswer: function() {
191 if (this.stage == 2)
192 this.sendAnswer(assessment.indices[assessment.index]);
193 },
194 // stage 2
195 sendAnswer: function(realIndex) {
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 };
203 if (assessment.mode == "open")
204 return gotoNext(); //only local
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)
223 return this.$emit("warning", ret.errmsg);
224 else
225 gotoNext();
226 //socket.emit(message.newAnswer, answer);
227 },
228 });
229 },
230 },
231 },
232 },
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 },
240 mounted: function() {
241 $(".modal").modal();
242 },
243 methods: {
244 // In case of AJAX errors
245 warning: function(message) {
246 this.warnMsg = message;
247 $("#warning").modal("open");
248 },
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)
265 return this.warning(s.errmsg);
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() {
280 let initializeStage2 = (questions,paper) => {
281 $("#leftButton, #rightButton").hide();
282 if (assessment.time > 0)
283 {
284 const deltaTime = !!paper ? Date.now() - paper.startTime : 0;
285 this.remainingTime = assessment.time * 60 - Math.round(deltaTime / 1000);
286 this.runTimer();
287 }
288 // Initialize structured answer(s) based on questions type and nesting (TODO: more general)
289 if (!!questions)
290 assessment.questions = questions;
291 for (let q of assessment.questions)
292 this.inputs.push( _(q.options.length).times( _.constant(false) ) );
293 if (!paper)
294 {
295 assessment.indices = assessment.fixed
296 ? _.range(assessment.questions.length)
297 : _.shuffle( _.range(assessment.questions.length) );
298 }
299 else
300 {
301 // Resuming
302 let indices = paper.inputs.map( input => { return input.index; });
303 let remainingIndices = _.difference(_.range(assessment.questions.length), indices);
304 assessment.indices = indices.concat( _.shuffle(remainingIndices) );
305 }
306 assessment.index = !!paper ? paper.inputs.length : 0;
307 this.stage = 2;
308 Vue.nextTick( () => {
309 // Run Prism + MathJax on questions text
310 $("#statements").find("code[class^=language-]").each( (i,elem) => {
311 Prism.highlightElement(elem);
312 });
313 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"statements"]);
314 });
315 };
316 if (assessment.mode == "open")
317 return initializeStage2();
318 $.ajax("/start/assessment", {
319 method: "GET",
320 data: {
321 number: this.student.number,
322 aid: assessment._id
323 },
324 dataType: "json",
325 success: s => {
326 if (!!s.errmsg)
327 return this.warning(s.errmsg);
328 if (!!s.paper)
329 {
330 // Resuming: receive stored answers + startTime
331 this.student.password = s.paper.password;
332 this.inputs = s.paper.inputs.map( inp => { return inp.input; });
333 }
334 else
335 {
336 this.student.password = s.password;
337 // Got password: students answers locked to this page until potential teacher
338 // action (power failure, computer down, ...)
339 }
340 // TODO: password also exchanged by sockets to check identity
341 //socket = io.connect("/" + assessment.name, {
342 // query: "number=" + this.student.number + "&password=" + this.password
343 //});
344 //socket.on(message.allAnswers, this.setAnswers);
345 //socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red), redirect
346 initializeStage2(s.questions, s.paper);
347 },
348 });
349 },
350 // stage 2
351 runTimer: function() {
352 if (assessment.time <= 0)
353 return;
354 let self = this;
355 setInterval( function() {
356 self.remainingTime--;
357 if (self.remainingTime <= 0 || self.stage >= 4)
358 self.endAssessment();
359 clearInterval(this);
360 }, 1000);
361 },
362 // stage 2 --> 3 (or 4)
363 // from a message by statements component, or time over
364 endAssessment: function() {
365 // Set endTime, destroy password
366 $("#leftButton, #rightButton").show();
367 if (assessment.mode == "open")
368 {
369 this.stage = 4;
370 return;
371 }
372 $.ajax("/end/assessment", {
373 method: "GET",
374 data: {
375 aid: assessment._id,
376 number: this.student.number,
377 password: this.student.password,
378 },
379 dataType: "json",
380 success: ret => {
381 if (!!ret.errmsg)
382 return this.warning(ret.errmsg);
383 assessment.conclusion = ret.conclusion;
384 this.stage = 3;
385 delete this.student["password"]; //unable to send new answers now
386 //socket.disconnect();
387 //socket = null;
388 },
389 });
390 },
391 // stage 3 --> 4 (on socket message "feedback")
392 setAnswers: function(answers) {
393 for (let i=0; i<answers.length; i++)
394 assessment.questions[i].answer = answers[i];
395 this.stage = 4;
396 },
397 },
398 });