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
## 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,
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
* [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.
+++ /dev/null
-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>
- * ...
+++ /dev/null
-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;
+++ /dev/null
-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;
+++ /dev/null
-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;
-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);
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; });
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);
});
},
{
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))
// 2) Replace assessment
delete assessment["_id"];
assessment.cid = ObjectId(assessment.cid);
- AssessmentEntity.replace(aid, assessment, cb);
+ AssessmentModel.replace(aid, assessment, cb);
});
});
},
// 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)
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}); }
+ );
});
});
},
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);
});
},
- // 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;
-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);
});
}
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);
});
}
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);
});
});
},
-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)
initials = initials + (Math.max(...numbers)+1);
}
Object.assign(newUser, {initials: initials});
- UserEntity.insert(newUser, callback);
+ UserModel.insert(newUser, callback);
});
},
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);
});
},
}
$("#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();
+/*
+ * 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),
-// 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',
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) => {
);
},
// 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)
- },
},
});
--- /dev/null
+//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)
+ },
+ },
+});
+++ /dev/null
-//TODO: similar to monitor / course (need to factor some code)
-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 */
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; }
--- /dev/null
+table.result {
+ cursor: pointer;
+}
+
+tr.stats {
+ padding-top: 10px;
+}
+
+#questionList {
+ margin: 20px 5px;
+}
+a#rightButton {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
.on-left {
margin-right: 20px;
}
+
+h4.title {
+ cursor: pointer;
+ background-color: lightgrey;
+}
.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;
--- /dev/null
+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; }
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");
maxAge: params.cookieExpire,
});
}
- res.json(ret); //contains questions+password(or paper if resuming)
+ res.json(ret); //contains password (or paper if resuming)
});
});
});
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({});
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) => {
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);
});
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]});
});
});
});
-// TODO: grading page (for at least partially open-questions exams)
-
module.exports = router;
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", {
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) => {
});
});
+// 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"];
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");
{
// 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,
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", () => {
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);
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"});
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, {
});
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");
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");
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});
});
});
const _ = require("underscore");
-const UserEntity = require("../entities/user");
+const UserModel = require("../models/user");
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);
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")
extends withQuestions
block append stylesheets
+ link(rel="stylesheet" href="/stylesheets/statements.css")
link(rel="stylesheet" href="/stylesheets/course.css")
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
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)
.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
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
.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
--- /dev/null
+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")
+++ /dev/null
-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")
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
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
extends withQuestions
block append stylesheets
+ link(rel="stylesheet" href="/stylesheets/statements.css")
link(rel="stylesheet" href="/stylesheets/monitor.css")
block content