refactoring, better README (breaking commit...)
authorBenjamin Auder <benjamin.auder@somewhere>
Tue, 13 Feb 2018 17:45:19 +0000 (18:45 +0100)
committerBenjamin Auder <benjamin.auder@somewhere>
Tue, 13 Feb 2018 17:45:19 +0000 (18:45 +0100)
33 files changed:
README.md
TODO [deleted file]
entities/assessment.js [deleted file]
entities/course.js [deleted file]
entities/user.js [deleted file]
models/assessment.js
models/course.js
models/user.js
public/javascripts/assessment.js
public/javascripts/components/statements.js
public/javascripts/course.js
public/javascripts/grade.js [new file with mode: 0644]
public/javascripts/grading.js [deleted file]
public/stylesheets/assessment.css
public/stylesheets/course.css
public/stylesheets/grade.css [new file with mode: 0644]
public/stylesheets/layout.css
public/stylesheets/monitor.css
public/stylesheets/statements.css [new file with mode: 0644]
routes/assessments.js
routes/courses.js
routes/pages.js
routes/users.js
setup/students_small_test.csv [moved from stt.csv with 100% similarity]
sockets.js
utils/access.js
views/assessment.pug
views/course.pug
views/grade.pug [new file with mode: 0644]
views/grading.pug [deleted file]
views/index.pug
views/login.pug
views/monitor.pug

index 845524b..0fc0587 100644 (file)
--- a/README.md
+++ b/README.md
@@ -12,12 +12,10 @@ Source code of [qomet.auder.net](https://qomet.auder.net)
 
 Allow teachers to create courses, containing assessments. Each of them can be public, or
 restricted to a classroom (identification by student ID).
-Individual answers to an exam are monitored in real time, and feedback is sent
-to each participant in the end (answers, computing grade).
-Once a series of exam is over, the teacher can get all grades in CSV format from course page.
-
-*Note:* for now exams composition is limited to single question exercises.
-Automatic grades are also not available.
+Individual answers to an exam are monitored in real time, and answers are sent
+to each participant in the end (allowing them to estimate their grade).
+Once a series of exam is over, the teacher can get all grades in CSV format
+(assuming all questions were either quiz-like or parameterized).
 
 ## Installation
 
@@ -25,7 +23,42 @@ See setup/README
 
 ## Usage
 
-TODO: write tutorial, maybe a demo video.
+As a teacher, first create an account from the upper-left "login" menu.
+Then create a course using the appropriate button in the middle of the screen.
+Finally, create some exams ("new assessment" button). The syntax for a series of questions is described by the following example:
+
+       > Global (HTML) introduction [optional]
+
+       Question 1 text or introduction (optional if there are subquestions)
+
+               Text for question 1.1 (index detected from indentation)
+               * Answer to 1.1
+
+               Question 1.2 (a text is optional since there are subquestions)
+
+                       Question 1.2.1 text (mandatory); e.g. quiz-like:
+                       + choice 1
+                       - choice 2
+                       - choice 3
+
+                       Question 1.2.2 text (mandatory); e.g. open question:
+                       * answer to 1.2.2 (can be on several lines)
+       
+       Question 2 text ...
+       * An answer to question 2
+       ...
+
+All question texts (and open answers) can be on several lines.
+HTML markup (slightly limited) can be used, as well as [MathJax](https://www.mathjax.org/) with $ and $$ delimiters,
+and syntax highlighting using [prism](http://prismjs.com/): `<code class="language-xyz">` for language code `xyz`.
+The syntax for parameterized exercises (not working yet) is still undecided.
+
+Use the "exam" mode if browsing the web is allowed, and "watch" mode otherwise to monitor
+students actions like losing focus or resizing window.
+Finally the "secure" mode forbids all attempts to do anything else than focusing on the exam,
+but can be "a bit too much"; keep in mind next section if using it.
+All these modes restrict the access to a classroom. To open a series of question to the world,
+the "open" mode is for you.
 
 *Note about exams:*
 Once an assessment is started, it's impossible to quit and restart using another browser,
@@ -33,16 +66,21 @@ because a password stored in cookies need to be sent with every request.
 So under normal circumstances it's also impossible for a student to continue the exam of another.
 (The password is destroyed when exam ends or when the teacher decides to finish assessment).
 
-## Limitations
+## Limitations & workarounds
+
+Version "standard classroom": some potential internet cheating ways even in 'secure' mode (in addition to
+the usual ones like using phones, talking, doing signs, using short memos...)
 
-Version "standard classroom": some potential cheating ways,
  - headless browsers with renamed http-user-agent; difficult to counter with 100% confidence
  - block JS script using e.g. [uBlock Origin](https://github.com/gorhill/uBlock), then re-inject the script cleaned of listeners
  - intercept HTTP response to "start quiz" signal, re-compose the page without listeners and run
 
-The only way to garanty zero internet cheat is to use some SELinux configuration in kiosk mode
-with just one safe web browser enabled, e.g. [surf](https://surf.suckless.org/).
-Not that more traditional ways of cheating may still be used (phones, talking, signs, memos...)
+The easy way to prevent these cheating attempts would consist in installing qomet on a local server,
+and restricting exam rooms to the intranet while preventing users to access their account (where they could
+keep a copy of the courses). This also prevent internet-based students communication.
+
+Another option (which seems more complicated, but might be required if the intranet itself shouldn't be accessed)
+would be to force e.g. chromium in kiosk mode restricted to one domain (using SELinux on a special account maybe).
 
 ## Alternative softwares
 
@@ -56,8 +94,7 @@ Not that more traditional ways of cheating may still be used (phones, talking, s
 
  * [wims](http://wims.unice.fr/~wims/)<br/>
   Full-featured (and open source) training center for students, with various types of exercises,
-  possibly in exam mode too.
-  The spirit, however, is more "enhanced homework" than "internet exams".
+  possibly in exam mode too. The spirit, however, is more "enhanced homework" than "internet exams".
 
  * [socrative](https://socrative.com/)<br/>
   Nice looking realtime feedback (lacking in evalbox), but thought for interactive classes.
diff --git a/TODO b/TODO
deleted file mode 100644 (file)
index 19b41c8..0000000
--- a/TODO
+++ /dev/null
@@ -1,54 +0,0 @@
-Replace underscore by lodash (or better: ES6)
-Replace socket.io by Websockets ( https://www.npmjs.com/package/websocket )
-
-time per question (in mode "one question at a time" from server...)
-compute grades after exam (in teacher's view)
-factorize redundant code in course.js, monitor.js and (TOWRITE) grade.js
-  (showing students list + grades or papers)
-monitoring: main teacher should not be asked for pwd, and button "send feedback" hidden for others
-(or just disabled)
-
------
-
-Draft format (compiled to json)
-
-> Some global (HTML) intro
-
-<some html question (or/+ exercise intro)>
-
-       <some html subQuestion>
-       * some answer [trigger input/index in answers]
-
-       <another subquestion>
-
-               <sub-subQuestion>
-               + choix1
-               - choix 2
-               + choix 3
-               - choix4
-
-               <another sub sub>
-               * answer 2 (which can
-               be on
-               several lines)
-
-<Some second question>
-* With answer
-
-=====
-
-questions group by index prefix 1.2.3 1.1 ...etc --> '1'
-
-NOTE: questions can contain parameterized exercises (how ?
---> describe variables (syntax ?)
---> write javascript script (OK, users trusted ? ==> safe mode possible if public website)
-Imaginary example: (using math.js)
-       <params> (avant l'exo)
-       x: math.random()
-       y: math.random()
-       M: math.matrix([[7, x], [y, -3]]);
-       res: math.det(M)
-       </params>
-       <div>Calculer le déterminant de 
-       $$\begin{matrix}7 & x\\y & -3\end{matrix}$$</div>
-       * ...
diff --git a/entities/assessment.js b/entities/assessment.js
deleted file mode 100644 (file)
index b4b6d89..0000000
+++ /dev/null
@@ -1,257 +0,0 @@
-const db = require("../utils/database");
-
-const AssessmentEntity =
-{
-       /*
-        * Structure:
-        *   _id: BSON id
-        *   cid: course ID
-        *   name: varchar
-        *   active: boolean true/false
-        *   mode: secure | exam | open (decreasing security)
-        *   fixed: bool (questions in fixed order; default: false)
-        *   display: "one" or "all" (generally "all" for open questions, but...)
-        *   time: 0, //<=0 means "untimed"; otherwise, time in seconds
-        *   introduction: "",
-        *   coefficient: number, default 1
-        *   questions: array of
-        *     index: for paper test, like 2.1.a (?!); and quiz: 0, 1, 2, 3...
-        *     wording: varchar (HTML)
-        *     options: array of varchar --> if present, question type == quiz!
-        *     fixed: bool, options in fixed order (default: false)
-        *     answer: array of integers (for quiz) or html text (for paper); striped in exam mode
-        *     active: boolean, is question in current assessment?
-        *     points: points for this question (default 1)
-        *   papers : array of
-        *     number: student number
-        *     inputs: array of indexed arrays of integers (or html text if not quiz)
-        *     startTime, endTime
-        *     discoTime, totalDisco: last disconnect timestamp (if relevant) + total
-        *     discoCount: total disconnections
-        *     password: random string identifying student for exam session TEMPORARY
-        */
-
-       getById: function(aid, callback)
-       {
-               db.assessments.findOne(
-                       { _id: aid },
-                       callback
-               );
-       },
-
-       getByPath: function(cid, name, callback)
-       {
-               db.assessments.findOne(
-                       {
-                               cid: cid,
-                               name: name,
-                       },
-                       callback
-               );
-       },
-
-       insert: function(cid, name, callback)
-       {
-               db.assessments.insert(
-                       {
-                               name: name,
-                               cid: cid,
-                               active: false,
-                               mode: "exam",
-                               fixed: false,
-                               display: "one",
-                               time: 0,
-                               introduction: "",
-                               coefficient: 1,
-                               questions: [ ],
-                               papers: [ ],
-                       },
-                       callback
-               );
-       },
-
-       getByCourse: function(cid, callback)
-       {
-               db.assessments.find(
-                       { cid: cid },
-                       callback
-               );
-       },
-
-       // arg: full assessment without _id field
-       replace: function(aid, assessment, cb)
-       {
-               // Should be: (but unsupported by mongojs)
-//             db.assessments.replaceOne(
-//                     { _id: aid },
-//                     assessment,
-//                     cb
-//             );
-               // Temporary workaround:
-               db.assessments.update(
-                       { _id: aid },
-                       { $set: assessment },
-                       cb
-               );
-       },
-
-       getQuestions: function(aid, callback)
-       {
-               db.assessments.findOne(
-                       { _id: aid },
-                       { questions: 1},
-                       (err,res) => {
-                               callback(err, !!res ? res.questions : null);
-                       }
-               );
-       },
-
-       getPaperByNumber: function(aid, number, callback)
-       {
-               db.assessments.findOne(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                       },
-                       (err,a) => {
-                               if (!!err || !a)
-                                       return callback(err,a);
-                               for (let p of a.papers)
-                               {
-                                       if (p.number == number)
-                                               return callback(null,p); //reached for sure
-                               }
-                       }
-               );
-       },
-
-       startSession: function(aid, number, password, callback)
-       {
-               db.assessments.update(
-                       { _id: aid },
-                       { $push: { papers: {
-                               number: number,
-                               startTime: Date.now(),
-                               endTime: undefined,
-                               password: password,
-                               totalDisco: 0,
-                               discoCount: 0,
-                               inputs: [ ], //TODO: this is stage 1, stack indexed answers.
-                               // then build JSON tree for easier access / correct
-                       }}},
-                       callback
-               );
-       },
-
-       // NOTE: no callbacks for 2 next functions, failures are not so important
-       // (because monitored: teachers can see what's going on)
-
-       addDisco: function(aid, number, deltaTime)
-       {
-               db.assessments.update(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                       },
-                       { $inc: {
-                               "papers.$.discoCount": 1,
-                               "papers.$.totalDisco": deltaTime,
-                       } },
-                       { $set: { "papers.$.discoTime": null } }
-               );
-       },
-
-       setDiscoTime: function(aid, number)
-       {
-               db.assessments.update(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                       },
-                       { $set: { "papers.$.discoTime": Date.now() } }
-               );
-       },
-
-       getDiscoTime: function(aid, number, cb)
-       {
-               db.assessments.findOne(
-                       { _id: aid },
-                       (err,a) => {
-                               if (!!err)
-                                       return cb(err, null);
-                               const idx = a.papers.findIndex( item => { return item.number == number; });
-                               cb(null, a.papers[idx].discoTime);
-                       }
-               );
-       },
-
-       hasInput: function(aid, number, password, idx, cb)
-       {
-               db.assessments.findOne(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                               "papers.password": password,
-                       },
-                       (err,a) => {
-                               if (!!err || !a)
-                                       return cb(err,a);
-                               let papIdx = a.papers.findIndex( item => { return item.number == number; });
-                               for (let i of a.papers[papIdx].inputs)
-                               {
-                                       if (i.index == idx)
-                                               return cb(null,true);
-                               }
-                               cb(null,false);
-                       }
-               );
-       },
-
-       // https://stackoverflow.com/questions/27874469/mongodb-push-in-nested-array
-       setInput: function(aid, number, password, input, callback) //input: index + arrayOfInt (or txt)
-       {
-               db.assessments.update(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                               "papers.password": password,
-                       },
-                       { $push: { "papers.$.inputs": input } },
-                       callback
-               );
-       },
-
-       endAssessment: function(aid, number, password, callback)
-       {
-               db.assessments.update(
-                       {
-                               _id: aid,
-                               "papers.number": number,
-                               "papers.password": password,
-                       },
-                       { $set: {
-                               "papers.$.endTime": Date.now(),
-                               "papers.$.password": "",
-                       } },
-                       callback
-               );
-       },
-
-       remove: function(aid, cb)
-       {
-               db.assessments.remove(
-                       { _id: aid },
-                       cb
-               );
-       },
-
-       removeGroup: function(cid, cb)
-       {
-               db.assessments.remove(
-                       { cid: cid },
-                       cb
-               );
-       },
-}
-
-module.exports = AssessmentEntity;
diff --git a/entities/course.js b/entities/course.js
deleted file mode 100644 (file)
index 9b3fb46..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-const db = require("../utils/database");
-
-const CourseEntity =
-{
-       /*
-        * Structure:
-        *   _id: BSON id
-        *   uid: prof ID
-        *   code: varchar
-        *   description: varchar
-        *   password: monitoring password hash
-        *   students: array of
-        *     number: student number
-        *     name: varchar
-        *     group: integer
-        */
-
-       getByUser: function(uid, callback)
-       {
-               db.courses.find(
-                       { uid: uid },
-                       callback
-               );
-       },
-
-       getById: function(cid, callback)
-       {
-               db.courses.findOne(
-                       { _id: cid },
-                       callback
-               );
-       },
-
-       getByPath: function(uid, code, callback)
-       {
-               db.courses.findOne(
-                       {
-                               $and: [
-                                       { uid: uid },
-                                       { code: code },
-                               ]
-                       },
-                       callback
-               );
-       },
-
-       insert: function(uid, code, description, cb)
-       {
-               db.courses.insert(
-                       {
-                               uid: uid,
-                               code: code,
-                               description: description,
-                               students: [ ],
-                       },
-                       cb);
-       },
-
-       setStudents: function(cid, students, cb)
-       {
-               db.courses.update(
-                       { _id: cid },
-                       { $set: { students: students } },
-                       cb
-               );
-       },
-
-       // Note: return { students: { ... } }, pointing on the requested row
-       getStudent: function(cid, number, cb)
-       {
-               db.courses.findOne(
-                       { _id: cid },
-                       {
-                               _id: 0,
-                               students: { $elemMatch: {number: number} }
-                       },
-                       cb
-               );
-       },
-
-       setPassword: function(cid, pwd, cb)
-       {
-               db.courses.update(
-                       { _id: cid },
-                       { $set: { password: pwd } },
-                       cb
-               );
-       },
-
-       remove: function(cid, cb)
-       {
-               db.courses.remove(
-                       { _id: cid },
-                       cb
-               );
-       },
-}
-
-module.exports = CourseEntity;
diff --git a/entities/user.js b/entities/user.js
deleted file mode 100644 (file)
index 72b3a43..0000000
+++ /dev/null
@@ -1,145 +0,0 @@
-const db = require("../utils/database");
-
-const UserEntity =
-{
-       /*
-        * Structure:
-        *   _id: BSON id
-        *   ** Strings, identification informations:
-        *   email
-        *   name
-        *   initials : computed, Benjamin Auder --> ba ...etc
-        *   loginToken: {
-        *     value: string
-        *     timestamp: datetime (validity)
-        *     ip: address of requesting machine
-        *   }
-        *   sessionTokens (array): cookie identification
-        */
-
-       getInitialsByPrefix: function(prefix, cb)
-       {
-               db.users.find(
-                       { initials: new RegExp("^" + prefix) },
-                       { initials: 1, _id: 0 },
-                       cb
-               );
-       },
-
-       insert: function(newUser, cb)
-       {
-               db.users.insert(Object.assign({},
-                       newUser,
-                       {
-                               loginToken: { },
-                               sessionTokens: [ ],
-                       }),
-                       cb
-               );
-       },
-
-       getByLoginToken: function(token, cb)
-       {
-               db.users.findOne(
-                       { "loginToken.value": token },
-                       cb
-               );
-       },
-
-       getBySessionToken: function(token, cb)
-       {
-               db.users.findOne(
-                       { sessionTokens: token},
-                       cb
-               );
-       },
-
-       getById: function(uid, cb)
-       {
-               db.users.findOne(
-                       { _id: uid },
-                       cb
-               );
-       },
-
-       getByEmail: function(email, cb)
-       {
-               db.users.findOne(
-                       { email: email },
-                       cb
-               );
-       },
-
-       getByInitials: function(initials, cb)
-       {
-               db.users.findOne(
-                       { initials: initials },
-                       cb
-               );
-       },
-
-       getUnlogged: function(cb)
-       {
-               var tsNow = new Date().getTime();
-               // 86400000 = 24 hours in milliseconds
-               var day = 86400000;
-               db.users.find({}, (err,userArray) => {
-                       let unlogged = userArray.filter( u => {
-                               return u.sessionTokens.length==0 && u._id.getTimestamp().getTime() + day < tsNow;
-                       });
-                       cb(err, unlogged);
-               });
-       },
-
-       getAll: function(cb)
-       {
-               db.users.find({}, cb);
-       },
-
-       setLoginToken: function(token, uid, ip, cb)
-       {
-               db.users.update(
-                       { _id: uid },
-                       { $set: { loginToken: {
-                                       value: token,
-                                       timestamp: new Date().getTime(),
-                                       ip: ip,
-                               }}
-                       },
-                       cb
-               );
-       },
-
-       setSessionToken: function(token, uid, cb)
-       {
-               // Also empty the login token to invalidate future attempts
-               db.users.update(
-                       { _id: uid },
-                       {
-                               $set: { loginToken: {} },
-                               $push: { sessionTokens: {
-                                       $each: [token],
-                                       $slice: -7 //only allow 7 simultaneous connections per user (TODO?)
-                               }}
-                       },
-                       cb
-               );
-       },
-
-       removeToken: function(uid, token, cb)
-       {
-               db.users.update(
-                       { _id: uid },
-                       { $pull: {sessionTokens: token} },
-                       cb
-               );
-       },
-
-       // TODO: later, allow account removal
-       remove: function(uids)
-       {
-               db.users.remove({_id: uids});
-       },
-}
-
-module.exports = UserEntity;
index d0e0d0b..c7cb0fd 100644 (file)
-const AssessmentEntity = require("../entities/assessment");
-const CourseEntity = require("../entities/course");
+const CourseModel = require("../models/course");
+const UserModel = require("../models/user");
 const ObjectId = require("bson-objectid");
-const UserEntity = require("../entities/user");
 const TokenGen = require("../utils/tokenGenerator");
+const db = require("../utils/database");
 
 const AssessmentModel =
 {
+       /*
+        * Structure:
+        *   _id: BSON id
+        *   cid: course ID
+        *   name: varchar
+        *   active: boolean true/false
+        *   mode: secure | exam | open (decreasing security)
+        *   fixed: bool (questions in fixed order; default: false)
+        *   display: "one" or "all" (generally "all" for open questions, but...)
+        *   time: 0, //<=0 means "untimed"; otherwise, time in seconds
+        *   introduction: "",
+        *   coefficient: number, default 1
+        *   questions: array of
+        *     index: for paper test, like 2.1.a (?!); and quiz: 0, 1, 2, 3...
+        *     wording: varchar (HTML)
+        *     options: array of varchar --> if present, question type == quiz!
+        *     fixed: bool, options in fixed order (default: false)
+        *     answer: array of integers (for quiz) or html text (for paper); striped in exam mode
+        *     active: boolean, is question in current assessment?
+        *     points: points for this question (default 1)
+        *     time: 0 (<=0: untimed)
+        *   papers : array of
+        *     number: student number
+        *     inputs: array of {index,answer[array of integers or html text],startTime}
+        *     current: index of current question (if relevant: display="one")
+        *     startTime
+        *     discoTime, totalDisco: last disconnect timestamp (if relevant) + total time
+        *     discoCount: number of disconnections
+        *     password: random string identifying student for exam session TEMPORARY
+        */
+
+       //////////////////
+       // BASIC FUNCTIONS
+
+       getById: function(aid, callback)
+       {
+               db.assessments.findOne(
+                       { _id: aid },
+                       callback
+               );
+       },
+
+       getByPath: function(cid, name, callback)
+       {
+               db.assessments.findOne(
+                       {
+                               cid: cid,
+                               name: name,
+                       },
+                       callback
+               );
+       },
+
+       insert: function(cid, name, callback)
+       {
+               db.assessments.insert(
+                       {
+                               name: name,
+                               cid: cid,
+                               active: false,
+                               mode: "exam",
+                               fixed: false,
+                               display: "one",
+                               time: 0,
+                               introduction: "",
+                               coefficient: 1,
+                               questions: [ ],
+                               papers: [ ],
+                       },
+                       callback
+               );
+       },
+
+       getByCourse: function(cid, callback)
+       {
+               db.assessments.find(
+                       { cid: cid },
+                       callback
+               );
+       },
+
+       // arg: full assessment without _id field
+       replace: function(aid, assessment, cb)
+       {
+               // Should be: (but unsupported by mongojs)
+//             db.assessments.replaceOne(
+//                     { _id: aid },
+//                     assessment,
+//                     cb
+//             );
+               // Temporary workaround:
+               db.assessments.update(
+                       { _id: aid },
+                       { $set: assessment },
+                       cb
+               );
+       },
+
+       getQuestions: function(aid, callback)
+       {
+               db.assessments.findOne(
+                       {
+                               _id: aid,
+                               display: "all",
+                       },
+                       { questions: 1},
+                       (err,res) => {
+                               callback(err, !!res ? res.questions : null);
+                       }
+               );
+       },
+
+       getQuestion: function(aid, index, callback)
+       {
+               db.assessments.findOne(
+                       {
+                               _id: aid,
+                               display: "one",
+                       },
+                       { questions: 1},
+                       (err,res) => {
+                               if (!!err || !res)
+                                       return callback(err, res);
+                               const qIdx = res.questions.findIndex( item => { return item.index == index; });
+                               if (qIdx === -1)
+                                       return callback({errmsg: "Question not found"}, null);
+                               callback(null, res.questions[qIdx]);
+                       }
+               );
+       },
+
+       getPaperByNumber: function(aid, number, callback)
+       {
+               db.assessments.findOne(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                       },
+                       (err,a) => {
+                               if (!!err || !a)
+                                       return callback(err,a);
+                               for (let p of a.papers)
+                               {
+                                       if (p.number == number)
+                                               return callback(null,p); //reached for sure
+                               }
+                       }
+               );
+       },
+
+       // NOTE: no callbacks for 2 next functions, failures are not so important
+       // (because monitored: teachers can see what's going on)
+
+       addDisco: function(aid, number, deltaTime)
+       {
+               db.assessments.update(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                       },
+                       { $inc: {
+                               "papers.$.discoCount": 1,
+                               "papers.$.totalDisco": deltaTime,
+                       } },
+                       { $set: { "papers.$.discoTime": null } }
+               );
+       },
+
+       setDiscoTime: function(aid, number)
+       {
+               db.assessments.update(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                       },
+                       { $set: { "papers.$.discoTime": Date.now() } }
+               );
+       },
+
+       getDiscoTime: function(aid, number, cb)
+       {
+               db.assessments.findOne(
+                       { _id: aid },
+                       (err,a) => {
+                               if (!!err)
+                                       return cb(err, null);
+                               const idx = a.papers.findIndex( item => { return item.number == number; });
+                               cb(null, a.papers[idx].discoTime);
+                       }
+               );
+       },
+
+       hasInput: function(aid, number, password, idx, cb)
+       {
+               db.assessments.findOne(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                               "papers.password": password,
+                       },
+                       (err,a) => {
+                               if (!!err || !a)
+                                       return cb(err,a);
+                               let papIdx = a.papers.findIndex( item => { return item.number == number; });
+                               for (let i of a.papers[papIdx].inputs)
+                               {
+                                       if (i.index == idx)
+                                               return cb(null,true);
+                               }
+                               cb(null,false);
+                       }
+               );
+       },
+
+       // https://stackoverflow.com/questions/27874469/mongodb-push-in-nested-array
+       setInput: function(aid, number, password, input, callback) //input: index + arrayOfInt (or txt)
+       {
+               db.assessments.update(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                               "papers.password": password,
+                       },
+                       { $push: { "papers.$.inputs": input } },
+                       callback
+               );
+       },
+
+       endAssessment: function(aid, number, password, callback)
+       {
+               db.assessments.update(
+                       {
+                               _id: aid,
+                               "papers.number": number,
+                               "papers.password": password,
+                       },
+                       { $set: {
+                               "papers.$.endTime": Date.now(),
+                               "papers.$.password": "",
+                       } },
+                       callback
+               );
+       },
+
+       remove: function(aid, cb)
+       {
+               db.assessments.remove(
+                       { _id: aid },
+                       cb
+               );
+       },
+
+       removeGroup: function(cid, cb)
+       {
+               db.assessments.remove(
+                       { cid: cid },
+                       cb
+               );
+       },
+
+       /////////////////////
+       // ADVANCED FUNCTIONS
+
        getByRefs: function(initials, code, name, cb)
        {
-               UserEntity.getByInitials(initials, (err,user) => {
+               UserModel.getByInitials(initials, (err,user) => {
                        if (!!err || !user)
                                return cb(err || {errmsg: "User not found"});
-                       CourseEntity.getByPath(user._id, code, (err2,course) => {
+                       CourseModel.getByPath(user._id, code, (err2,course) => {
                                if (!!err2 || !course)
                                        return cb(err2 || {errmsg: "Course not found"});
-                               AssessmentEntity.getByPath(course._id, name, (err3,assessment) => {
+                               AssessmentModel.getByPath(course._id, name, (err3,assessment) => {
                                        if (!!err3 || !assessment)
                                                return cb(err3 || {errmsg: "Assessment not found"});
                                        cb(null,assessment);
@@ -25,7 +288,7 @@ const AssessmentModel =
 
        checkPassword: function(aid, number, password, cb)
        {
-               AssessmentEntity.getById(aid, (err,assessment) => {
+               AssessmentModel.getById(aid, (err,assessment) => {
                        if (!!err || !assessment)
                                return cb(err, assessment);
                        const paperIdx = assessment.papers.findIndex( item => { return item.number == number; });
@@ -38,13 +301,13 @@ const AssessmentModel =
        add: function(uid, cid, name, cb)
        {
                // 1) Check that course is owned by user of ID uid
-               CourseEntity.getById(cid, (err,course) => {
+               CourseModel.getById(cid, (err,course) => {
                        if (!!err || !course)
                                return cb({errmsg: "Course retrieval failure"});
                        if (!course.uid.equals(uid))
                                return cb({errmsg:"Not your course"},undefined);
                        // 2) Insert new blank assessment
-                       AssessmentEntity.insert(cid, name, cb);
+                       AssessmentModel.insert(cid, name, cb);
                });
        },
 
@@ -52,10 +315,10 @@ const AssessmentModel =
        {
                const aid = ObjectId(assessment._id);
                // 1) Check that assessment is owned by user of ID uid
-               AssessmentEntity.getById(aid, (err,assessmentOld) => {
+               AssessmentModel.getById(aid, (err,assessmentOld) => {
                        if (!!err || !assessmentOld)
                                return cb({errmsg: "Assessment retrieval failure"});
-                       CourseEntity.getById(ObjectId(assessmentOld.cid), (err2,course) => {
+                       CourseModel.getById(ObjectId(assessmentOld.cid), (err2,course) => {
                                if (!!err2 || !course)
                                        return cb({errmsg: "Course retrieval failure"});
                                if (!course.uid.equals(uid))
@@ -63,7 +326,7 @@ const AssessmentModel =
                                // 2) Replace assessment
                                delete assessment["_id"];
                                assessment.cid = ObjectId(assessment.cid);
-                               AssessmentEntity.replace(aid, assessment, cb);
+                               AssessmentModel.replace(aid, assessment, cb);
                        });
                });
        },
@@ -71,7 +334,7 @@ const AssessmentModel =
        // Set password in responses collection
        startSession: function(aid, number, password, cb)
        {
-               AssessmentEntity.getPaperByNumber(aid, number, (err,paper) => {
+               AssessmentModel.getPaperByNumber(aid, number, (err,paper) => {
                        if (!!err)
                                return cb(err,null);
                        if (!paper && !!password)
@@ -83,18 +346,26 @@ const AssessmentModel =
                                if (paper.password != password)
                                        return cb({errmsg: "Wrong password"});
                        }
-                       AssessmentEntity.getQuestions(aid, (err,questions) => {
-                               if (!!err)
-                                       return cb(err,null);
+                       AssessmentModel.getQuestions(aid, (err2,questions) => {
+                               if (!!err2)
+                                       return cb(err2,null);
                                if (!!paper)
-                                       return cb(null,{paper:paper,questions:questions});
+                                       return cb(null,{paper:paper});
                                const pwd = TokenGen.generate(12); //arbitrary number, 12 seems enough...
-                               AssessmentEntity.startSession(aid, number, pwd, (err2,ret) => {
-                                       cb(err2, {
-                                               questions: questions,
-                                               password: pwd,
-                                       });
-                               });
+                               db.assessments.update(
+                                       { _id: aid },
+                                       { $push: { papers: {
+                                               number: number,
+                                               startTime: Date.now(),
+                                               endTime: undefined,
+                                               password: password,
+                                               totalDisco: 0,
+                                               discoCount: 0,
+                                               inputs: [ ], //TODO: this is stage 1, stack indexed answers.
+                                               // then build JSON tree for easier access / correct
+                                       }}},
+                                       (err3,ret) => { cb(err3,{password:password}); }
+                               );
                        });
                });
        },
@@ -102,12 +373,12 @@ const AssessmentModel =
        newAnswer: function(aid, number, password, input, cb)
        {
                // Check that student hasn't already answered
-               AssessmentEntity.hasInput(aid, number, password, input.index, (err,ret) => {
+               AssessmentModel.hasInput(aid, number, password, input.index, (err,ret) => {
                        if (!!err)
                                return cb(err,null);
                        if (!!ret)
                                return cb({errmsg:"Question already answered"},null);
-                       AssessmentEntity.setInput(aid, number, password, input, (err2,ret2) => {
+                       AssessmentModel.setInput(aid, number, password, input, (err2,ret2) => {
                                if (!!err2 || !ret2)
                                        return cb(err2,ret2);
                                return cb(null,ret2);
@@ -115,17 +386,16 @@ const AssessmentModel =
                });
        },
 
-       // NOTE: no callbacks for 2 next functions, failures are not so important
+       // NOTE: no callbacks for next function, failures are not so important
        // (because monitored: teachers can see what's going on)
-
        newConnection: function(aid, number)
        {
                //increment discoCount, reset discoTime to NULL, update totalDisco
-               AssessmentEntity.getDiscoTime(aid, number, (err,discoTime) => {
+               AssessmentModel.getDiscoTime(aid, number, (err,discoTime) => {
                        if (!!discoTime)
-                               AssessmentEntity.addDisco(aid, number, Date.now() - discoTime);
+                               AssessmentModel.addDisco(aid, number, Date.now() - discoTime);
                });
        },
-};
+}
 
 module.exports = AssessmentModel;
index 53419c1..631bab9 100644 (file)
-const CourseEntity = require("../entities/course");
-const UserEntity = require("../entities/user");
-const AssessmentEntity = require("../entities/assessment");
+const UserModel = require("../models/user");
+const AssessmentModel = require("../models/assessment");
+const db = require("../utils/database");
 
 const CourseModel =
 {
+       /*
+        * Structure:
+        *   _id: BSON id
+        *   uid: prof ID
+        *   code: varchar
+        *   description: varchar
+        *   password: monitoring password hash
+        *   students: array of
+        *     number: student number
+        *     name: varchar
+        *     group: integer
+        */
+
+       //////////////////
+       // BASIC FUNCTIONS
+
+       getByUser: function(uid, callback)
+       {
+               db.courses.find(
+                       { uid: uid },
+                       callback
+               );
+       },
+
+       getById: function(cid, callback)
+       {
+               db.courses.findOne(
+                       { _id: cid },
+                       callback
+               );
+       },
+
+       getByPath: function(uid, code, callback)
+       {
+               db.courses.findOne(
+                       {
+                               $and: [
+                                       { uid: uid },
+                                       { code: code },
+                               ]
+                       },
+                       callback
+               );
+       },
+
+       insert: function(uid, code, description, cb)
+       {
+               db.courses.insert(
+                       {
+                               uid: uid,
+                               code: code,
+                               description: description,
+                               students: [ ],
+                       },
+                       cb);
+       },
+
+       setStudents: function(cid, students, cb)
+       {
+               db.courses.update(
+                       { _id: cid },
+                       { $set: { students: students } },
+                       cb
+               );
+       },
+
+       // Note: return { students: { ... } }, pointing on the requested row
+       getStudent: function(cid, number, cb)
+       {
+               db.courses.findOne(
+                       { _id: cid },
+                       {
+                               _id: 0,
+                               students: { $elemMatch: {number: number} }
+                       },
+                       cb
+               );
+       },
+
+       setPassword: function(cid, pwd, cb)
+       {
+               db.courses.update(
+                       { _id: cid },
+                       { $set: { password: pwd } },
+                       cb
+               );
+       },
+
+       remove: function(cid, cb)
+       {
+               db.courses.remove(
+                       { _id: cid },
+                       cb
+               );
+       },
+
+       /////////////////////
+       // ADVANCED FUNCTIONS
+
        getByInitials: function(initials, callback)
        {
-               UserEntity.getByInitials(initials, (err,user) => {
+               UserModel.getByInitials(initials, (err,user) => {
                        if (!!err || !user)
                                callback(err, []);
                        else
                        {
-                               CourseEntity.getByUser(user._id, (err2,courseArray) => {
+                               CourseModel.getByUser(user._id, (err2,courseArray) => {
                                        callback(err2, courseArray);
                                });
                        }
@@ -20,12 +119,12 @@ const CourseModel =
 
        getByRefs: function(initials, code, callback)
        {
-               UserEntity.getByInitials(initials, (err,user) => {
+               UserModel.getByInitials(initials, (err,user) => {
                        if (!!err || !user)
                                callback(err, []);
                        else
                        {
-                               CourseEntity.getByPath(user._id, code, (err2,course) => {
+                               CourseModel.getByPath(user._id, code, (err2,course) => {
                                        callback(err2, course);
                                });
                        }
@@ -35,37 +134,37 @@ const CourseModel =
        importStudents: function(uid, cid, students, cb)
        {
                // 1) check if uid == course uid
-               CourseEntity.getById(cid, (err,course) => {
+               CourseModel.getById(cid, (err,course) => {
                        if (!!err || !course || !course.uid.equals(uid))
                                return cb({errmsg:"Not your course"},{});
                        // 2) Set students
-                       CourseEntity.setStudents(cid, students, cb);
+                       CourseModel.setStudents(cid, students, cb);
                });
        },
 
        setPassword: function(uid, cid, pwd, cb)
        {
                // 1) check if uid == course uid
-               CourseEntity.getById(cid, (err,course) => {
+               CourseModel.getById(cid, (err,course) => {
                        if (!!err || !course || !course.uid.equals(uid))
                                return cb({errmsg:"Not your course"},{});
                        // 2) Insert new student (overwrite if number already exists)
-                       CourseEntity.setPassword(cid, pwd, cb);
+                       CourseModel.setPassword(cid, pwd, cb);
                });
        },
 
        remove: function(uid, cid, cb)
        {
                // 1) check if uid == course uid
-               CourseEntity.getById(cid, (err,course) => {
+               CourseModel.getById(cid, (err,course) => {
                        if (!!err || !course || !course.uid.equals(uid))
                                return cb({errmsg:"Not your course"},{});
                        // 2) remove all associated assessments
-                       AssessmentEntity.removeGroup(cid, (err2,ret) => {
+                       AssessmentModel.removeGroup(cid, (err2,ret) => {
                                if (!!err)
                                        return cb(err,{});
                                // 3) remove course (with its students)
-                               CourseEntity.remove(cid, cb);
+                               CourseModel.remove(cid, cb);
                        });
                });
        },
index c6fa776..4b66c88 100644 (file)
-const UserEntity = require("../entities/user");
 const params = require("../config/parameters");
+const db = require("../utils/database");
 
 const UserModel =
 {
+       /*
+        * Structure:
+        *   _id: BSON id
+        *   ** Strings, identification informations:
+        *   email
+        *   name
+        *   initials : computed, Benjamin Auder --> ba ...etc
+        *   loginToken: {
+        *     value: string
+        *     timestamp: datetime (validity)
+        *     ip: address of requesting machine
+        *   }
+        *   sessionTokens (array): cookie identification
+        */
+
+       // BASIC FUNCTIONS
+       //////////////////
+
+       getInitialsByPrefix: function(prefix, cb)
+       {
+               db.users.find(
+                       { initials: new RegExp("^" + prefix) },
+                       { initials: 1, _id: 0 },
+                       cb
+               );
+       },
+
+       insert: function(newUser, cb)
+       {
+               db.users.insert(Object.assign({},
+                       newUser,
+                       {
+                               loginToken: { },
+                               sessionTokens: [ ],
+                       }),
+                       cb
+               );
+       },
+
+       getByLoginToken: function(token, cb)
+       {
+               db.users.findOne(
+                       { "loginToken.value": token },
+                       cb
+               );
+       },
+
+       getBySessionToken: function(token, cb)
+       {
+               db.users.findOne(
+                       { sessionTokens: token},
+                       cb
+               );
+       },
+
+       getById: function(uid, cb)
+       {
+               db.users.findOne(
+                       { _id: uid },
+                       cb
+               );
+       },
+
+       getByEmail: function(email, cb)
+       {
+               db.users.findOne(
+                       { email: email },
+                       cb
+               );
+       },
+
+       getByInitials: function(initials, cb)
+       {
+               db.users.findOne(
+                       { initials: initials },
+                       cb
+               );
+       },
+
+       getUnlogged: function(cb)
+       {
+               var tsNow = new Date().getTime();
+               // 86400000 = 24 hours in milliseconds
+               var day = 86400000;
+               db.users.find({}, (err,userArray) => {
+                       let unlogged = userArray.filter( u => {
+                               return u.sessionTokens.length==0 && u._id.getTimestamp().getTime() + day < tsNow;
+                       });
+                       cb(err, unlogged);
+               });
+       },
+
+       getAll: function(cb)
+       {
+               db.users.find({}, cb);
+       },
+
+       setLoginToken: function(token, uid, ip, cb)
+       {
+               db.users.update(
+                       { _id: uid },
+                       { $set: { loginToken: {
+                                       value: token,
+                                       timestamp: new Date().getTime(),
+                                       ip: ip,
+                               }}
+                       },
+                       cb
+               );
+       },
+
+       setSessionToken: function(token, uid, cb)
+       {
+               // Also empty the login token to invalidate future attempts
+               db.users.update(
+                       { _id: uid },
+                       {
+                               $set: { loginToken: {} },
+                               $push: { sessionTokens: {
+                                       $each: [token],
+                                       $slice: -7 //only allow 7 simultaneous connections per user (TODO?)
+                               }}
+                       },
+                       cb
+               );
+       },
+
+       removeToken: function(uid, token, cb)
+       {
+               db.users.update(
+                       { _id: uid },
+                       { $pull: {sessionTokens: token} },
+                       cb
+               );
+       },
+
+       // TODO: later, allow account removal
+       remove: function(uids)
+       {
+               db.users.remove({_id: uids});
+       },
+
+       /////////////////////
+       // ADVANCED FUNCTIONS
+
        create: function(newUser, callback)
        {
                // Determine initials from name parts
                let nameParts = newUser.name.split(/[ -]+/);
                let initials = nameParts.map( n => { return n.charAt(0).toLowerCase(); }).join("");
                // First retrieve all users with similar prefix initials
-               UserEntity.getInitialsByPrefix(initials, (err,userArray) => {
+               UserModel.getInitialsByPrefix(initials, (err,userArray) => {
                        if (!!userArray && userArray.length == 1)
                                initials = initials + "2"; //thus number == users count for this hash
                        else if (!!userArray && userArray.length > 1)
@@ -25,7 +170,7 @@ const UserModel =
                                initials = initials + (Math.max(...numbers)+1);
                        }
                        Object.assign(newUser, {initials: initials});
-                       UserEntity.insert(newUser, callback);
+                       UserModel.insert(newUser, callback);
                });
        },
 
@@ -41,15 +186,10 @@ const UserModel =
                return false;
        },
 
-       logout: function(uid, token, cb)
-       {
-               UserEntity.removeToken(uid, token, cb);
-       },
-
        cleanUsersDb: function()
        {
-               UserEntity.getUnlogged( (err,unlogged) => {
-                       UserEntity.remove(unlogged);
+               UserModel.getUnlogged( (err,unlogged) => {
+                       UserModel.remove(unlogged);
                });
        },
 }
index 95cd4d7..ec56309 100644 (file)
@@ -133,6 +133,9 @@ new Vue({
                                $("#leftButton, #rightButton").hide();
                                if (assessment.time > 0)
                                {
+
+// TODO: distinguish total exam time AND question time
+
                                        const deltaTime = !!paper ? Date.now() - paper.startTime : 0;
                                        this.remainingTime = assessment.time * 60 - Math.round(deltaTime / 1000);
                                        this.runTimer();
index 819d723..0fb5124 100644 (file)
@@ -1,3 +1,21 @@
+/*
+ * questions group by index prefix 1.2.3 1.1 ...etc --> '1'
+
+NOTE: questions can contain parameterized exercises (how ?
+--> describe variables (syntax ?)
+--> write javascript script (OK, users trusted ? ==> safe mode possible if public website)
+Imaginary example: (using math.js)
+       <params> (avant l'exo)
+       x: math.random()
+       y: math.random()
+       M: math.matrix([[7, x], [y, -3]]);
+       res: math.det(M)
+       </params>
+       <div>Calculer le déterminant de 
+       $$\begin{matrix}7 & x\\y & -3\end{matrix}$$</div>
+       * ...
+*/
+
 Vue.component("statements", {
        // 'answers' is an object containing
        //   'inputs'(array),
index 8064eaf..0e6a169 100644 (file)
@@ -1,7 +1,28 @@
-// TODO: YAML format for questions, parsed from text (nested questions)
-// Then yaml parsed to json --> array of indexed questions
-// Use open mode for question banks: add setting "nbQuestions" to show nbQuestions
-// at random among active questions
+/*Draft format (compiled to json)
+
+> Some global (HTML) intro
+
+<some html question (or/+ exercise intro)>
+
+       <some html subQuestion>
+       * some answer [trigger input/index in answers]
+
+       <another subquestion>
+
+               <sub-subQuestion>
+               + choix1
+               - choix 2
+               + choix 3
+               - choix4
+
+               <another sub sub>
+               * answer 2 (which can
+               be on
+               several lines)
+
+<Some second question>
+* With answer
+*/
 
 new Vue({
        el: '#course',
@@ -9,21 +30,12 @@ new Vue({
                display: "assessments", //or "students", or "grades" (admin mode)
                course: course,
                mode: "view", //or "edit" (some assessment)
-               // assessment data:
                monitorPwd: "",
                newAssessment: { name: "" },
                assessmentArray: assessmentArray,
                assessmentIndex: 0, //current edited assessment index
                assessment: { }, //copy of assessment at editing index in array
                assessmentText: "", //questions in an assessment, in text format
-               // grades data:
-               settings: {
-                       totalPoints: 20,
-                       halfPoints: false,
-                       zeroSum: false,
-               },
-               group: 1, //for detailed grades tables
-               grades: { }, //computed
        },
        mounted: function() {
                $('.modal').each( (i,elem) => {
@@ -273,79 +285,11 @@ new Vue({
                        );
                },
                // NOTE: artifact required for Vue v-model to behave well
-               checkBoxFixedId: function(i) {
+               checkboxFixedId: function(i) {
                        return "questionFixed" + i;
                },
-               checkBoxActiveId: function(i) {
+               checkboxActiveId: function(i) {
                        return "questionActive" + i;
                },
-               // GRADES:
-               gradeSettings: function() {
-                       $("#gradeSettings").modal("open");
-                       Materialize.updateTextFields(); //total points field in grade settings overlap
-               },
-               download: function() {
-                       // Download (all) grades as a CSV file
-                       let data = [ ];
-                       this.studentList(0).forEach( s => {
-                               let finalGrade = 0.;
-                               let gradesCount = 0;
-                               if (!!this.grades[s.number])
-                               {
-                                       Object.keys(this.grades[s.number]).forEach( assessmentName => {
-                                               s[assessmentName] = this.grades[s.number][assessmentName];
-                                               if (_.isNumeric(s[assessmentName]) && !isNaN(s[assessmentName]))
-                                               {
-                                                       finalGrade += s[assessmentName];
-                                                       gradesCount++;
-                                               }
-                                               if (gradesCount >= 1)
-                                                       finalGrade /= gradesCount;
-                                               s["final"] = finalGrade; //TODO: forbid "final" as assessment name
-                                       });
-                               }
-                               data.push(s); //number,name,group,assessName1...assessNameN,final
-                       });
-                       let csv = Papa.unparse(data, {
-                               quotes: true,
-                               header: true,
-                       });
-                       let downloadAnchor = $("#download");
-                       downloadAnchor.attr("download", this.course.code + "_results.csv");
-                       downloadAnchor.attr("href", "data:text/plain;charset=utf-8," + encodeURIComponent(csv));
-                       this.$refs.download.click()
-                       //downloadAnchor.click(); //fails
-               },
-               showDetails: function(group) {
-                       this.group = group;
-                       $("#detailedGrades").modal("open");
-               },
-               groupList: function() {
-                       let maxGrp = 1;
-                       this.course.students.forEach( s => {
-                               if (s.group > maxGrp)
-                                       maxGrp = s.group;
-                       });
-                       return _.range(1,maxGrp+1);
-               },
-               grade: function(assessmentIndex, studentNumber) {
-                       if (!this.grades[assessmentIndex] || !this.grades[assessmentIndex][studentNumber])
-                               return ""; //no grade yet
-                       return this.grades[assessmentIndex][studentNumber];
-               },
-               groupId: function(group, prefix) {
-                       return (prefix || "") + "group" + group;
-               },
-               togglePresence: function(number, index) {
-                       // UNIMPLEMENTED
-                       // TODO: if no grade (thus automatic 0), toggle "exempt" state on student for current exam
-                       // --> automatic update of grades view (just a few number to change)
-               },
-               computeGrades: function() {
-                       // UNIMPLEMENTED
-                       // TODO: compute all grades using settings (points, coefficients, bonus/malus...).
-                       // If some questions with free answers (open), display answers and ask teacher action.
-                       // TODO: need a setting for that too (by student, by exercice, by question)
-               },
        },
 });
diff --git a/public/javascripts/grade.js b/public/javascripts/grade.js
new file mode 100644 (file)
index 0000000..334b2a3
--- /dev/null
@@ -0,0 +1,88 @@
+//TODO: compute grades after exam (in teacher's view)
+
+new Vue({
+       el: '#grade',
+       data: {
+               assessmentArray: assessmentArray,
+               settings: {
+                       totalPoints: 20,
+                       halfPoints: false,
+                       zeroSum: false,
+               },
+               group: 1, //for detailed grades tables
+               grades: { }, //computed
+       },
+       mounted: function() {
+               // TODO
+       },
+       methods: {
+               // GRADES:
+               gradeSettings: function() {
+                       $("#gradeSettings").modal("open");
+                       Materialize.updateTextFields(); //total points field in grade settings overlap
+               },
+               download: function() {
+                       // Download (all) grades as a CSV file
+                       let data = [ ];
+                       this.studentList(0).forEach( s => {
+                               let finalGrade = 0.;
+                               let gradesCount = 0;
+                               if (!!this.grades[s.number])
+                               {
+                                       Object.keys(this.grades[s.number]).forEach( assessmentName => {
+                                               s[assessmentName] = this.grades[s.number][assessmentName];
+                                               if (_.isNumeric(s[assessmentName]) && !isNaN(s[assessmentName]))
+                                               {
+                                                       finalGrade += s[assessmentName];
+                                                       gradesCount++;
+                                               }
+                                               if (gradesCount >= 1)
+                                                       finalGrade /= gradesCount;
+                                               s["final"] = finalGrade; //TODO: forbid "final" as assessment name
+                                       });
+                               }
+                               data.push(s); //number,name,group,assessName1...assessNameN,final
+                       });
+                       let csv = Papa.unparse(data, {
+                               quotes: true,
+                               header: true,
+                       });
+                       let downloadAnchor = $("#download");
+                       downloadAnchor.attr("download", this.course.code + "_results.csv");
+                       downloadAnchor.attr("href", "data:text/plain;charset=utf-8," + encodeURIComponent(csv));
+                       this.$refs.download.click()
+                       //downloadAnchor.click(); //fails
+               },
+               showDetails: function(group) {
+                       this.group = group;
+                       $("#detailedGrades").modal("open");
+               },
+               groupList: function() {
+                       let maxGrp = 1;
+                       this.course.students.forEach( s => {
+                               if (s.group > maxGrp)
+                                       maxGrp = s.group;
+                       });
+                       return _.range(1,maxGrp+1);
+               },
+               grade: function(assessmentIndex, studentNumber) {
+                       if (!this.grades[assessmentIndex] || !this.grades[assessmentIndex][studentNumber])
+                               return ""; //no grade yet
+                       return this.grades[assessmentIndex][studentNumber];
+               },
+               groupId: function(group, prefix) {
+                       return (prefix || "") + "group" + group;
+               },
+               togglePresence: function(number, index) {
+                       // UNIMPLEMENTED
+                       // TODO: if no grade (thus automatic 0), toggle "exempt" state on student for current exam
+                       // --> automatic update of grades view (just a few number to change)
+               },
+               computeGrades: function() {
+                       // UNIMPLEMENTED
+                       // TODO: compute all grades using settings (points, coefficients, bonus/malus...).
+                       // If some questions with free answers (open), display answers and ask teacher action.
+                       // TODO: need a setting for that too (by student, by exercice, by question)
+               },
+       },
+});
diff --git a/public/javascripts/grading.js b/public/javascripts/grading.js
deleted file mode 100644 (file)
index 5264d83..0000000
+++ /dev/null
@@ -1 +0,0 @@
-//TODO: similar to monitor / course (need to factor some code)
index 1baab9c..8f414f5 100644 (file)
@@ -1,67 +1 @@
-a#rightButton {
-       position: absolute;
-       top: 0;
-       right: 0;
-}
-
-button.sendAnswer {
-       display: block;
-       margin: 0 auto;
-}
-
-.question {
-       margin: 20px 5px;
-       padding: 15px 0;
-}
-
-.question label {
-       color: black;
-}
-
-.question .choiceCorrect {
-       background-color: lightgreen;
-}
-
-.question .choiceWrong {
-       background-color: peachpuff;
-}
-
-.question .wording {
-       margin-bottom: 10px;
-       overflow: auto;
-}
-
-.question .option {
-       margin-left: 15px;
-}
-
-.question p {
-       margin-top: 10px;
-}
-
-.questionInactive {
-       background-color: lightgrey;
-}
-
-.introduction {
-       padding: 20px 5px;
-}
-
-.timer {
-       font-size: 2rem;
-}
-
-table.in-question {
-       border: 1px solid black;
-       width: auto;
-       margin: 10px auto;
-}
-
-table.in-question th, table.in-question td {
-       padding: 7px;
-       border-bottom: 1px solid grey;
-}
-
-/*table { border: none; border-collapse: collapse; }*/
-table.in-question td { border-left: 1px solid grey; }
-table.in-question td:first-child { border-left: none; }
+/* TODO */
index 20c9527..d62457c 100644 (file)
@@ -19,55 +19,3 @@ input#password {
 table.result {
        cursor: pointer;
 }
-
-tr.stats {
-       padding-top: 10px;
-}
-
-#questionList {
-       margin: 20px 5px;
-}
-
-.question {
-       margin: 20px 0;
-}
-
-.question .choiceCorrect {
-       background-color: lightgreen;
-}
-
-.question .wording {
-       margin-bottom: 10px;
-       overflow: auto;
-}
-
-.question .option {
-       margin-left: 15px;
-}
-
-.question p {
-       margin-top: 10px;
-}
-
-.questionInactive {
-       background-color: lightgrey;
-}
-
-.introduction {
-       margin-top: 20px;
-}
-
-table.in-question {
-       border: 1px solid black;
-       width: auto;
-       margin: 10px auto;
-}
-
-table.in-question th, table.in-question td {
-       padding: 7px;
-       border-bottom: 1px solid grey;
-}
-
-/*table { border: none; border-collapse: collapse; }*/
-table.in-question td { border-left: 1px solid grey; }
-table.in-question td:first-child { border-left: none; }
diff --git a/public/stylesheets/grade.css b/public/stylesheets/grade.css
new file mode 100644 (file)
index 0000000..2cb1f48
--- /dev/null
@@ -0,0 +1,11 @@
+table.result {
+       cursor: pointer;
+}
+
+tr.stats {
+       padding-top: 10px;
+}
+
+#questionList {
+       margin: 20px 5px;
+}
index d4dc11d..81b7bfa 100644 (file)
@@ -1,3 +1,14 @@
+a#rightButton {
+       position: absolute;
+       top: 0;
+       right: 0;
+}
+
 .on-left {
        margin-right: 20px;
 }
+
+h4.title {
+       cursor: pointer;
+       background-color: lightgrey;
+}
index b22b317..42a4895 100644 (file)
@@ -1,58 +1,16 @@
 .blur {
        background-color: lightsalmon;
 }
+
 .resize {
        font-style: italic;
        color: darkred;
 }
+
 .disconnect {
        background-color: grey;
 }
 
-/* TODO: factor this piece of code from assessment (and course, and here...) */
-.question {
-       margin: 20px 5px;
-       padding: 15px 0;
-}
-.question label {
-       color: black;
-}
-.question .choiceCorrect {
-       background-color: lightgreen;
-}
-.question .choiceWrong {
-       background-color: peachpuff;
-}
-.question .wording {
-       margin-bottom: 10px;
-}
-.question .option {
-       margin-left: 15px;
-}
-.question p {
-       margin-top: 10px;
-}
-.questionInactive {
-       background-color: lightgrey;
-}
-.introduction {
-       padding: 20px 5px;
-}
-table.in-question {
-       border: 1px solid black;
-       width: auto;
-       margin: 10px auto;
-}
-
-table.in-question th, table.in-question td {
-       padding: 7px;
-       border-bottom: 1px solid grey;
-}
-
-/*table { border: none; border-collapse: collapse; }*/
-table.in-question td { border-left: 1px solid grey; }
-table.in-question td:first-child { border-left: none; }
-
 .absent {
        opacity: 0.5;
        background-color: lightgrey;
diff --git a/public/stylesheets/statements.css b/public/stylesheets/statements.css
new file mode 100644 (file)
index 0000000..169e67d
--- /dev/null
@@ -0,0 +1,61 @@
+button.sendAnswer {
+       display: block;
+       margin: 0 auto;
+}
+
+.question {
+       margin: 20px 5px;
+       padding: 15px 0;
+}
+
+.question label {
+       color: black;
+}
+
+.question .choiceCorrect {
+       background-color: lightgreen;
+}
+
+.question .choiceWrong {
+       background-color: peachpuff;
+}
+
+.question .wording {
+       margin-bottom: 10px;
+       overflow: auto;
+}
+
+.question .option {
+       margin-left: 15px;
+}
+
+.question p {
+       margin-top: 10px;
+}
+
+.questionInactive {
+       background-color: lightgrey;
+}
+
+.introduction {
+       padding: 20px 5px;
+}
+
+.timer {
+       font-size: 2rem;
+}
+
+table.in-question {
+       border: 1px solid black;
+       width: auto;
+       margin: 10px auto;
+}
+
+table.in-question th, table.in-question td {
+       padding: 7px;
+       border-bottom: 1px solid grey;
+}
+
+/*table { border: none; border-collapse: collapse; }*/
+table.in-question td { border-left: 1px solid grey; }
+table.in-question td:first-child { border-left: none; }
index 3a91b5a..d9f83ea 100644 (file)
@@ -2,7 +2,6 @@ let router = require("express").Router();
 const access = require("../utils/access");
 const UserModel = require("../models/user");
 const AssessmentModel = require("../models/assessment");
-const AssessmentEntity = require("../entities/assessment");
 const CourseModel = require("../models/course");
 const params = require("../config/parameters");
 const validator = require("../public/javascripts/utils/validation");
@@ -68,7 +67,7 @@ router.get("/start/assessment", access.ajax, (req,res) => {
                                        maxAge: params.cookieExpire,
                                });
                        }
-                       res.json(ret); //contains questions+password(or paper if resuming)
+                       res.json(ret); //contains password (or paper if resuming)
                });
        });
 });
@@ -119,7 +118,7 @@ router.get("/end/assessment", access.ajax, (req,res) => {
        if (error.length > 0)
                return res.json({errmsg:error});
        // Destroy pwd, set endTime
-       AssessmentEntity.endAssessment(ObjectId(aid), number, password, (err,ret) => {
+       AssessmentModel.endAssessment(ObjectId(aid), number, password, (err,ret) => {
                access.checkRequest(res,err,ret,"Cannot end assessment", () => {
                        res.clearCookie('password');
                        res.json({});
index e07e243..89b38b3 100644 (file)
@@ -3,7 +3,6 @@ const access = require("../utils/access.js");
 const validator = require("../public/javascripts/utils/validation");
 const sanitizeHtml = require('sanitize-html');
 const ObjectId = require("bson-objectid");
-const CourseEntity = require("../entities/course");
 const CourseModel = require("../models/course");
 
 router.get('/add/course', access.ajax, access.logged, (req,res) => {
@@ -12,7 +11,7 @@ router.get('/add/course', access.ajax, access.logged, (req,res) => {
        let error = validator({code:code}, "Course");
        if (error.length > 0)
                return res.json({errmsg:error});
-       CourseEntity.insert(req.user._id, code, description, (err,course) => {
+       CourseModel.insert(req.user._id, code, description, (err,course) => {
                access.checkRequest(res, err, course, "Course addition failed", () => {
                        res.json(course);
                });
@@ -55,7 +54,7 @@ router.get('/get/student', access.ajax, (req,res) => {
        let error = validator({ _id: cid, students: [{number:number}] }, "Course");
        if (error.length > 0)
                return res.json({errmsg:error});
-       CourseEntity.getStudent(ObjectId(cid), number, (err,ret) => {
+       CourseModel.getStudent(ObjectId(cid), number, (err,ret) => {
                access.checkRequest(res, err, ret, "Failed retrieving student", () => {
                        res.json({student: ret.students[0]});
                });
@@ -74,6 +73,4 @@ router.get('/remove/course', access.ajax, access.logged, (req,res) => {
        });
 });
 
-// TODO: grading page (for at least partially open-questions exams)
-
 module.exports = router;
index a78ba0d..c92f2e7 100644 (file)
@@ -1,15 +1,14 @@
 let router = require("express").Router();
 const access = require("../utils/access");
-const UserEntity = require("../entities/user");
-const AssessmentEntity = require("../entities/assessment");
-const CourseModel = require("../models/course");
+const UserModel = require("../models/user");
 const AssessmentModel = require("../models/assessment");
+const CourseModel = require("../models/course");
 
 // Actual pages (least specific last)
 
 // List initials and count assessments
 router.get("/", (req,res) => {
-       UserEntity.getAll( (err,userArray) => {
+       UserModel.getAll( (err,userArray) => {
                if (!!err)
                        return res.json(err);
                res.render("index", {
@@ -76,7 +75,7 @@ router.get("/:initials([a-z0-9]+)/:courseCode([a-z0-9._-]+)", (req,res) => {
        let code = req.params["courseCode"];
        CourseModel.getByRefs(initials, code, (err,course) => {
                access.checkRequest(res, err, course, "Course not found", () => {
-                       AssessmentEntity.getByCourse(course._id, (err2,assessmentArray) => {
+                       AssessmentModel.getByCourse(course._id, (err2,assessmentArray) => {
                                if (!!err)
                                        return res.json(err);
                                access.getUser(req, res, (err2,user) => {
@@ -97,6 +96,18 @@ router.get("/:initials([a-z0-9]+)/:courseCode([a-z0-9._-]+)", (req,res) => {
        });
 });
 
+// Grading students answers: --> after identification (password), always send secret with requests
+router.get("/:initials([a-z0-9]+)/:courseCode([a-z0-9._-]+)/grade", (req,res) => {
+       let initials = req.params["initials"];
+       let code = req.params["courseCode"];
+       // TODO: if (main) teacher, also send secret, saving one request
+       res.render("grade", {
+               title: "grade exams " + code + "/" + name,
+               initials: initials,
+               courseCode: code,
+       });
+});
+
 // Display assessment (exam or open status)
 router.get("/:initials([a-z0-9]+)/:courseCode([a-z0-9._-]+)/:assessmentName([a-z0-9._-]+)", (req,res) => {
        let initials = req.params["initials"];
index 2de89b0..5dab77e 100644 (file)
@@ -1,7 +1,6 @@
 let router = require("express").Router();
 const validator = require('../public/javascripts/utils/validation');
 const UserModel = require('../models/user');
-const UserEntity = require('../entities/user');
 const maild = require('../utils/mailer');
 const TokenGen = require("../utils/tokenGenerator");
 const access = require("../utils/access");
@@ -12,7 +11,7 @@ function sendLoginToken(subject, to, res)
 {
        // Set login token and send welcome(back) email with auth link
        let token = TokenGen.generate(params.token.length);
-       UserEntity.setLoginToken(token, to._id, to.ip, (err,ret) => {
+       UserModel.setLoginToken(token, to._id, to.ip, (err,ret) => {
                access.checkRequest(res, err, ret, "Cannot set login token", () => {
                        maild.send({
                                from: params.mail.from,
@@ -41,7 +40,7 @@ router.get('/register', access.ajax, access.unlogged, (req,res) => {
                return res.json({errmsg:error});
        if (!UserModel.whitelistCheck(newUser.email))
                return res.json({errmsg: "Email not in whitelist"});
-       UserEntity.getByEmail(newUser.email, (err,user0) => {
+       UserModel.getByEmail(newUser.email, (err,user0) => {
                access.checkRequest(res, err, !user0?["ok"]:{}, "An account exists with this email", () => {
                        UserModel.create(newUser, (err,user) => {
                                access.checkRequest(res, err, user, "Registration failed", () => {
@@ -59,7 +58,7 @@ router.get('/sendtoken', access.ajax, access.unlogged, (req,res) => {
        let error = validator({email:email}, "User");
        if (error.length > 0)
                return res.json({errmsg:error});
-       UserEntity.getByEmail(email, (err,user) => {
+       UserModel.getByEmail(email, (err,user) => {
                access.checkRequest(res, err, user, "Unknown user", () => {
                        user.ip = req.ip;
                        sendLoginToken("Token for " + params.siteURL, user, res);
@@ -73,7 +72,7 @@ router.get('/authenticate', access.unlogged, (req,res) => {
        let error = validator({token:loginToken}, "User");
        if (error.length > 0)
                return res.json({errmsg:error});
-       UserEntity.getByLoginToken(loginToken, (err,user) => {
+       UserModel.getByLoginToken(loginToken, (err,user) => {
                access.checkRequest(res, err, user, "Invalid token", () => {
                        if (user.loginToken.ip != req.ip)
                                return res.json({errmsg: "IP address mismatch"});
@@ -84,7 +83,7 @@ router.get('/authenticate', access.unlogged, (req,res) => {
                                return res.json({errmsg: "Token expired"});
                        // Generate and update session token + destroy login token
                        let token = TokenGen.generate(params.token.length);
-                       UserEntity.setSessionToken(token, user._id, (err,ret) => {
+                       UserModel.setSessionToken(token, user._id, (err,ret) => {
                                access.checkRequest(res, err, ret, "Authentication failed", () => {
                                        // Set cookies and redirect to user main control panel
                                        res.cookie("token", token, {
@@ -103,7 +102,7 @@ router.get('/authenticate', access.unlogged, (req,res) => {
 });
 
 router.get('/logout', access.logged, (req,res) => {
-       UserModel.logout(req.user._id, req.cookies.token, (err,ret) => {
+       UserModel.removeToken(req.user._id, req.cookies.token, (err,ret) => {
                access.checkRequest(res, err, ret, "Logout failed", () => {
                        res.clearCookie("initials");
                        res.clearCookie("token");
similarity index 100%
rename from stt.csv
rename to setup/students_small_test.csv
index 57fd657..30beb32 100644 (file)
@@ -1,6 +1,5 @@
 const message = require("./public/javascripts/utils/socketMessages");
 const params = require("./config/parameters");
-const AssessmentEntity = require("./entities/assessment");
 const AssessmentModel = require("./models/assessment");
 const ObjectId = require("bson-objectid");
 
@@ -43,7 +42,7 @@ module.exports = function(io)
                                        socket.broadcast.to(aid + "_teacher").emit(message.studentFullscreen, m);
                                });
                                socket.on("disconnect", () => { //notify monitor + server
-                                       AssessmentEntity.setDiscoTime(ObjectId(aid), number);
+                                       AssessmentModel.setDiscoTime(ObjectId(aid), number);
                                        socket.broadcast.to(aid + "_teacher").emit(message.studentDisconnect, {number: number});
                                });
                        });
index 1f91724..23a0b79 100644 (file)
@@ -1,5 +1,5 @@
 const _ = require("underscore");
-const UserEntity = require("../entities/user");
+const UserModel = require("../models/user");
 
 let Access =
 {
@@ -7,7 +7,7 @@ let Access =
        {
                if (!res.locals.loggedIn)
                        return callback({errmsg: "Not logged in!"}, undefined);
-               UserEntity.getBySessionToken(req.cookies.token, function(err, user) {
+               UserModel.getBySessionToken(req.cookies.token, function(err, user) {
                        if (!user)
                                return callback({errmsg: "Not logged in!"}, undefined);
                        return callback(null, user);
index 87d9fba..531354d 100644 (file)
@@ -1,6 +1,7 @@
 extends withQuestions
 
 block append stylesheets
+       link(rel="stylesheet" href="/stylesheets/statements.css")
        link(rel="stylesheet" href="/stylesheets/assessment.css")
        noscript
                meta(http-equiv="Refresh" content="0; URL=/enablejs")
index 87788e3..8e2fe51 100644 (file)
@@ -1,6 +1,7 @@
 extends withQuestions
 
 block append stylesheets
+       link(rel="stylesheet" href="/stylesheets/statements.css")
        link(rel="stylesheet" href="/stylesheets/course.css")
 
 block content
@@ -17,6 +18,15 @@ block content
                                                a.waves-effect.waves-light.btn(href="#!" @click="addAssessment()")
                                                        span Submit
                                                        i.material-icons.right send
+               .row(v-show="mode=='view'")
+                       
+                       
+                       
+                       
+                       
+                       
+                       
+                       
                        #assessmentSettings.modal
                                .modal-content
                                        form
@@ -26,6 +36,9 @@ block content
                                                p
                                                        input#secure(name="status" type="radio" value="secure" v-model="assessment.mode")
                                                        label(for="secure") Exam mode, secured (class only)
+                                               p
+                                                       input#watch(name="status" type="radio" value="watch" v-model="assessment.mode")
+                                                       label(for="watch") Exam mode, watched (class only)
                                                p
                                                        input#exam(name="status" type="radio" value="exam" v-model="assessment.mode")
                                                        label(for="exam") Exam mode, free (class only)
@@ -59,39 +72,13 @@ block content
                                .modal-footer
                                        .center-align
                                                a.modal-action.modal-close.waves-effect.waves-light.btn-flat(href="#!") Done
-                       #gradeSettings.modal
-                               .modal-content
-                                       form(@submit.prevent="computeGrades")
-                                               .input-field
-                                                       input#points(type="number" v-model.number="settings.totalPoints" required)
-                                                       label(for="points") Total points
-                                               p
-                                                       input#partial(type="checkbox" v-model="settings.halfPoint")
-                                                       label(for="partial") Half point for partial answers? (&ge; 50%)
-                                               p
-                                                       input#malus(type="checkbox" v-model="settings.zeroSum")
-                                                       label(for="malus") Lose points on wrong answers? ("Zero-sum" game)
-                               .modal-footer
-                                       .center-align
-                                               a.modal-action.modal-close.waves-effect.waves-light.btn(href="#!" @click="computeGrades()")
-                                                       span Compute
-                                                       i.material-icons.right send
-                       #detailedGrades.modal
-                               .modal-content
-                                       table
-                                               thead
-                                                       tr
-                                                               th Number
-                                                               th(v-for="assessment in assessmentArray") {{ assessment.name }}
-                                               tbody
-                                                       tr.grade(v-for="student in studentList(group)")
-                                                               td {{ student.number }}
-                                                               td(v-for="(assessment,i) in assessmentArray" @click="togglePresence(student.number,i)")
-                                                                       | {{ grade(i,student.number) }}
-                               .modal-footer
-                                       .center-align
-                                               a.modal-action.modal-close.waves-effect.waves-light.btn-flat(href="#!") Close
-               .row(v-show="mode=='view'")
+                       
+                       
+                       
+                       
+                       
+                       
+                       
                        .col.s12.m10.offset-m1
                                if teacher
                                        h4.title(@click="toggleDisplay('students')") Students
@@ -131,32 +118,6 @@ block content
                                                                td {{ assessment.mode }}
                                                                td {{ assessment.questions.reduce( (a,b) => { return b.active ? a+1 : a; }, 0) }}
                                                                td {{ assessment.time }}
-                               if teacher
-                                       h4.title(@click="toggleDisplay('grades')") Grades
-                                       .card(v-show="display=='grades'")
-                                               .center-align
-                                                       button.on-left.waves-effect.waves-light.btn(@click="gradeSettings()") Settings
-                                                       a#download.hide(href="#" ref="download")
-                                                       button.waves-effect.waves-light.btn(@click="download") Download
-                                               ul.tabs.tabs-fixed-width
-                                                       li.tab
-                                                               a(href="#group0") All
-                                                       li.tab(v-for="group in groupList()")
-                                                               a(:href="groupId(group,'#')") G.{{ group }}
-                                               table.result(:id="groupId(group)" v-for="group in [0].concat(groupList())" @click="showDetails(group)")
-                                                       thead
-                                                               tr
-                                                                       th Number
-                                                                       th Name
-                                                                       th Final
-                                                       tbody
-                                                               tr.grade(v-for="student in studentList(group)")
-                                                                       td {{ student.number }}
-                                                                       td {{ student.name }}
-                                                                       td grade...
-                                                                       //td {{ grades[student.number].final }}
-                                                               tr.stats
-                                                                       td(colspan="4") Stats: range= stdev= mean=
                if teacher
                        .row(v-show="mode=='edit'")
                                .col.s12.m10.offset-m1
@@ -172,10 +133,12 @@ block content
                                                                .wording(v-html="question.wording")
                                                                .option(v-for="(option,j) in question.options" :class="{choiceCorrect:question.answer.includes(j)}" v-html="option")
                                                                p
-                                                                       input(:id="checkBoxFixedId(i)" type="checkbox" v-model="question.fixed")
-                                                                       label.on-left(:for="checkBoxFixedId(i)") Fixed
-                                                                       input(:id="checkBoxActiveId(i)" type="checkbox" v-model="question.active")
-                                                                       label(:for="checkBoxActiveId(i)") Active
+                                                                       input(:id="checkboxFixedId(i)" type="checkbox" v-model="question.fixed")
+                                                                       label.on-left(:for="checkboxFixedId(i)") Fixed
+                                                                       input(:id="checkboxActiveId(i)" type="checkbox" v-model="question.active")
+                                                                       label(:for="checkboxActiveId(i)") Active
+                                                                       input(time default 0 = untimed)
+                                                                       label Time for the question
                                                .center-align
                                                        button.waves-effect.waves-light.btn.on-left(@click="mode='view'") Cancel
                                                        button.waves-effect.waves-light.btn(@click="updateAssessment") Send
diff --git a/views/grade.pug b/views/grade.pug
new file mode 100644 (file)
index 0000000..d38032a
--- /dev/null
@@ -0,0 +1,111 @@
+extends withQuestions
+
+block append stylesheets
+       link(rel="stylesheet" href="/stylesheets/statements.css")
+       link(rel="stylesheet" href="/stylesheets/grade.css")
+
+block rightMenu
+       a#rightButton.btn-floating.btn-large.grey(href="grade")
+               i.material-icons mode_edit
+
+
+                       #gradeSettings.modal
+                               .modal-content
+                                       form(@submit.prevent="computeGrades")
+                                               .input-field
+                                                       input#points(type="number" v-model.number="settings.totalPoints" required)
+                                                       label(for="points") Total points
+                                               p
+                                                       input#partial(type="checkbox" v-model="settings.halfPoint")
+                                                       label(for="partial") Half point for partial answers? (&ge; 50%)
+                                               p
+                                                       input#malus(type="checkbox" v-model="settings.zeroSum")
+                                                       label(for="malus") Lose points on wrong answers? ("Zero-sum" game)
+                               .modal-footer
+                                       .center-align
+                                               a.modal-action.modal-close.waves-effect.waves-light.btn(href="#!" @click="computeGrades()")
+                                                       span Compute
+                                                       i.material-icons.right send
+                       #detailedGrades.modal
+                               .modal-content
+                                       table
+                                               thead
+                                                       tr
+                                                               th Number
+                                                               th(v-for="assessment in assessmentArray") {{ assessment.name }}
+                                               tbody
+                                                       tr.grade(v-for="student in studentList(group)")
+                                                               td {{ student.number }}
+                                                               td(v-for="(assessment,i) in assessmentArray" @click="togglePresence(student.number,i)")
+                                                                       | {{ grade(i,student.number) }}
+                               .modal-footer
+                                       .center-align
+                                               a.modal-action.modal-close.waves-effect.waves-light.btn-flat(href="#!") Close
+
+
+block content
+       .container#grading
+               .row
+                       .col.s12.m10.offset-m1
+                               h4 #{courseCode} grading
+                               // TODO: Allow grading per student, per question or sub-question
+                               .card
+                                       ul.tabs.tabs-fixed-width
+                                               li.tab
+                                                       a(href="#group0") All
+                                               li.tab(v-for="group in groupList()")
+                                                       a(:href="groupId(group,'#')") G.{{ group }}
+                                       table(:id="groupId(group)" v-for="group in [0].concat(groupList())")
+                                               thead
+                                                       tr
+                                                               th Name
+                                                               th(v-for="(q,i) in assessment.questions") Q.{{ (i+1) }}
+                                               tbody
+                                                       tr.assessment(v-for="s in studentList(group)")
+                                                               td {{ s.name }}
+                                                               td(v-for="(q,i) in assessment.questions" :style="{background-color: getColor(number,i)}" @click="seeDetails(number,i)") &nbsp;
+                               h4.title(@click="toggleDisplay('assessment')") Assessment
+                               div(v-show="display=='assessment'")
+                                       .card
+                                               .introduction(v-html="assessment.introduction")
+                                       .card
+                                               statements(:questions="assessment.questions" :answers:"answers")
+
+
+
+                               if teacher
+                                       h4.title(@click="toggleDisplay('grades')") Grades
+                                       .card(v-show="display=='grades'")
+                                               .center-align
+                                                       button.on-left.waves-effect.waves-light.btn(@click="gradeSettings()") Settings
+                                                       a#download.hide(href="#" ref="download")
+                                                       button.waves-effect.waves-light.btn(@click="download") Download
+                                               ul.tabs.tabs-fixed-width
+                                                       li.tab
+                                                               a(href="#group0") All
+                                                       li.tab(v-for="group in groupList()")
+                                                               a(:href="groupId(group,'#')") G.{{ group }}
+                                               table.result(:id="groupId(group)" v-for="group in [0].concat(groupList())" @click="showDetails(group)")
+                                                       thead
+                                                               tr
+                                                                       th Number
+                                                                       th Name
+                                                                       th Final
+                                                       tbody
+                                                               tr.grade(v-for="student in studentList(group)")
+                                                                       td {{ student.number }}
+                                                                       td {{ student.name }}
+                                                                       td grade...
+                                                                       //td {{ grades[student.number].final }}
+                                                               tr.stats
+                                                                       td(colspan="4") Stats: range= stdev= mean=
+
+
+
+
+block append javascripts
+       script.
+               const initials = "#{initials}";
+               const courseCode = "#{courseName}";
+       script(src="/javascripts/utils/sha1.js")
+       script(src="/javascripts/grade.js")
diff --git a/views/grading.pug b/views/grading.pug
deleted file mode 100644 (file)
index 5a0abf4..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-extends withQuestions
-
-block content
-       .container#grading
-               .row
-                       .col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
-                               h4= examName
-                               h4.title(@click="toggleDisplay('grading')") Grading
-                               // TODO: Allow grading per student, per question or sub-question
-                               .card(v-show="display=='grading'")
-                                       ul.tabs.tabs-fixed-width
-                                               li.tab
-                                                       a(href="#group0") All
-                                               li.tab(v-for="group in groupList()")
-                                                       a(:href="groupId(group,'#')") G.{{ group }}
-                                       table(:id="groupId(group)" v-for="group in [0].concat(groupList())")
-                                               thead
-                                                       tr
-                                                               th Name
-                                                               th(v-for="(q,i) in assessment.questions") Q.{{ (i+1) }}
-                                               tbody
-                                                       tr.assessment(v-for="s in studentList(group)")
-                                                               td {{ s.name }}
-                                                               td(v-for="(q,i) in assessment.questions" :style="{background-color: getColor(number,i)}" @click="seeDetails(number,i)") &nbsp;
-                               h4.title(@click="toggleDisplay('assessment')") Assessment
-                               div(v-show="display=='assessment'")
-                                       .card
-                                               .introduction(v-html="assessment.introduction")
-                                       .card
-                                               statements(:questions="assessment.questions" :answers:"answers")
-
-block append javascripts
-       script.
-               const examName = "#{examName}";
-               const courseCode = "#{courseName}";
-               const initials = "#{initials}";
-       script(src="/javascripts/components/statements.js")
-       script(src="/javascripts/utils/sha1.js")
-       script(src="/javascripts/grading.js")
index 9856758..4eb1653 100644 (file)
@@ -6,7 +6,7 @@ block stylesheets
 block content
        .container
                .row
-                       .card.col.s12.m8.offset-m2.l6.offset-l3.xl4.offset-xl4
+                       .card.col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
                                table
                                        thead
                                                tr
index 31adc75..32f0495 100644 (file)
@@ -6,7 +6,7 @@ block stylesheets
 block content
        .container#login
                .row
-                       .col.s12.m8.offset-m2.l6.offset-l3.xl4.offset-xl4
+                       .col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
                                .card#form
                                        form(@submit.prevent="submit")
                                                .input-field
index b3da2d6..671b136 100644 (file)
@@ -1,6 +1,7 @@
 extends withQuestions
 
 block append stylesheets
+       link(rel="stylesheet" href="/stylesheets/statements.css")
        link(rel="stylesheet" href="/stylesheets/monitor.css")
 
 block content