First commit
[qomet.git] / public / javascripts / course.js
1 // TODO: YAML format for questions, parsed from text (nested questions)
2 // Then yaml parsed to json --> array of indexed questions
3 // Use open mode for question banks: add setting "nbQuestions" to show nbQuestions
4 // at random among active questions
5
6 window.onload = function() {
7
8 V = new Vue({
9 el: '#course',
10 data: {
11 display: "assessments", //or "students", or "grades" (admin mode)
12 course: course,
13 mode: "view", //or "edit" (some assessment)
14 // assessment data:
15 monitorPwd: "",
16 newAssessment: { name: "" },
17 assessmentArray: assessmentArray,
18 assessmentIndex: 0, //current edited assessment index
19 assessment: { }, //copy of assessment at editing index in array
20 assessmentText: "", //questions in an assessment, in text format
21 // grades data:
22 settings: {
23 totalPoints: 20,
24 halfPoints: false,
25 zeroSum: false,
26 },
27 group: 1, //for detailed grades tables
28 grades: { }, //computed
29 },
30 mounted: function() {
31 $('.modal').each( (i,elem) => {
32 if (elem.id != "assessmentEdit")
33 $(elem).modal();
34 });
35 $('ul.tabs').tabs();
36 $('#assessmentEdit').modal({
37 complete: () => {
38 this.parseAssessment();
39 Vue.nextTick( () => {
40 $("#questionList").find("code[class^=language-]").each( (i,elem) => {
41 Prism.highlightElement(elem);
42 });
43 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"questionList"]);
44 });
45 },
46 });
47 },
48 methods: {
49 // GENERAL:
50 toggleDisplay: function(area) {
51 if (this.display == area)
52 this.display = "";
53 else
54 this.display = area;
55 },
56 studentList: function(group) {
57 return this.course.students
58 .filter( s => { return group==0 || s.group == group; })
59 .map( s => { return Object.assign({}, s); }) //not altering initial array
60 .sort( (a,b) => {
61 let res = a.name.localeCompare(b.name);
62 if (res == 0)
63 res += a.forename.localeCompare(b.forename);
64 return res;
65 });
66 },
67 // STUDENTS:
68 uploadTrigger: function() {
69 $("#upload").click();
70 },
71 upload: function(e) {
72 let file = (e.target.files || e.dataTransfer.files)[0];
73 Papa.parse(file, {
74 header: true,
75 skipEmptyLines: true,
76 complete: (results,file) => {
77 let students = [ ];
78 // Post-process: add group/number if missing
79 let number = 1;
80 results.data.forEach( d => {
81 if (!d.group)
82 d.group = 1;
83 if (!d.number)
84 d.number = number++;
85 if (typeof d.number !== "string")
86 d.number = d.number.toString();
87 students.push(d);
88 });
89 $.ajax("/import/students", {
90 method: "POST",
91 data: {
92 cid: this.course._id,
93 students: JSON.stringify(students),
94 },
95 dataType: "json",
96 success: res => {
97 if (!res.errmsg)
98 this.course.students = students;
99 else
100 alert(res.errmsg);
101 },
102 });
103 },
104 });
105 },
106 // ASSESSMENT:
107 addAssessment: function() {
108 if (!admin)
109 return;
110 // modal, fill code and description
111 let error = Validator.checkObject(this.newAssessment, "Assessment");
112 if (!!error)
113 return alert(error);
114 else
115 $('#newAssessment').modal('close');
116 $.ajax("/add/assessment",
117 {
118 method: "GET",
119 data: {
120 name: this.newAssessment.name,
121 cid: course._id,
122 },
123 dataType: "json",
124 success: res => {
125 if (!res.errmsg)
126 {
127 this.newAssessment["name"] = "";
128 this.assessmentArray.push(res);
129 }
130 else
131 alert(res.errmsg);
132 },
133 }
134 );
135 },
136 materialOpenModal: function(id) {
137 $("#" + id).modal("open");
138 Materialize.updateTextFields(); //textareas, time field...
139 },
140 updateAssessment: function() {
141 $.ajax("/update/assessment", {
142 method: "POST",
143 data: {assessment: JSON.stringify(this.assessment)},
144 dataType: "json",
145 success: res => {
146 if (!res.errmsg)
147 {
148 this.assessmentArray[this.assessmentIndex] = this.assessment;
149 this.mode = "view";
150 }
151 else
152 alert(res.errmsg);
153 },
154 });
155 },
156 deleteAssessment: function(assessment) {
157 if (!admin)
158 return;
159 if (confirm("Delete assessment '" + assessment.name + "' ?"))
160 {
161 $.ajax("/remove/assessment",
162 {
163 method: "GET",
164 data: { qid: this.assessment._id },
165 dataType: "json",
166 success: res => {
167 if (!res.errmsg)
168 this.assessmentArray.splice( this.assessmentArray.findIndex( item => {
169 return item._id == assessment._id;
170 }), 1 );
171 else
172 alert(res.errmsg);
173 },
174 }
175 );
176 }
177 },
178 toggleState: function(questionIndex) {
179 // add or remove from activeSet of current assessment
180 let activeIndex = this.assessment.activeSet.findIndex( item => { return item == questionIndex; });
181 if (activeIndex >= 0)
182 this.assessment.activeSet.splice(activeIndex, 1);
183 else
184 this.assessment.activeSet.push(questionIndex);
185 },
186 setAssessmentText: function() {
187 let txt = "";
188 this.assessment.questions.forEach( q => {
189 txt += q.wording + "\n";
190 q.options.forEach( (o,i) => {
191 let symbol = q.answer.includes(i) ? "+" : "-";
192 txt += symbol + " " + o + "\n";
193 });
194 txt += "\n"; //separate questions by new line
195 });
196 this.assessmentText = txt;
197 },
198 parseAssessment: function() {
199 let questions = [ ];
200 let lines = this.assessmentText.split("\n").map( L => { return L.trim(); })
201 lines.push(""); //easier parsing
202 let emptyQuestion = () => {
203 return {
204 wording: "",
205 options: [ ],
206 answer: [ ],
207 active: true, //default
208 };
209 };
210 let q = emptyQuestion();
211 lines.forEach( L => {
212 if (L.length > 0)
213 {
214 if (['+','-'].includes(L.charAt(0)))
215 {
216 if (L.charAt(0) == '+')
217 q.answer.push(q.options.length);
218 q.options.push(L.slice(1).trim());
219 }
220 else if (L.charAt(0) == '*')
221 {
222 // TODO: read current + next lines into q.answer (HTML, 1-elem array)
223 }
224 else
225 q.wording += L + " "; //space required at line breaks, generally
226 }
227 else
228 {
229 // Flush current question (if any)
230 if (q.wording.length > 0)
231 {
232 questions.push(q);
233 q = emptyQuestion();
234 }
235 }
236 });
237 this.assessment.questions = questions;
238 },
239 actionAssessment: function(index) {
240 if (admin)
241 {
242 // Edit screen
243 this.assessmentIndex = index;
244 this.assessment = $.extend(true, {}, this.assessmentArray[index]);
245 this.setAssessmentText();
246 this.mode = "edit";
247 Vue.nextTick( () => {
248 $("#questionList").find("code[class^=language-]").each( (i,elem) => {
249 Prism.highlightElement(elem);
250 });
251 MathJax.Hub.Queue(["Typeset",MathJax.Hub,"questionList"]);
252 });
253 }
254 else //external user: show assessment
255 this.redirect(this.assessmentArray[index].name);
256 },
257 redirect: function(assessmentName) {
258 document.location.href = "/" + initials + "/" + course.code + "/" + assessmentName;
259 },
260 setPassword: function() {
261 let hashPwd = Sha1.Compute(this.monitorPwd);
262 let error = Validator.checkObject({password:hashPwd}, "Course");
263 if (error.length > 0)
264 return alert(error);
265 $.ajax("/set/password",
266 {
267 method: "GET",
268 data: {
269 cid: this.course._id,
270 pwd: hashPwd,
271 },
272 dataType: "json",
273 success: res => {
274 if (!res.errmsg)
275 alert("Password saved!");
276 else
277 alert(res.errmsg);
278 },
279 }
280 );
281 },
282 // NOTE: artifact required for Vue v-model to behave well
283 checkBoxFixedId: function(i) {
284 return "questionFixed" + i;
285 },
286 checkBoxActiveId: function(i) {
287 return "questionActive" + i;
288 },
289 // GRADES:
290 gradeSettings: function() {
291 $("#gradeSettings").modal("open");
292 Materialize.updateTextFields(); //total points field in grade settings overlap
293 },
294 download: function() {
295 // Download (all) grades as a CSV file
296 let data = [ ];
297 this.studentList(0).forEach( s => {
298 let finalGrade = 0.;
299 let gradesCount = 0;
300 if (!!this.grades[s.number])
301 {
302 Object.keys(this.grades[s.number]).forEach( assessmentName => {
303 s[assessmentName] = this.grades[s.number][assessmentName];
304 if (_.isNumeric(s[assessmentName]) && !isNaN(s[assessmentName]))
305 {
306 finalGrade += s[assessmentName];
307 gradesCount++;
308 }
309 if (gradesCount >= 1)
310 finalGrade /= gradesCount;
311 s["final"] = finalGrade; //TODO: forbid "final" as assessment name
312 });
313 }
314 data.push(s); //number,forename,name,group,assessName1...assessNameN,final
315 });
316 let csv = Papa.unparse(data, {
317 quotes: true,
318 header: true,
319 });
320 let downloadAnchor = $("#download");
321 downloadAnchor.attr("download", this.course.code + "_results.csv");
322 downloadAnchor.attr("href", "data:text/plain;charset=utf-8," + encodeURIComponent(csv));
323 this.$refs.download.click()
324 //downloadAnchor.click(); //fails
325 },
326 showDetails: function(group) {
327 this.group = group;
328 $("#detailedGrades").modal("open");
329 },
330 groupList: function() {
331 let maxGrp = 1;
332 this.course.students.forEach( s => {
333 if (s.group > maxGrp)
334 maxGrp = s.group;
335 });
336 return _.range(1,maxGrp+1);
337 },
338 grade: function(assessmentIndex, studentNumber) {
339 if (!this.grades[assessmentIndex] || !this.grades[assessmentIndex][studentNumber])
340 return ""; //no grade yet
341 return this.grades[assessmentIndex][studentNumber];
342 },
343 groupId: function(group, hash) {
344 return (!!hash?"#":"") + "group" + group;
345 },
346 togglePresence: function(number, index) {
347 // UNIMPLEMENTED
348 // TODO: if no grade (thus automatic 0), toggle "exempt" state on student for current exam
349 // --> automatic update of grades view (just a few number to change)
350 },
351 computeGrades: function() {
352 // UNIMPLEMENTED
353 // TODO: compute all grades using settings (points, coefficients, bonus/malus...).
354 // If some questions with free answers (open), display answers and ask teacher action.
355 // TODO: need a setting for that too (by student, by exercice, by question)
356 },
357 },
358 });
359
360 };