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