From f03a2ad9e0b2fa36051def18d4c19c2f293cac1d Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Sun, 28 Jan 2018 18:00:53 +0100
Subject: [PATCH] Prevent student from re-starting the same quiz, implement
 resuming logic (still buggish)

---
 entities/assessment.js           | 44 +++++++++++++++++
 models/assessment.js             | 47 +++++++++++++++---
 public/javascripts/assessment.js | 83 ++++++++++++++++++++------------
 routes/assessments.js            | 22 +++++----
 views/assessment.pug             | 11 ++---
 5 files changed, 153 insertions(+), 54 deletions(-)

diff --git a/entities/assessment.js b/entities/assessment.js
index 2104193..a8a781b 100644
--- a/entities/assessment.js
+++ b/entities/assessment.js
@@ -106,6 +106,25 @@ const AssessmentEntity =
 		);
 	},
 
+	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)
 	{
 		// TODO: security, do not re-do tasks if already done
@@ -123,6 +142,31 @@ const AssessmentEntity =
 		);
 	},
 
+
+	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);
+				for (let p of a.papers)
+				{
+					for (let i of p.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)
 	{
diff --git a/models/assessment.js b/models/assessment.js
index 3f89cc9..a1a41e2 100644
--- a/models/assessment.js
+++ b/models/assessment.js
@@ -57,19 +57,52 @@ const AssessmentModel =
 	},
 
 	// Set password in responses collection
-	startSession: function(aid, number, cb)
+	startSession: function(aid, number, password, cb)
 	{
-		const password = TokenGen.generate(12); //arbitrary number, 12 seems enough...
-		AssessmentEntity.getQuestions(aid, (err,questions) => {
-			AssessmentEntity.startSession(aid, number, password, (err2,ret) => {
-				cb(err, {
-					questions: questions,
-					password: password,
+		AssessmentEntity.getPaperByNumber(aid, number, (err,paper) => {
+			if (!!err)
+				return cb(err,null);
+			if (!!paper)
+			{
+				if (!password)
+					return cb({errmsg:"Missing password"});
+				if (paper.password != password)
+					return cb({errmsg:"Wrong password"});
+			}
+			AssessmentEntity.getQuestions(aid, (err,questions) => {
+				if (!!err)
+					return cb(err,null);
+				if (!!paper)
+					return cb(null,{paper:paper,questions:questions});
+				AssessmentEntity.startSession(aid, number, password, (err2,ret) => {
+					const pwd = TokenGen.generate(12); //arbitrary number, 12 seems enough...
+					cb(err2, {
+						questions: questions,
+						password: pwd,
+					});
 				});
 			});
 		});
 	},
 
+	newAnswer: function(aid, number, password, input, cb)
+	{
+		console.log(JSON.stringify(input));
+		// Check that student hasn't already answered
+		AssessmentEntity.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) => {
+				console.log(JSON.stringify(ret2));
+				if (!!err2 || !ret2)
+					return cb(err2,ret2);
+				return cb(null,ret2);
+			});
+		});
+	},
+
 	endSession: function(aid, number, password, cb)
 	{
 		AssessmentEntity.endAssessment(aid, number, password, (err,ret) => {
diff --git a/public/javascripts/assessment.js b/public/javascripts/assessment.js
index c574f1f..5fe24cd 100644
--- a/public/javascripts/assessment.js
+++ b/public/javascripts/assessment.js
@@ -11,7 +11,8 @@ function checkWindowSize()
 	// NOTE: temporarily accept smartphone (security hole: pretend being a smartphone on desktop browser...)
 	if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry)/))
 		return true;
-	return window.innerWidth == screen.width && window.innerHeight == screen.height;
+	// 3 is arbitrary, but a small tolerance is required (e.g. in Firefox)
+	return window.innerWidth >= screen.width-3 && window.innerHeight >= screen.height-3;
 };
 
 new Vue({
@@ -27,15 +28,11 @@ new Vue({
 		//       4: show answers
 		stage: assessment.mode != "open" ? 0 : 1,
 		remainingTime: 0, //global, in seconds
+		warnMsg: "",
 	},
 	components: {
 		"statements": {
 			props: ['assessment','inputs','student','stage'],
-			data: function() {
-				return {
-					index: 0, //current question index in assessment.indices
-				};
-			},
 			// TODO: general render function for nested exercises
 			// TODO: with answer if stage==4 : class "wrong" if ticked AND stage==4 AND received answers
 			// class "right" if stage == 4 AND received answers (background-color: red / green)
@@ -43,7 +40,7 @@ new Vue({
 			// Full questions tree is rendered, but some parts hidden depending on display settings
 			render(h) {
 				let self = this;
-				let questions = assessment.questions.map( (q,i) => {
+				let questions = (assessment.questions || [ ]).map( (q,i) => {
 					let questionContent = [ ];
 					questionContent.push(
 						h(
@@ -69,7 +66,7 @@ new Vue({
 								"input",
 								{
 									domProps: {
-										checked: this.inputs[i][idx],
+										checked: this.inputs.length > 0 && this.inputs[i][idx],
 									},
 									attrs: {
 										id: this.inputId(i,idx),
@@ -124,7 +121,7 @@ new Vue({
 						{
 							"class": {
 								"question": true,
-								"hide": this.stage == 2 && assessment.display == 'one' && assessment.indices[this.index] != i,
+								"hide": this.stage == 2 && assessment.display == 'one' && assessment.indices[assessment.index] != i,
 							},
 						},
 						questionContent
@@ -132,7 +129,6 @@ new Vue({
 				});
 				if (this.stage == 2)
 				{
-					// TODO: one button per question
 					questions.unshift(
 						h(
 							"button",
@@ -148,7 +144,7 @@ new Vue({
 									"margin-right": "auto",
 								},
 								on: {
-									click: () => this.sendAnswer(assessment.indices[this.index]),
+									click: () => this.sendAnswer(assessment.indices[assessment.index]),
 								},
 							},
 							"Send"
@@ -193,14 +189,16 @@ new Vue({
 				},
 				trySendCurrentAnswer: function() {
 					if (this.stage == 2)
-						this.sendAnswer(assessment.indices[this.index]);
+						this.sendAnswer(assessment.indices[assessment.index]);
 				},
 				// stage 2
 				sendAnswer: function(realIndex) {
-					if (this.index == assessment.questions.length - 1)
+					console.log(realIndex);
+					if (assessment.index == assessment.questions.length - 1)
 						this.$emit("gameover");
 					else
-						this.index++;
+						assessment.index++;
+					this.$forceUpdate(); //TODO: shouldn't be required
 					if (assessment.mode == "open")
 						return; //only local
 					let answerData = {
@@ -221,7 +219,7 @@ new Vue({
 						dataType: "json",
 						success: ret => {
 							if (!!ret.errmsg)
-								return alert(ret.errmsg);
+								return this.$emit("warning", ret.errmsg);
 							//socket.emit(message.newAnswer, answer);
 						},
 					});
@@ -236,7 +234,15 @@ new Vue({
 			return this.padWithZero(minutes) + ":" + this.padWithZero(seconds);
 		},
 	},
+	mounted: function() {
+		$(".modal").modal();
+	},
 	methods: {
+		// In case of AJAX errors
+		warning: function(message) {
+			this.warnMsg = message;
+			$("#warning").modal("open");
+		},
 		padWithZero: function(x) {
 			if (x < 10)
 				return "0" + x;
@@ -253,7 +259,7 @@ new Vue({
 				dataType: "json",
 				success: s => {
 					if (!!s.errmsg)
-						return alert(s.errmsg);
+						return this.warning(s.errmsg);
 					this.stage = 1;
 					this.student = s.student;
 					Vue.nextTick( () => { Materialize.updateTextFields(); });
@@ -268,11 +274,11 @@ new Vue({
 		},
 		// stage 1 --> 2 (get all questions, set password)
 		startAssessment: function() {
-			let initializeStage2 = questions => {
+			let initializeStage2 = (questions,paper) => {
 				$("#leftButton, #rightButton").hide();
 				if (assessment.time > 0)
 				{
-					this.remainingTime = assessment.time * 60;
+					this.remainingTime = assessment.time * 60 - (!!paper ? paper.startTime/1000 : 0);
 					this.runTimer();
 				}
 				// Initialize structured answer(s) based on questions type and nesting (TODO: more general)
@@ -280,9 +286,20 @@ new Vue({
 					assessment.questions = questions;
 				for (let q of assessment.questions)
 					this.inputs.push( _(q.options.length).times( _.constant(false) ) );
-				assessment.indices = assessment.fixed
-					? _.range(assessment.questions.length)
-					: _.shuffle( _.range(assessment.questions.length) );
+				if (!paper)
+				{
+					assessment.indices = assessment.fixed
+						? _.range(assessment.questions.length)
+						: _.shuffle( _.range(assessment.questions.length) );
+				}
+				else
+				{
+					// Resuming
+					let indices = paper.inputs.map( input => { return input.index; });
+					let remainingIndices = _.difference(_.range(assessment.questions.length), indices);
+					assessment.indices = indices.concat( _.shuffle(remainingIndices) );
+				}
+				assessment.index = !!paper ? paper.inputs.length : 0;
 				this.stage = 2;
 				Vue.nextTick( () => {
 					// Run Prism + MathJax on questions text
@@ -294,8 +311,6 @@ new Vue({
 			};
 			if (assessment.mode == "open")
 				return initializeStage2();
-			// TODO: if existing password cookie: get stored answers (papers[number cookie]), inject (inputs), set index+indices
-			//       (instead of following ajax call)
 			$.ajax("/start/assessment", {
 				method: "GET",
 				data: {
@@ -305,18 +320,26 @@ new Vue({
 				dataType: "json",
 				success: s => {
 					if (!!s.errmsg)
-						return alert(s.errmsg);
-					this.student.password = s.password;
-					// Got password: students answers locked to this page until potential teacher
-					// action (power failure, computer down, ...)
-					// TODO: set password cookie
+						return this.warning(s.errmsg);
+					if (!!s.paper)
+					{
+						// Resuming: receive stored answers + startTime
+						this.student.password = s.paper.password;
+						this.inputs = s.paper.inputs.map( inp => { return inp.input; });
+					}
+					else
+					{
+						this.student.password = s.password;
+						// Got password: students answers locked to this page until potential teacher
+						// action (power failure, computer down, ...)
+					}
 					// TODO: password also exchanged by sockets to check identity
 					//socket = io.connect("/" + assessment.name, {
 					//	query: "number=" + this.student.number + "&password=" + this.password
 					//});
 					//socket.on(message.allAnswers, this.setAnswers);
 					//socket.on("disconnect", () => { }); //TODO: notify monitor (highlight red), redirect
-					initializeStage2(s.questions);
+					initializeStage2(s.questions, s.paper);
 				},
 			});
 		},
@@ -352,7 +375,7 @@ new Vue({
 				dataType: "json",
 				success: ret => {
 					if (!!ret.errmsg)
-						return alert(ret.errmsg);
+						return this.warning(ret.errmsg);
 					assessment.conclusion = ret.conclusion;
 					this.stage = 3;
 					delete this.student["password"]; //unable to send new answers now
diff --git a/routes/assessments.js b/routes/assessments.js
index 559f08f..49410c4 100644
--- a/routes/assessments.js
+++ b/routes/assessments.js
@@ -47,17 +47,21 @@ router.post("/update/assessment", access.ajax, access.logged, (req,res) => {
 router.get("/start/assessment", access.ajax, (req,res) => {
 	let number = req.query["number"];
 	let aid = req.query["aid"];
-	let error = validator({ _id:aid, papers:[{number:number}] }, "Assessment");
+	let password = req.cookies["password"]; //potentially from cookies, resuming
+	let error = validator({ _id:aid, papers:[{number:number,password:password || "samplePwd"}] }, "Assessment");
 	if (error.length > 0)
 		return res.json({errmsg:error});
-	AssessmentModel.startSession(ObjectId(aid), number, (err,ret) => {
+	AssessmentModel.startSession(ObjectId(aid), number, password, (err,ret) => {
 		access.checkRequest(res,err,ret,"Failed session initialization", () => {
-			// Set password
-			res.cookie("password", ret.password, {
-				httpOnly: true,
-				maxAge: params.cookieExpire,
-			});
-			res.json(ret); //contains questions+password
+			if (!password)
+			{
+				// Set password
+				res.cookie("password", ret.password, {
+					httpOnly: true,
+					maxAge: params.cookieExpire,
+				});
+			}
+			res.json(ret); //contains questions+password(or paper if resuming)
 		});
 	});
 });
@@ -70,7 +74,7 @@ router.get("/send/answer", access.ajax, (req,res) => {
 	let error = validator({ _id:aid, papers:[{number:number,password:password,inputs:[input]}] }, "Assessment");
 	if (error.length > 0)
 		return res.json({errmsg:error});
-	AssessmentEntity.setInput(ObjectId(aid), number, password, input, (err,ret) => {
+	AssessmentModel.newAnswer(ObjectId(aid), number, password, input, (err,ret) => {
 		access.checkRequest(res,err,ret,"Cannot send answer", () => {
 			res.json({});
 		});
diff --git a/views/assessment.pug b/views/assessment.pug
index b4ba8c8..c5395d6 100644
--- a/views/assessment.pug
+++ b/views/assessment.pug
@@ -13,15 +13,10 @@ block content
 	.container#assessment
 		.row
 			#warning.modal
-				.modal-content
-					p Your answer to the current question was sent to the server.
-					p To avoid future unpleasant surprises, please don't
-					ul
-						li resize the window, or
-						li lose window focus.
+				.modal-content {{ warnMsg }}
 				.modal-footer
 					.center-align
-						a.modal-action.modal-close.waves-effect.waves-light.btn-flat(href="#!") Got it!
+						a.modal-action.modal-close.waves-effect.waves-light.btn-flat(href="#!") Ok
 		.row
 			.col.s12.m10.offset-m1.l8.offset-l2.xl6.offset-xl3
 				h4= assessment.name
@@ -52,7 +47,7 @@ block content
 						.card
 							.timer.center(v-if="stage==2") {{ countdown }}
 					.card
-						statements(:assessment="assessment" :student="student" :stage="stage" :inputs="inputs" @gameover="endAssessment")
+						statements(:assessment="assessment" :student="student" :stage="stage" :inputs="inputs" @gameover="endAssessment" @warning="warning")
 				#stage3(v-show="stage==3")
 					.card
 						.finish Exam completed &#9786; ...don't close the window!
-- 
2.44.0