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