Prevent student from re-starting the same quiz, implement resuming logic (still buggish)
[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) {
f03a2ad9
BA
196 console.log(realIndex);
197 if (assessment.index == assessment.questions.length - 1)
e99c53fb
BA
198 this.$emit("gameover");
199 else
f03a2ad9
BA
200 assessment.index++;
201 this.$forceUpdate(); //TODO: shouldn't be required
e99c53fb
BA
202 if (assessment.mode == "open")
203 return; //only local
204 let answerData = {
205 aid: assessment._id,
206 answer: JSON.stringify({
207 index:realIndex.toString(),
208 input:this.inputs[realIndex]
209 .map( (tf,i) => { return {val:tf,idx:i}; } )
210 .filter( item => { return item.val; })
211 .map( item => { return item.idx; })
212 }),
213 number: this.student.number,
214 password: this.student.password,
215 };
216 $.ajax("/send/answer", {
217 method: "GET",
218 data: answerData,
219 dataType: "json",
220 success: ret => {
221 if (!!ret.errmsg)
f03a2ad9 222 return this.$emit("warning", ret.errmsg);
e99c53fb
BA
223 //socket.emit(message.newAnswer, answer);
224 },
225 });
226 },
e99c53fb
BA
227 },
228 },
229 },
e99c53fb
BA
230 computed: {
231 countdown: function() {
232 let seconds = this.remainingTime % 60;
233 let minutes = Math.floor(this.remainingTime / 60);
234 return this.padWithZero(minutes) + ":" + this.padWithZero(seconds);
235 },
236 },
f03a2ad9
BA
237 mounted: function() {
238 $(".modal").modal();
239 },
e99c53fb 240 methods: {
f03a2ad9
BA
241 // In case of AJAX errors
242 warning: function(message) {
243 this.warnMsg = message;
244 $("#warning").modal("open");
245 },
e99c53fb
BA
246 padWithZero: function(x) {
247 if (x < 10)
248 return "0" + x;
249 return x;
250 },
251 // stage 0 --> 1
252 getStudent: function(cb) {
253 $.ajax("/get/student", {
254 method: "GET",
255 data: {
256 number: this.student.number,
257 cid: assessment.cid,
258 },
259 dataType: "json",
260 success: s => {
261 if (!!s.errmsg)
f03a2ad9 262 return this.warning(s.errmsg);
e99c53fb
BA
263 this.stage = 1;
264 this.student = s.student;
265 Vue.nextTick( () => { Materialize.updateTextFields(); });
266 if (!!cb)
267 cb();
268 },
269 });
270 },
271 // stage 1 --> 0
272 cancelStudent: function() {
273 this.stage = 0;
274 },
275 // stage 1 --> 2 (get all questions, set password)
276 startAssessment: function() {
f03a2ad9 277 let initializeStage2 = (questions,paper) => {
e99c53fb
BA
278 $("#leftButton, #rightButton").hide();
279 if (assessment.time > 0)
280 {
f03a2ad9 281 this.remainingTime = assessment.time * 60 - (!!paper ? paper.startTime/1000 : 0);
e99c53fb
BA
282 this.runTimer();
283 }
284 // Initialize structured answer(s) based on questions type and nesting (TODO: more general)
285 if (!!questions)
286 assessment.questions = questions;
287 for (let q of assessment.questions)
288 this.inputs.push( _(q.options.length).times( _.constant(false) ) );
f03a2ad9
BA
289 if (!paper)
290 {
291 assessment.indices = assessment.fixed
292 ? _.range(assessment.questions.length)
293 : _.shuffle( _.range(assessment.questions.length) );
294 }
295 else
296 {
297 // Resuming
298 let indices = paper.inputs.map( input => { return input.index; });
299 let remainingIndices = _.difference(_.range(assessment.questions.length), indices);
300 assessment.indices = indices.concat( _.shuffle(remainingIndices) );
301 }
302 assessment.index = !!paper ? paper.inputs.length : 0;
e99c53fb 303 this.stage = 2;
cc7c0f5e
BA
304 Vue.nextTick( () => {
305 // Run Prism + MathJax on questions text
306 $("#statements").find("code[class^=language-]").each( (i,elem) => {
307 Prism.highlightElement(elem);
308 });
309 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"statements"]);
310 });
e99c53fb
BA
311 };
312 if (assessment.mode == "open")
313 return initializeStage2();
314 $.ajax("/start/assessment", {
315 method: "GET",
316 data: {
317 number: this.student.number,
318 aid: assessment._id
319 },
320 dataType: "json",
321 success: s => {
322 if (!!s.errmsg)
f03a2ad9
BA
323 return this.warning(s.errmsg);
324 if (!!s.paper)
325 {
326 // Resuming: receive stored answers + startTime
327 this.student.password = s.paper.password;
328 this.inputs = s.paper.inputs.map( inp => { return inp.input; });
329 }
330 else
331 {
332 this.student.password = s.password;
333 // Got password: students answers locked to this page until potential teacher
334 // action (power failure, computer down, ...)
335 }
e99c53fb
BA
336 // TODO: password also exchanged by sockets to check identity
337 //socket = io.connect("/" + assessment.name, {
338 // query: "number=" + this.student.number + "&password=" + this.password
339 //});
340 //socket.on(message.allAnswers, this.setAnswers);
cc7c0f5e 341 //socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red), redirect
f03a2ad9 342 initializeStage2(s.questions, s.paper);
e99c53fb
BA
343 },
344 });
345 },
346 // stage 2
347 runTimer: function() {
348 if (assessment.time <= 0)
349 return;
350 let self = this;
351 setInterval( function() {
352 self.remainingTime--;
353 if (self.remainingTime <= 0 || self.stage >= 4)
354 self.endAssessment();
355 clearInterval(this);
356 }, 1000);
357 },
e99c53fb 358 // stage 2 --> 3 (or 4)
cc7c0f5e 359 // from a message by statements component, or time over
e99c53fb 360 endAssessment: function() {
cc7c0f5e 361 // Set endTime, destroy password
e99c53fb 362 $("#leftButton, #rightButton").show();
cc7c0f5e 363 if (assessment.mode == "open")
e99c53fb 364 {
e99c53fb 365 this.stage = 4;
cc7c0f5e
BA
366 return;
367 }
368 $.ajax("/end/assessment", {
369 method: "GET",
370 data: {
371 aid: assessment._id,
372 number: this.student.number,
373 password: this.student.password,
374 },
375 dataType: "json",
376 success: ret => {
377 if (!!ret.errmsg)
f03a2ad9 378 return this.warning(ret.errmsg);
cc7c0f5e
BA
379 assessment.conclusion = ret.conclusion;
380 this.stage = 3;
381 delete this.student["password"]; //unable to send new answers now
382 //socket.disconnect();
383 //socket = null;
384 },
385 });
e99c53fb
BA
386 },
387 // stage 3 --> 4 (on socket message "feedback")
388 setAnswers: function(answers) {
389 for (let i=0; i<answers.length; i++)
390 assessment.questions[i].answer = answers[i];
391 this.stage = 4;
392 },
393 },
394});