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