From: Benjamin Auder Date: Tue, 13 Feb 2018 17:45:19 +0000 (+0100) Subject: refactoring, better README (breaking commit...) X-Git-Url: https://git.auder.net/doc/html/%7B%7B%20path%28%27mixstore_static_policy%27%29%20%7D%7D?a=commitdiff_plain;h=43828378be054cf3604b753e8d9ab24af911188f;p=qomet.git refactoring, better README (breaking commit...) --- diff --git a/README.md b/README.md index 845524b..0fc0587 100644 --- 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/): `` 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/)
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/)
Nice looking realtime feedback (lacking in evalbox), but thought for interactive classes. diff --git a/TODO b/TODO deleted file mode 100644 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 answer [trigger input/index in answers] - - - - - + choix1 - - choix 2 - + choix 3 - - choix4 - - - * answer 2 (which can - be on - several lines) - - -* 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) - (avant l'exo) - x: math.random() - y: math.random() - M: math.matrix([[7, x], [y, -3]]); - res: math.det(M) - -
Calculer le déterminant de - $$\begin{matrix}7 & x\\y & -3\end{matrix}$$
- * ... diff --git a/entities/assessment.js b/entities/assessment.js deleted file mode 100644 index b4b6d89..0000000 --- a/entities/assessment.js +++ /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 index 9b3fb46..0000000 --- a/entities/course.js +++ /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 index 72b3a43..0000000 --- a/entities/user.js +++ /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; diff --git a/models/assessment.js b/models/assessment.js index d0e0d0b..c7cb0fd 100644 --- a/models/assessment.js +++ b/models/assessment.js @@ -1,20 +1,283 @@ -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; diff --git a/models/course.js b/models/course.js index 53419c1..631bab9 100644 --- a/models/course.js +++ b/models/course.js @@ -1,17 +1,116 @@ -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); }); }); }, diff --git a/models/user.js b/models/user.js index c6fa776..4b66c88 100644 --- a/models/user.js +++ b/models/user.js @@ -1,15 +1,160 @@ -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); }); }, } diff --git a/public/javascripts/assessment.js b/public/javascripts/assessment.js index 95cd4d7..ec56309 100644 --- a/public/javascripts/assessment.js +++ b/public/javascripts/assessment.js @@ -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(); diff --git a/public/javascripts/components/statements.js b/public/javascripts/components/statements.js index 819d723..0fb5124 100644 --- a/public/javascripts/components/statements.js +++ b/public/javascripts/components/statements.js @@ -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) + (avant l'exo) + x: math.random() + y: math.random() + M: math.matrix([[7, x], [y, -3]]); + res: math.det(M) + +
Calculer le déterminant de + $$\begin{matrix}7 & x\\y & -3\end{matrix}$$
+ * ... +*/ + Vue.component("statements", { // 'answers' is an object containing // 'inputs'(array), diff --git a/public/javascripts/course.js b/public/javascripts/course.js index 8064eaf..0e6a169 100644 --- a/public/javascripts/course.js +++ b/public/javascripts/course.js @@ -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 answer [trigger input/index in answers] + + + + + + choix1 + - choix 2 + + choix 3 + - choix4 + + + * answer 2 (which can + be on + several lines) + + +* 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 index 0000000..334b2a3 --- /dev/null +++ b/public/javascripts/grade.js @@ -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 index 5264d83..0000000 --- a/public/javascripts/grading.js +++ /dev/null @@ -1 +0,0 @@ -//TODO: similar to monitor / course (need to factor some code) diff --git a/public/stylesheets/assessment.css b/public/stylesheets/assessment.css index 1baab9c..8f414f5 100644 --- a/public/stylesheets/assessment.css +++ b/public/stylesheets/assessment.css @@ -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 */ diff --git a/public/stylesheets/course.css b/public/stylesheets/course.css index 20c9527..d62457c 100644 --- a/public/stylesheets/course.css +++ b/public/stylesheets/course.css @@ -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 index 0000000..2cb1f48 --- /dev/null +++ b/public/stylesheets/grade.css @@ -0,0 +1,11 @@ +table.result { + cursor: pointer; +} + +tr.stats { + padding-top: 10px; +} + +#questionList { + margin: 20px 5px; +} diff --git a/public/stylesheets/layout.css b/public/stylesheets/layout.css index d4dc11d..81b7bfa 100644 --- a/public/stylesheets/layout.css +++ b/public/stylesheets/layout.css @@ -1,3 +1,14 @@ +a#rightButton { + position: absolute; + top: 0; + right: 0; +} + .on-left { margin-right: 20px; } + +h4.title { + cursor: pointer; + background-color: lightgrey; +} diff --git a/public/stylesheets/monitor.css b/public/stylesheets/monitor.css index b22b317..42a4895 100644 --- a/public/stylesheets/monitor.css +++ b/public/stylesheets/monitor.css @@ -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 index 0000000..169e67d --- /dev/null +++ b/public/stylesheets/statements.css @@ -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; } diff --git a/routes/assessments.js b/routes/assessments.js index 3a91b5a..d9f83ea 100644 --- a/routes/assessments.js +++ b/routes/assessments.js @@ -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({}); diff --git a/routes/courses.js b/routes/courses.js index e07e243..89b38b3 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -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; diff --git a/routes/pages.js b/routes/pages.js index a78ba0d..c92f2e7 100644 --- a/routes/pages.js +++ b/routes/pages.js @@ -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"]; diff --git a/routes/users.js b/routes/users.js index 2de89b0..5dab77e 100644 --- a/routes/users.js +++ b/routes/users.js @@ -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"); diff --git a/stt.csv b/setup/students_small_test.csv similarity index 100% rename from stt.csv rename to setup/students_small_test.csv diff --git a/sockets.js b/sockets.js index 57fd657..30beb32 100644 --- a/sockets.js +++ b/sockets.js @@ -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}); }); }); diff --git a/utils/access.js b/utils/access.js index 1f91724..23a0b79 100644 --- a/utils/access.js +++ b/utils/access.js @@ -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); diff --git a/views/assessment.pug b/views/assessment.pug index 87d9fba..531354d 100644 --- a/views/assessment.pug +++ b/views/assessment.pug @@ -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") diff --git a/views/course.pug b/views/course.pug index 87788e3..8e2fe51 100644 --- a/views/course.pug +++ b/views/course.pug @@ -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? (≥ 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 index 0000000..d38032a --- /dev/null +++ b/views/grade.pug @@ -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? (≥ 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)")   + 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 index 5a0abf4..0000000 --- a/views/grading.pug +++ /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)")   - 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") diff --git a/views/index.pug b/views/index.pug index 9856758..4eb1653 100644 --- a/views/index.pug +++ b/views/index.pug @@ -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 diff --git a/views/login.pug b/views/login.pug index 31adc75..32f0495 100644 --- a/views/login.pug +++ b/views/login.pug @@ -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 diff --git a/views/monitor.pug b/views/monitor.pug index b3da2d6..671b136 100644 --- a/views/monitor.pug +++ b/views/monitor.pug @@ -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