From 9abc6748ae006125ece702758c4424ce4476243a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 30 Apr 2018 08:17:05 +0200 Subject: [PATCH] MOBILE-2345 lesson: Implement lesson and offline providers --- src/addon/mod/lesson/lang/en.json | 85 + src/addon/mod/lesson/lesson.module.ts | 29 + .../mod/lesson/providers/lesson-offline.ts | 598 +++ src/addon/mod/lesson/providers/lesson.ts | 3233 +++++++++++++++++ src/core/grades/providers/grades.ts | 6 + 5 files changed, 3951 insertions(+) create mode 100644 src/addon/mod/lesson/lang/en.json create mode 100644 src/addon/mod/lesson/lesson.module.ts create mode 100644 src/addon/mod/lesson/providers/lesson-offline.ts create mode 100644 src/addon/mod/lesson/providers/lesson.ts diff --git a/src/addon/mod/lesson/lang/en.json b/src/addon/mod/lesson/lang/en.json new file mode 100644 index 000000000..920c695d6 --- /dev/null +++ b/src/addon/mod/lesson/lang/en.json @@ -0,0 +1,85 @@ +{ + "answer": "Answer", + "attempt": "Attempt: {{$a}}", + "attemptheader": "Attempt", + "attemptsremaining": "You have {{$a}} attempt(s) remaining", + "averagescore": "Average score", + "averagetime": "Average time", + "branchtable": "Content", + "cannotfindattempt": "Error: could not find attempt", + "cannotfinduser": "Error: could not find users", + "clusterjump": "Unseen question within a cluster", + "completed": "Completed", + "congratulations": "Congratulations - end of lesson reached", + "continue": "Continue", + "continuetonextpage": "Continue to next page.", + "defaultessayresponse": "Your essay will be graded by your teacher.", + "detailedstats": "Detailed statistics", + "didnotanswerquestion": "Did not answer this question.", + "displayofgrade": "Display of grade (for students only)", + "displayscorewithessays": "

You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.

Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.

Your current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.

", + "displayscorewithoutessays": "Your score is {{$a.score}} (out of {{$a.grade}}).", + "emptypassword": "Password cannot be empty", + "enterpassword": "Please enter the password:", + "eolstudentoutoftimenoanswers": "You did not answer any questions. You have received a 0 for this lesson.", + "errorprefetchrandombranch": "This lesson contains a jump to a random content page. It can't be attempted in the app until it has been started in a web browser.", + "errorreviewretakenotlast": "This attempt can no longer be reviewed because another attempt has been finished.", + "finish": "Finish", + "finishretakeoffline": "This attempt was finished offline.", + "firstwrong": "You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)", + "gotoendoflesson": "Go to the end of the lesson", + "grade": "Grade", + "highscore": "High score", + "hightime": "High time", + "leftduringtimed": "You have left during a timed lesson.
Please click on Continue to restart the lesson.", + "leftduringtimednoretake": "You have left during a timed lesson and you are
not allowed to retake or continue the lesson.", + "lessonmenu": "Lesson menu", + "lessonstats": "Lesson statistics", + "linkedmedia": "Linked media", + "loginfail": "Login failed, please try again...", + "lowscore": "Low score", + "lowtime": "Low time", + "maximumnumberofattemptsreached": "Maximum number of attempts reached - Moving to next page", + "modattemptsnoteacher": "Student review only works for students.", + "noanswer": "One or more questions have no answer given. Please go back and submit an answer.", + "nolessonattempts": "No attempts have been made on this lesson.", + "nolessonattemptsgroup": "No attempts have been made by {{$a}} group members on this lesson.", + "notcompleted": "Not completed", + "numberofcorrectanswers": "Number of correct answers: {{$a}}", + "numberofpagesviewed": "Number of questions answered: {{$a}}", + "numberofpagesviewednotice": "Number of questions answered: {{$a.nquestions}} (You should answer at least {{$a.minquestions}})", + "ongoingcustom": "You have earned {{$a.score}} point(s) out of {{$a.currenthigh}} point(s) thus far.", + "ongoingnormal": "You have answered {{$a.correct}} correctly out of {{$a.viewed}} attempts.", + "or": "OR", + "overview": "Overview", + "preview": "Preview", + "progressbarteacherwarning2": "You will not see the progress bar because you can edit this lesson", + "progresscompleted": "You have completed {{$a}}% of the lesson", + "question": "Question", + "rawgrade": "Raw grade", + "reports": "Reports", + "response": "Response", + "retakefinishedinsync": "An offline attempt was synchronised. Do you want to review it?", + "retakelabelfull": "{{retake}}: {{grade}} {{timestart}} ({{duration}})", + "retakelabelshort": "{{retake}}: {{grade}} {{timestart}}", + "review": "Review", + "reviewlesson": "Review lesson", + "reviewquestionback": "Yes, I'd like to try again", + "reviewquestioncontinue": "No, I just want to go on to the next question", + "secondpluswrong": "Not quite. Would you like to try again?", + "submit": "Submit", + "teacherjumpwarning": "An {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Login as a student to test these jumps.", + "teacherongoingwarning": "Ongoing score is only displayed for student. Login as a student to test ongoing score", + "teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.", + "thatsthecorrectanswer": "That's the correct answer", + "thatsthewronganswer": "That's the wrong answer", + "timeremaining": "Time remaining", + "timetaken": "Time taken", + "unseenpageinbranch": "Unseen question within a content page", + "warningretakefinished": "The attempt was finished on the site.", + "welldone": "Well done!", + "youhaveseen": "You have seen more than one page of this lesson already.
Do you want to start at the last page you saw?", + "youranswer": "Your answer", + "yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}", + "youshouldview": "You should answer at least: {{$a}}" +} \ No newline at end of file diff --git a/src/addon/mod/lesson/lesson.module.ts b/src/addon/mod/lesson/lesson.module.ts new file mode 100644 index 000000000..40da3d1f9 --- /dev/null +++ b/src/addon/mod/lesson/lesson.module.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModLessonProvider } from './providers/lesson'; +import { AddonModLessonOfflineProvider } from './providers/lesson-offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModLessonProvider, + AddonModLessonOfflineProvider + ] +}) +export class AddonModLessonModule { } diff --git a/src/addon/mod/lesson/providers/lesson-offline.ts b/src/addon/mod/lesson/providers/lesson-offline.ts new file mode 100644 index 000000000..d1d80cf14 --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson-offline.ts @@ -0,0 +1,598 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Service to handle offline lesson. + */ +@Injectable() +export class AddonModLessonOfflineProvider { + + protected logger; + + // Variables for database. We use lowercase in the names to match the WS responses. + protected RETAKES_TABLE = 'addon_mod_lesson_retakes'; + protected PAGE_ATTEMPTS_TABLE = 'addon_mod_lesson_page_attempts'; + protected tablesSchema = [ + { + name: this.RETAKES_TABLE, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true // Only 1 offline retake per lesson. + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'finished', + type: 'INTEGER' + }, + { + name: 'outoftime', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'lastquestionpage', + type: 'INTEGER' + }, + ] + }, + { + name: this.PAGE_ATTEMPTS_TABLE, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + notNull: true + }, + { + name: 'retake', // Retake number. + type: 'INTEGER', + notNull: true + }, + { + name: 'pageid', + type: 'INTEGER', + notNull: true + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'data', + type: 'TEXT' + }, + { + name: 'type', + type: 'INTEGER' + }, + { + name: 'newpageid', + type: 'INTEGER' + }, + { + name: 'correct', + type: 'INTEGER' + }, + { + name: 'answerid', + type: 'INTEGER' + }, + { + name: 'useranswer', + type: 'TEXT' + }, + ], + primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'] // A user can attempt several times per page and retake. + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, + private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider) { + this.logger = logger.getInstance('AddonModLessonOfflineProvider'); + + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete an offline attempt. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {number} timemodified The timemodified of the attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, { + lessonid: lessonId, + retake: retake, + pageid: pageId, + timemodified: timemodified + }); + }); + } + + /** + * Delete offline lesson retake. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetake(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.RETAKES_TABLE, {lessonid: lessonId}); + }); + } + + /** + * Delete offline attempts for a retake and page. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId}); + }); + } + + /** + * Mark a retake as finished. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {boolean} finished Whether retake is finished. + * @param {boolean} outOfTime If the user ran out of time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetake(lessonId: number, courseId: number, retake: number, finished?: boolean, outOfTime?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + // Get current stored retake (if any). If not found, it will create a new one. + return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => { + entry.finished = finished ? 1 : 0; + entry.outoftime = outOfTime ? 1 : 0; + entry.timemodified = this.timeUtils.timestamp(); + + return site.getDb().insertRecord(this.RETAKES_TABLE, entry); + }); + }); + } + + /** + * Get all the offline page attempts in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved when the offline attempts are retrieved. + */ + getAllAttempts(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.PAGE_ATTEMPTS_TABLE); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Get all the lessons that have offline data in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved with an object containing the lessons. + */ + getAllLessonsWithData(siteId?: string): Promise { + const promises = [], + lessons = {}; + + // Get the lessons from page attempts. + promises.push(this.getAllAttempts(siteId).then((entries) => { + this.getLessonsFromEntries(lessons, entries); + }).catch(() => { + // Ignore errors. + })); + + // Get the lessons from retakes. + promises.push(this.getAllRetakes(siteId).then((entries) => { + this.getLessonsFromEntries(lessons, entries); + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return this.utils.objectToArray(lessons); + }); + } + + /** + * Get all the offline retakes in a certain site. + * + * @param {string} [siteId] Site ID. If not set, use current site. + * @return {Promise} Promise resolved when the offline retakes are retrieved. + */ + getAllRetakes(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.RETAKES_TABLE); + }); + } + + /** + * Retrieve the last offline attempt stored in a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempt (undefined if no attempts). + */ + getLastQuestionPageAttempt(lessonId: number, retake: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getRetakeWithFallback(lessonId, 0, retake, siteId).then((retakeData) => { + if (!retakeData.lastquestionpage) { + // No question page attempted. + return; + } + + return this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId).then((attempts) => { + // Return the attempt with highest timemodified. + return attempts.reduce((a, b) => { + return a.timemodified > b.timemodified ? a : b; + }); + }); + }).catch(() => { + // Error, return undefined. + }); + } + + /** + * Retrieve all offline attempts for a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempts. + */ + getLessonAttempts(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Given a list of DB entries (either retakes or page attempts), get the list of lessons. + * + * @param {any} lessons Object where to store the lessons. + * @param {any[]} entries List of DB entries. + */ + protected getLessonsFromEntries(lessons: any, entries: any[]): void { + entries.forEach((entry) => { + if (!lessons[entry.lessonid]) { + lessons[entry.lessonid] = { + id: entry.lessonid, + courseId: entry.courseid + }; + } + }); + } + + /** + * Get attempts for question pages and retake in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempts. + */ + getQuestionsAttempts(lessonId: number, retake: number, correct?: boolean, pageId?: number, siteId?: string): Promise { + let promise; + + if (pageId) { + // Page ID is set, only get the attempts for that page. + promise = this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId); + } else { + // Page ID not specified, get all the attempts. + promise = this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId); + } + + return promise.then((attempts) => { + if (correct) { + return attempts.filter((attempt) => { + return !!attempt.correct; + }); + } + + return attempts; + }); + } + + /** + * Retrieve a retake from site DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake. + */ + getRetake(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.RETAKES_TABLE, {lessonid: lessonId}); + }); + } + + /** + * Retrieve all offline attempts for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Retrieve offline attempts for a retake and page. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Lesson retake number. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, pageid: pageId}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Retrieve offline attempts for certain pages for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake attempts. + */ + getRetakeAttemptsForType(lessonId: number, retake: number, type: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.PAGE_ATTEMPTS_TABLE, {lessonid: lessonId, retake: retake, type: type}); + }).then((attempts) => { + return this.parsePageAttempts(attempts); + }); + } + + /** + * Get stored retake. If not found or doesn't match the retake number, return a new one. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake. + */ + protected getRetakeWithFallback(lessonId: number, courseId: number, retake: number, siteId?: string): Promise { + // Get current stored retake. + return this.getRetake(lessonId, siteId).then((retakeData) => { + if (retakeData.retake != retake) { + // The stored retake doesn't match the retake number, create a new one. + return Promise.reject(null); + } + + return retakeData; + }).catch(() => { + // No retake, create a new one. + return { + lessonid: lessonId, + retake: retake, + courseid: courseId, + finished: 0 + }; + }); + } + + /** + * Check if there is a finished retake for a certain lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean. + */ + hasFinishedRetake(lessonId: number, siteId?: string): Promise { + return this.getRetake(lessonId, siteId).then((retake) => { + return !!retake.finished; + }).catch(() => { + return false; + }); + } + + /** + * Check if a lesson has offline data. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean. + */ + hasOfflineData(lessonId: number, siteId?: string): Promise { + const promises = []; + let hasData = false; + + promises.push(this.getRetake(lessonId, siteId).then(() => { + hasData = true; + }).catch(() => { + // Ignore errors. + })); + + promises.push(this.getLessonAttempts(lessonId, siteId).then((attempts) => { + hasData = hasData || !!attempts.length; + }).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return hasData; + }); + } + + /** + * Check if there are offline attempts for a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a boolean. + */ + hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise { + return this.getRetakeAttempts(lessonId, retake, siteId).then((list) => { + return !!list.length; + }).catch(() => { + return false; + }); + } + + /** + * Parse some properties of a page attempt. + * + * @param {any} attempt The attempt to treat. + * @return {any} The treated attempt. + */ + protected parsePageAttempt(attempt: any): any { + attempt.data = this.textUtils.parseJSON(attempt.data); + attempt.useranswer = this.textUtils.parseJSON(attempt.useranswer); + + return attempt; + } + + /** + * Parse some properties of some page attempts. + * + * @param {any[]} attempts The attempts to treat. + * @return {any[]} The treated attempts. + */ + protected parsePageAttempts(attempts: any[]): any[] { + attempts.forEach((attempt) => { + this.parsePageAttempt(attempt); + }); + + return attempts; + } + + /** + * Process a lesson page, saving its data. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {any} page Page. + * @param {any} data Data to save. + * @param {number} newPageId New page ID (calculated). + * @param {number} [answerId] The answer ID that the user answered. + * @param {boolean} [correct] If answer is correct. Only for question pages. + * @param {any} [userAnswer] The user's answer (userresponse from checkAnswer). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + processPage(lessonId: number, courseId: number, retake: number, page: any, data: any, newPageId: number, answerId?: number, + correct?: boolean, userAnswer?: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + lessonid: lessonId, + retake: retake, + pageid: page.id, + timemodified: this.timeUtils.timestamp(), + courseid: courseId, + data: data ? JSON.stringify(data) : null, + type: page.type, + newpageid: newPageId, + correct: correct ? 1 : 0, + answerid: Number(answerId), + useranswer: userAnswer ? JSON.stringify(userAnswer) : null, + }; + + return site.getDb().insertRecord(this.PAGE_ATTEMPTS_TABLE, entry); + }).then(() => { + if (page.type == AddonModLessonProvider.TYPE_QUESTION) { + // It's a question page, set it as last question page attempted. + return this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId); + } + }); + } + + /** + * Set the last question page attempted in a retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} courseId Course ID the lesson belongs to. + * @param {number} retake Retake number. + * @param {number} lastPage ID of the last question page attempted. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + setLastQuestionPageAttempted(lessonId: number, courseId: number, retake: number, lastPage: number, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + // Get current stored retake (if any). If not found, it will create a new one. + return this.getRetakeWithFallback(lessonId, courseId, retake, site.id).then((entry) => { + entry.lastquestionpage = lastPage; + entry.timemodified = this.timeUtils.timestamp(); + + return site.getDb().insertRecord(this.RETAKES_TABLE, entry); + }); + }); + } +} diff --git a/src/addon/mod/lesson/providers/lesson.ts b/src/addon/mod/lesson/providers/lesson.ts new file mode 100644 index 000000000..7cfbd6091 --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson.ts @@ -0,0 +1,3233 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreGradesProvider } from '@core/grades/providers/grades'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { AddonModLessonOfflineProvider } from './lesson-offline'; + +/** + * Result of check answer. + */ +export interface AddonModLessonCheckAnswerResult { + answerid?: number; + noanswer?: boolean; + correctanswer?: boolean; + isessayquestion?: boolean; + response?: string; + newpageid?: number; + studentanswer?: any; + userresponse?: any; + feedback?: string; + nodefaultresponse?: boolean; + inmediatejump?: boolean; + studentanswerformat?: number; + useranswer?: any; +} + +/** + * Result of record attempt. + */ +export interface AddonModLessonRecordAttemptResult extends AddonModLessonCheckAnswerResult { + attemptsremaining?: number; + maxattemptsreached?: boolean; +} + +/** + * Result of lesson grade. + */ +export interface AddonModLessonGrade { + /** + * Number of questions answered. + * @type {number} + */ + nquestions: number; + + /** + * Number of question attempts. + * @type {number} + */ + attempts: number; + + /** + * Max points possible. + * @type {number} + */ + total: number; + + /** + * Points earned by the student. + * @type {number} + */ + earned: number; + + /** + * Calculated percentage grade. + * @type {number} + */ + grade: number; + + /** + * Numer of manually graded questions. + * @type {number} + */ + nmanual: number; + + /** + * Point value for manually graded questions. + * @type {number} + */ + manualpoints: number; +} + +/** + * Service that provides some features for lesson. + * + * Lesson terminology is a bit confusing and ambiguous in Moodle. For that reason, in the app it has been decided to use + * the following terminology: + * - Retake: An attempt in a lesson. In Moodle it's sometimes called "attempt", "try" or "retry". + * - Attempt: An attempt in a page inside a retake. In the app, this includes content pages. + * - Content page: A page with only content (no question). In Moodle it's sometimes called "branch table". + * - Page answers: List of possible answers for a page (configured by the teacher). NOT the student answer for the page. + * + * This terminology sometimes won't match with WebServices names, params or responses. + */ +@Injectable() +export class AddonModLessonProvider { + static COMPONENT = 'mmaModLesson'; + + // This page. + static LESSON_THISPAGE = 0; + // Next page -> any page not seen before. + static LESSON_UNSEENPAGE = 1; + // Next page -> any page not answered correctly. + static LESSON_UNANSWEREDPAGE = 2; + // Jump to Next Page. + static LESSON_NEXTPAGE = -1; + // End of Lesson. + static LESSON_EOL = -9; + // Jump to an unseen page within a branch and end of branch or end of lesson. + static LESSON_UNSEENBRANCHPAGE = -50; + // Jump to a random page within a branch and end of branch or end of lesson. + static LESSON_RANDOMPAGE = -60; + // Jump to a random Branch. + static LESSON_RANDOMBRANCH = -70; + // Cluster Jump. + static LESSON_CLUSTERJUMP = -80; + + // Type of page: question or structure (content). + static TYPE_QUESTION = 0; + static TYPE_STRUCTURE = 1; + + // Type of question pages. + static LESSON_PAGE_SHORTANSWER = 1; + static LESSON_PAGE_TRUEFALSE = 2; + static LESSON_PAGE_MULTICHOICE = 3; + static LESSON_PAGE_MATCHING = 5; + static LESSON_PAGE_NUMERICAL = 8; + static LESSON_PAGE_ESSAY = 10; + static LESSON_PAGE_BRANCHTABLE = 20; // Content page. + static LESSON_PAGE_ENDOFBRANCH = 21; + static LESSON_PAGE_CLUSTER = 30; + static LESSON_PAGE_ENDOFCLUSTER = 31; + + // Variables for database. + protected PASSWORD_TABLE = 'addon_mod_lesson_password'; + protected tablesSchema = { + name: this.PASSWORD_TABLE, + columns: [ + { + name: 'lessonId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'password', + type: 'TEXT' + }, + { + name: 'timemodified', + type: 'INTEGER' + } + ] + }; + + protected ROOT_CACHE_KEY = 'mmaModLesson:'; + protected logger; + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private translate: TranslateService, private textUtils: CoreTextUtilsProvider, + private lessonOfflineProvider: AddonModLessonOfflineProvider) { + this.logger = logger.getInstance('AddonModLessonProvider'); + + this.sitesProvider.createTableFromSchema(this.tablesSchema); + } + + /** + * Add a message to a list of messages, following the format of the messages returned by WS. + * + * @param {any[]} messages List of messages where to add the message. + * @param {string} stringName The ID of the message to be translated. E.g. 'addon.mod_lesson.numberofpagesviewednotice'. + * @param {any} [stringParams] The params of the message (if any). + */ + protected addMessage(messages: any[], stringName: string, stringParams?: any): void { + messages.push({ + message: this.translate.instant(stringName, stringParams) + }); + } + + /** + * Add a property to the result of the "process EOL page" simulation in offline. + * + * @param {any} result Result where to add the value. + * @param {string} name Name of the property. + * @param {any} value Value to add. + * @param {boolean} addMessage Whether to add a message related to the value. + */ + protected addResultValueEolPage(result: any, name: string, value: any, addMessage?: boolean): void { + let message = ''; + + if (addMessage) { + const params = typeof value != 'boolean' ? {$a: value} : undefined; + message = this.translate.instant('addon.mod_lesson.' + name, params); + } + + result.data[name] = { + name: name, + value: value, + message: message + }; + } + + /** + * Check if an answer page (from getUserRetake) is a content page. + * + * @param {any} page Answer page. + * @return {boolean} Whether it's a content page. + */ + answerPageIsContent(page: any): boolean { + // The page doesn't have any reliable field to use for checking this. Check qtype first (translated string). + if (page.qtype == this.translate.instant('addon.mod_lesson.branchtable')) { + return true; + } + + // The qtype doesn't match, but that doesn't mean it's not a content page, maybe the language is different. + // Check it's not a question page. + if (page.answerdata && !this.answerPageIsQuestion(page)) { + // It isn't a question page, but it can be an end of branch, etc. Check if the first answer has a button. + if (page.answerdata.answers && page.answerdata.answers[0]) { + this.div.innerHTML = page.answerdata.answers[0][0]; + + return !!this.div.querySelector('input[type="button"]'); + } + } + + return false; + } + + /** + * Check if an answer page (from getUserRetake) is a question page. + * + * @param {any} page Answer page. + * @return {boolean} Whether it's a question page. + */ + answerPageIsQuestion(page: any): boolean { + if (!page.answerdata) { + return false; + } + + if (page.answerdata.score) { + // Only question pages have a score. + return true; + } + + if (page.answerdata.answers) { + for (let i = 0; i < page.answerdata.answers.length; i++) { + const answer = page.answerdata.answers[i]; + if (answer[1]) { + // Only question pages have a statistic. + return true; + } + } + } + + return false; + } + + /** + * Calculate some offline data like progress and ongoingscore. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not defined, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{reviewMode: boolean, progress: number, ongoingScore: string}>} Promise resolved with the data. + */ + protected calculateOfflineData(lesson: any, accessInfo?: any, password?: string, review?: boolean, pageIndex?: any, + siteId?: string): Promise<{reviewMode: boolean, progress: number, ongoingScore: string}> { + + accessInfo = accessInfo || {}; + + const reviewMode = review || accessInfo.reviewmode, + promises = []; + let ongoingMessage = '', + progress: number; + + if (!accessInfo.canmanage) { + if (lesson.ongoing && !reviewMode) { + promises.push(this.getOngoingScoreMessage(lesson, accessInfo, password, review, pageIndex, siteId) + .then((message) => { + ongoingMessage = message; + })); + } + if (lesson.progressbar) { + promises.push(this.calculateProgress(lesson.id, accessInfo, password, review, pageIndex, siteId).then((p) => { + progress = p; + })); + } + } + + return Promise.all(promises).then(() => { + return { + reviewMode: reviewMode, + progress: progress, + ongoingScore: ongoingMessage + }; + }); + } + + /** + * Calculate the progress of the current user in the lesson. + * Based on Moodle's calculate_progress. + * + * @param {number} lessonId Lesson ID. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not defined, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a number: the progress (scale 0-100). + */ + calculateProgress(lessonId: number, accessInfo: any, password?: string, review?: boolean, pageIndex?: any, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Check if the user is reviewing the attempt. + if (review) { + return Promise.resolve(100); + } + + const retake = accessInfo.attemptscount; + let viewedPagesIds, + promise; + + if (pageIndex) { + promise = Promise.resolve(); + } else { + // Retrieve the index. + promise = this.getPages(lessonId, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + }); + } + + return promise.then(() => { + // Get the list of question pages attempted. + return this.getPagesIdsWithQuestionAttempts(lessonId, retake, false, siteId); + }).then((ids) => { + viewedPagesIds = ids; + + // Get the list of viewed content pages. + return this.getContentPagesViewedIds(lessonId, retake, siteId); + }).then((viewedContentPagesIds) => { + const validPages = {}; + let pageId = accessInfo.firstpageid; + + viewedPagesIds = this.utils.mergeArraysWithoutDuplicates(viewedPagesIds, viewedContentPagesIds); + + // Filter out the following pages: + // - End of Cluster + // - End of Branch + // - Pages found inside of Clusters + // Do not filter out Cluster Page(s) because we count a cluster as one. + // By keeping the cluster page, we get our 1. + while (pageId) { + pageId = this.validPageAndView(pageIndex, pageIndex[pageId], validPages, viewedPagesIds); + } + + // Progress calculation as a percent. + return this.textUtils.roundToDecimals(viewedPagesIds.length / Object.keys(validPages).length, 2) * 100; + }); + } + + /** + * Check if the answer provided by the user is correct or not and return the result object. + * This method is based on the check_answer implementation of all page types (Moodle). + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} jumps Result of get pages possible jumps. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @return {AddonModLessonCheckAnswerResult} Result. + */ + protected checkAnswer(lesson: any, pageData: any, data: any, jumps: any, pageIndex: any): AddonModLessonCheckAnswerResult { + // Default result. + const result: AddonModLessonCheckAnswerResult = { + answerid: 0, + noanswer: false, + correctanswer: false, + isessayquestion: false, + response: '', + newpageid: 0, + studentanswer: '', + userresponse: null, + feedback: '', + nodefaultresponse: false, + inmediatejump: false + }; + + switch (pageData.page.qtype) { + case AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE: + // Load the new page immediately. + result.inmediatejump = true; + result.newpageid = this.getNewPageId(pageData.page.id, data.jumpto, jumps); + break; + + case AddonModLessonProvider.LESSON_PAGE_ESSAY: + this.checkAnswerEssay(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MATCHING: + this.checkAnswerMatching(pageData, data, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: + this.checkAnswerMultichoice(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: + this.checkAnswerNumerical(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: + this.checkAnswerShort(lesson, pageData, data, pageIndex, result); + break; + + case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: + this.checkAnswerTruefalse(lesson, pageData, data, pageIndex, result); + break; + default: + // Nothing to do. + } + + return result; + } + + /** + * Check an essay answer. + * + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerEssay(pageData: any, data: any, result: AddonModLessonCheckAnswerResult): void { + let studentAnswer; + + result.isessayquestion = true; + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + if (typeof data.answer == 'object') { + studentAnswer = data.answer.text; + } else { + studentAnswer = data.answer; + } + + if (!studentAnswer || studentAnswer.trim() === '') { + result.noanswer = true; + + return; + } + + // Essay pages should only have 1 possible answer. + pageData.answers.forEach((answer) => { + result.answerid = answer.id; + result.newpageid = answer.jumpto; + }); + + result.userresponse = { + sent: 0, + graded: 0, + score: 0, + answer: studentAnswer, + answerformat: 1, + response: '', + responseformat: 1 + }; + result.studentanswerformat = 1; + result.studentanswer = studentAnswer; + } + + /** + * Check a matching answer. + * + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerMatching(pageData: any, data: any, result: AddonModLessonCheckAnswerResult): void { + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + const response = this.getUserResponseMatching(data), + getAnswers = this.utils.clone(pageData.answers), + correct = getAnswers.shift(), + wrong = getAnswers.shift(), + answers = {}; + + getAnswers.forEach((answer) => { + if (answer.answer !== '' || answer.response !== '') { + answers[answer.id] = answer; + } + }); + + // Get the user's exact responses for record keeping. + const userResponse = []; + let hits = 0; + + result.studentanswer = ''; + result.studentanswerformat = 1; + + for (const id in response) { + let value = response[id]; + + if (!value) { + result.noanswer = true; + + return; + } + + value = this.textUtils.decodeHTML(value); + userResponse.push(value); + + if (typeof answers[id] != 'undefined') { + const answer = answers[id]; + + result.studentanswer += '
' + answer.answer + ' = ' + value; + if (answer.response && answer.response.trim() == value.trim()) { + hits++; + } + } + } + + result.userresponse = userResponse.join(','); + + if (hits == Object.keys(answers).length) { + result.correctanswer = true; + result.response = correct.answer; + result.answerid = correct.id; + result.newpageid = correct.jumpto; + } else { + result.correctanswer = false; + result.response = wrong.answer; + result.answerid = wrong.id; + result.newpageid = wrong.jumpto; + } + } + + /** + * Check a multichoice answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkAnswerMultichoice(lesson: any, pageData: any, data: any, pageIndex: any, + result: AddonModLessonCheckAnswerResult): void { + + if (!data) { + result.inmediatejump = true; + result.newpageid = pageData.page.id; + + return; + } + + const answers = this.getUsedAnswersMultichoice(pageData); + + if (pageData.page.qoption) { + // Multianswer allowed, user's answer is an array. + const studentAnswers = this.getUserResponseMultichoice(data); + + if (!studentAnswers || !Array.isArray(studentAnswers)) { + result.noanswer = true; + + return; + } + + // Get what the user answered. + result.userresponse = studentAnswers.join(','); + + // Get the answers in a set order, the id order. + const responses = []; + let nHits = 0, + nCorrect = 0, + correctAnswerId = 0, + wrongAnswerId = 0, + correctPageId, + wrongPageId; + + // Store student's answers for displaying on feedback page. + result.studentanswer = ''; + result.studentanswerformat = 1; + answers.forEach((answer) => { + for (const i in studentAnswers) { + const answerId = studentAnswers[i]; + + if (answerId == answer.id) { + result.studentanswer += '
' + answer.answer; + if (this.textUtils.cleanTags(answer.response).trim()) { + responses.push(answer.response); + } + break; + } + } + }); + + // Iterate over all the possible answers. + answers.forEach((answer) => { + const correctAnswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + + // Iterate over all the student answers to check if he selected the current possible answer. + studentAnswers.forEach((answerId) => { + if (answerId == answer.id) { + if (correctAnswer) { + nHits++; + } else { + // Always use the first student wrong answer. + if (typeof wrongPageId == 'undefined') { + wrongPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!wrongAnswerId) { + wrongAnswerId = answer.id; + } + } + } + }); + + if (correctAnswer) { + nCorrect++; + + // Save the first jumpto. + if (typeof correctPageId == 'undefined') { + correctPageId = answer.jumpto; + } + // Save the answer id for scoring. + if (!correctAnswerId) { + correctAnswerId = answer.id; + } + } + }); + + if (studentAnswers.length == nCorrect && nHits == nCorrect) { + result.correctanswer = true; + result.response = responses.join('
'); + result.newpageid = correctPageId; + result.answerid = correctAnswerId; + } else { + result.correctanswer = false; + result.response = responses.join('
'); + result.newpageid = wrongPageId; + result.answerid = wrongAnswerId; + } + } else { + // Only one answer allowed. + if (typeof data.answerid == 'undefined' || (!data.answerid && Number(data.answerid) !== 0)) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Search the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.newpageid = answer.jumpto; + result.response = answer.response; + result.userresponse = result.studentanswer = answer.answer; + break; + } + } + } + } + + /** + * Check a numerical answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerNumerical(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + const parsedAnswer = parseFloat(data.answer); + + // Set defaults. + result.response = ''; + result.newpageid = 0; + + if (!data.answer || isNaN(parsedAnswer)) { + result.noanswer = true; + + return; + } else { + result.useranswer = parsedAnswer; + } + + result.studentanswer = result.userresponse = result.useranswer; + + // Find the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + let max, min; + + if (answer.answer && answer.answer.indexOf(':') != -1) { + // There's a pair of values. + const split = answer.answer.split(':'); + min = parseFloat(split[0]); + max = parseFloat(split[1]); + } else { + // Only one value. + min = parseFloat(answer.answer); + max = min; + } + + if (result.useranswer >= min && result.useranswer <= max) { + result.newpageid = answer.jumpto; + result.response = answer.response; + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.answerid = answer.id; + break; + } + } + } + + /** + * Check a short answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerShort(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + let studentAnswer = data.answer && data.answer.trim ? data.answer.trim() : false; + if (!studentAnswer) { + result.noanswer = true; + + return; + } + + // Search the answer in the list of possible answers. + for (const i in pageData.answers) { + const answer = pageData.answers[i], + useRegExp = pageData.page.qoption; + let expectedAnswer = answer.answer, + isMatch = false, + markIt = false, + ignoreCase; + + if (useRegExp) { + ignoreCase = ''; + if (expectedAnswer.substr(-2) == '/i') { + expectedAnswer = expectedAnswer.substr(0, expectedAnswer.length - 2); + ignoreCase = 'i'; + } + } else { + expectedAnswer = expectedAnswer.replace('*', '#####'); + expectedAnswer = this.textUtils.escapeForRegex(expectedAnswer); + expectedAnswer = expectedAnswer.replace('#####', '.*'); + } + + // See if user typed in any of the correct answers. + if (this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex)) { + if (!useRegExp) { // We are using 'normal analysis', which ignores case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + } + if (isMatch) { + result.correctanswer = true; + } + } else { + if (!useRegExp) { // We are using 'normal analysis'. + // See if user typed in any of the wrong answers; don't worry about case. + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) { + isMatch = true; + } + } else { // We are using regular expressions analysis. + const startCode = expectedAnswer.substr(0, 2); + + switch (startCode){ + // 1- Check for absence of required string in studentAnswer (coded by initial '--'). + case '--': + expectedAnswer = expectedAnswer.substr(2); + if (!studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + + // 2- Check for code for marking wrong strings (coded by initial '++'). + case '++': + expectedAnswer = expectedAnswer.substr(2); + markIt = true; + + // Check for one or several matches. + const matches = studentAnswer.match(new RegExp(expectedAnswer, 'g' + ignoreCase)); + if (matches) { + isMatch = true; + const nb = matches[0].length, + original = [], + marked = []; + + for (let j = 0; j < nb; j++) { + original.push(matches[0][j]); + marked.push('' + matches[0][j] + ''); + } + + studentAnswer = studentAnswer.replace(original, marked); + } + break; + + // 3- Check for wrong answers belonging neither to -- nor to ++ categories. + default: + if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) { + isMatch = true; + } + break; + } + + result.correctanswer = false; + } + } + + if (isMatch) { + result.newpageid = answer.jumpto; + result.response = answer.response; + result.answerid = answer.id; + break; // Quit answer analysis immediately after a match has been found. + } + } + + result.userresponse = studentAnswer; + result.studentanswer = this.textUtils.s(studentAnswer); // Clean student answer as it goes to output. + } + + /** + * Check a truefalse answer. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data containing the user answer. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {any} result Object where to store the result. + */ + protected checkAnswerTruefalse(lesson: any, pageData: any, data: any, pageIndex: any, result: AddonModLessonCheckAnswerResult) + : void { + + if (!data.answerid) { + result.noanswer = true; + + return; + } + + result.answerid = data.answerid; + + // Get the answer. + for (const i in pageData.answers) { + const answer = pageData.answers[i]; + if (answer.id == data.answerid) { + // Answer found. + result.correctanswer = this.isAnswerCorrect(lesson, pageData.page.id, answer, pageIndex); + result.newpageid = answer.jumpto; + result.response = answer.response; + result.studentanswer = result.userresponse = answer.answer; + break; + } + } + } + + /** + * Create a list of pages indexed by page ID based on a list of pages. + * + * @param {Object[]} pageList Result of get pages. + * @return {any} Pages index. + */ + protected createPagesIndex(pageList: any[]): any { + // Index the pages by page ID. + const pages = {}; + + pageList.forEach((pageData) => { + pages[pageData.page.id] = pageData.page; + }); + + return pages; + } + + /** + * Finishes a retake. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [outOfTime] If the user ran out of time. + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [offline] Whether it's offline mode. + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetake(lesson: any, courseId: number, password?: string, outOfTime?: boolean, review?: boolean, offline?: boolean, + accessInfo?: any, siteId?: string): Promise { + + if (offline) { + const retake = accessInfo.attemptscount; + + return this.lessonOfflineProvider.finishRetake(lesson.id, courseId, retake, true, outOfTime, siteId).then(() => { + // Get the lesson grade. + return this.lessonGrade(lesson, retake, password, review, undefined, siteId).catch(() => { + // Ignore errors. + return {}; + }); + }).then((gradeInfo: AddonModLessonGrade) => { + // Retake marked, now return the response. We won't return all the possible data. + // This code is based in Moodle's process_eol_page. + const result = { + data: {}, + messages: [], + warnings: [] + }, + promises = []; + let gradeLesson = true, + messageParams, + entryData; + + this.addResultValueEolPage(result, 'offline', true); // Mark the result as offline. + this.addResultValueEolPage(result, 'gradeinfo', gradeInfo); + + if (lesson.custom && !accessInfo.canmanage) { + /* Before we calculate the custom score make sure they answered the minimum number of questions. + We only need to do this for custom scoring as we can not get the miniumum score the user should achieve. + If we are not using custom scoring (so all questions are valued as 1) then we simply check if they + answered more than the minimum questions, if not, we mark it out of the number specified in the minimum + questions setting - which is done in lesson_grade(). */ + + // Get the number of answers given. + if (gradeInfo.nquestions < lesson.minquestions) { + gradeLesson = false; + messageParams = { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions + }; + this.addMessage(result.messages, 'addon.mod_lesson.numberofpagesviewednotice', {$a: messageParams}); + } + } + + if (!accessInfo.canmanage) { + if (gradeLesson) { + promises.push(this.calculateProgress(lesson.id, accessInfo, password, review, undefined, siteId) + .then((progress) => { + this.addResultValueEolPage(result, 'progresscompleted', progress); + })); + + if (gradeInfo.attempts) { + // User has answered questions. + if (!lesson.custom) { + this.addResultValueEolPage(result, 'numberofpagesviewed', gradeInfo.nquestions, true); + if (lesson.minquestions) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addResultValueEolPage(result, 'youshouldview', lesson.minquestions, true); + } + } + this.addResultValueEolPage(result, 'numberofcorrectanswers', gradeInfo.earned, true); + } + + entryData = { + score: gradeInfo.earned, + grade: gradeInfo.total + }; + if (gradeInfo.nmanual) { + entryData.tempmaxgrade = gradeInfo.total - gradeInfo.manualpoints; + entryData.essayquestions = gradeInfo.nmanual; + this.addResultValueEolPage(result, 'displayscorewithessays', entryData, true); + } else { + this.addResultValueEolPage(result, 'displayscorewithoutessays', entryData, true); + } + + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + entryData = { + grade: this.textUtils.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1), + total: lesson.grade + }; + this.addResultValueEolPage(result, 'yourcurrentgradeisoutof', entryData, true); + } + + } else { + // User hasn't answered any question, only content pages. + if (lesson.timelimit) { + if (outOfTime) { + this.addResultValueEolPage(result, 'eolstudentoutoftimenoanswers', true, true); + } + } else { + this.addResultValueEolPage(result, 'welldone', true, true); + } + } + } + } else { + // Display for teacher. + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addResultValueEolPage(result, 'displayofgrade', true, true); + } + } + + if (lesson.modattempts && accessInfo.canmanage) { + this.addResultValueEolPage(result, 'modattemptsnoteacher', true, true); + } + + if (gradeLesson) { + this.addResultValueEolPage(result, 'gradelesson', 1); + } + + return result; + }); + } + + return this.finishRetakeOnline(lesson.id, password, outOfTime, review, siteId); + } + + /** + * Finishes a retake. It will fail if offline or cannot connect. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [outOfTime] If the user ran out of time. + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + finishRetakeOnline(lessonId: number, password?: string, outOfTime?: boolean, review?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + outoftime: outOfTime ? 1 : 0, + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_finish_attempt', params).then((response) => { + // Convert the data array into an object and decode the values. + const map = {}; + + response.data.forEach((entry) => { + if (entry.value && typeof entry.value == 'string' && entry.value !== '1') { + // It's a JSON encoded object. Try to decode it. + entry.value = this.textUtils.parseJSON(entry.value); + } + + map[entry.name] = entry; + }); + response.data = map; + + return response; + }); + }); + } + + /** + * Get the access information of a certain lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the access information. + */ + getAccessInformation(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(lessonId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_lesson_access_information', params, preSets); + }); + } + + /** + * Get cache key for access information WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getAccessInformationCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'accessInfo:' + lessonId; + } + + /** + * Get content pages viewed in online and offline. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{online: any[], offline: any[]}>} Promise resolved with an object with the online and offline viewed pages. + */ + getContentPagesViewed(lessonId: number, retake: number, siteId?: string): Promise<{online: any[], offline: any[]}> { + const promises = [], + type = AddonModLessonProvider.TYPE_STRUCTURE, + result = { + online: [], + offline: [] + }; + + // Get the online pages. + promises.push(this.getContentPagesViewedOnline(lessonId, retake, false, false, siteId).then((pages) => { + result.online = pages; + })); + + // Get the offline pages. + promises.push(this.lessonOfflineProvider.getRetakeAttemptsForType(lessonId, retake, type, siteId).catch(() => { + return []; + }).then((pages) => { + result.offline = pages; + })); + + return Promise.all(promises).then(() => { + return result; + }); + } + + /** + * Get cache key for get content pages viewed WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @return {string} Cache key. + */ + protected getContentPagesViewedCacheKey(lessonId: number, retake: number): string { + return this.getContentPagesViewedCommonCacheKey(lessonId) + ':' + retake; + } + + /** + * Get common cache key for get content pages viewed WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getContentPagesViewedCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'contentPagesViewed:' + lessonId; + } + + /** + * Get IDS of content pages viewed in online and offline. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with list of IDs. + */ + getContentPagesViewedIds(lessonId: number, retake: number, siteId?: string): Promise { + return this.getContentPagesViewed(lessonId, retake, siteId).then((result) => { + const ids = {}, + pages = result.online.concat(result.offline); + + pages.forEach((page) => { + if (!ids[page.pageid]) { + ids[page.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => { + return Number(id); + }); + }); + } + + /** + * Get the list of content pages viewed in the site for a certain retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the viewed pages. + */ + getContentPagesViewedOnline(lessonId: number, retake: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + lessonattempt: retake + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getContentPagesViewedCacheKey(lessonId, retake) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_content_pages_viewed', params, preSets).then((result) => { + return result.pages; + }); + }); + } + + /** + * Get the last content page viewed. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the last content page viewed. + */ + getLastContentPageViewed(lessonId: number, retake: number, siteId?: string): Promise { + return this.getContentPagesViewed(lessonId, retake, siteId).then((data) => { + let lastPage, + maxTime = 0; + + data.online.forEach((page) => { + if (page.timeseen > maxTime) { + lastPage = page; + maxTime = page.timeseen; + } + }); + + data.offline.forEach((page) => { + if (page.timemodified > maxTime) { + lastPage = page; + maxTime = page.timemodified; + } + }); + + return lastPage; + }).catch(() => { + // Error getting last page, don't return anything. + }); + } + + /** + * Get the last page seen. + * Based on Moodle's get_last_page_seen. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the last page seen. + */ + getLastPageSeen(lessonId: number, retake: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let lastPageSeen: number; + + // Get the last question answered. + return this.lessonOfflineProvider.getLastQuestionPageAttempt(lessonId, retake, siteId).then((answer) => { + if (answer) { + lastPageSeen = answer.newpageid; + } + + // Now get the last content page viewed. + return this.getLastContentPageViewed(lessonId, retake, siteId).then((page) => { + if (page) { + if (answer) { + if (page.timemodified > answer.timemodified) { + // This content page was viewed more recently than the question page. + lastPageSeen = page.newpageid || page.pageid; + } + } else { + // Has not answered any questions but has viewed a content page. + lastPageSeen = page.newpageid || page.pageid; + } + } + + return lastPageSeen; + }); + }); + } + + /** + * Get a Lesson by module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmid Course module ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + getLesson(courseId: number, cmId: number, forceCache?: boolean, siteId?: string): Promise { + return this.getLessonByField(courseId, 'coursemodule', cmId, forceCache, siteId); + } + + /** + * Get a Lesson with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + protected getLessonByField(courseId: number, key: string, value: any, forceCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonDataCacheKey(courseId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } + + return site.read('mod_lesson_get_lessons_by_courses', params, preSets).then((response) => { + if (response && response.lessons) { + const currentLesson = response.lessons.find((lesson) => { + return lesson[key] == value; + }); + + if (currentLesson) { + return currentLesson; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a Lesson by lesson ID. + * + * @param {number} courseId Course ID. + * @param {number} id Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is retrieved. + */ + getLessonById(courseId: number, id: number, forceCache?: boolean, siteId?: string): Promise { + return this.getLessonByField(courseId, 'id', id, forceCache, siteId); + } + + /** + * Get cache key for Lesson data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getLessonDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'lesson:' + courseId; + } + + /** + * Get a lesson protected with password. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Password. + * @param {boolean} [validatePassword=true] If true, the function will fail if the password is wrong. + * If false, it will return a lesson with the basic data if password is wrong. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the lesson. + */ + getLessonWithPassword(lessonId: number, password?: string, validatePassword: boolean = true, forceCache?: boolean, + ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getLessonWithPasswordCacheKey(lessonId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_lesson', params, preSets).then((response) => { + if (typeof response.lesson.ongoing == 'undefined') { + // Basic data not received, password is wrong. Remove stored password. + this.removeStoredPassword(lessonId, site.id); + + if (validatePassword) { + // Invalidate the data and reject. + return this.invalidateLessonWithPassword(lessonId, site.id).catch(() => { + // Shouldn't happen. + }).then(() => { + return Promise.reject(this.translate.instant('addon.mod_lesson.loginfail')); + }); + } + } + + return response.lesson; + }); + }); + } + + /** + * Get cache key for get lesson with password WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getLessonWithPasswordCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'lessonWithPswrd:' + lessonId; + } + + /** + * Given a page ID, a jumpto and all the possible jumps, calcualate the new page ID. + * + * @param {number} pageId Current page ID. + * @param {number} jumpTo The jumpto. + * @param {any} jumps Result of get pages possible jumps. + * @return {number} New page ID. + */ + protected getNewPageId(pageId: number, jumpTo: number, jumps: any): number { + // If jump not found, return current jumpTo. + if (jumps && jumps[pageId] && jumps[pageId][jumpTo]) { + return jumps[pageId][jumpTo].calculatedjump; + } else if (!jumpTo) { + // Return current page. + return pageId; + } + + return jumpTo; + } + + /** + * Get the ongoing score message for the user (depending on the user permission and lesson settings). + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not provided, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the ongoing score message. + */ + getOngoingScoreMessage(lesson: any, accessInfo: any, password?: string, review?: boolean, pageIndex?: any, siteId?: string) + : Promise { + + if (accessInfo.canmanage) { + return Promise.resolve(this.translate.instant('addon.mod_lesson.teacherongoingwarning')); + } else { + let retake = accessInfo.attemptscount; + if (review) { + retake--; + } + + return this.lessonGrade(lesson, retake, password, review, pageIndex, siteId).then((gradeInfo) => { + const data: any = {}; + + if (lesson.custom) { + data.score = gradeInfo.earned; + data.currenthigh = gradeInfo.total; + + return this.translate.instant('addon.mod_lesson.ongoingcustom', {$a: data}); + } else { + data.correct = gradeInfo.earned; + data.viewed = gradeInfo.attempts; + + return this.translate.instant('addon.mod_lesson.ongoingnormal', {$a: data}); + } + }); + } + } + + /** + * Get the possible answers from a page. + * + * @param {any} lesson Lesson. + * @param {number} pageId Page ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of possible answers. + */ + protected getPageAnswers(lesson: any, pageId: number, password?: string, review?: boolean, siteId?: string): Promise { + return this.getPageData(lesson, pageId, password, review, true, true, false, undefined, undefined, siteId).then((data) => { + return data.answers; + }); + } + + /** + * Get all the possible answers from a list of pages, indexed by answerId. + * + * @param {any} lesson Lesson. + * @param {number[]} pageIds List of page IDs. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with an object containing the answers. + */ + protected getPagesAnswers(lesson: any, pageIds: number[], password?: string, review?: boolean, siteId?: string) + : Promise { + + const answers = {}, + promises = []; + + pageIds.forEach((pageId) => { + promises.push(this.getPageAnswers(lesson, pageId, password, review, siteId).then((pageAnswers) => { + pageAnswers.forEach((answer) => { + // Include the pageid in each answer and add them to the final list. + answer.pageid = pageId; + answers[answer.id] = answer; + }); + })); + }); + + return Promise.all(promises).then(() => { + return answers; + }); + } + + /** + * Get page data. + * + * @param {any} lesson Lesson. + * @param {number} pageId Page ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [includeContents] Include the page rendered contents. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {any} [jumps] Result of get pages possible jumps. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the page data. + */ + getPageData(lesson: any, pageId: number, password?: string, review?: boolean, includeContents?: boolean, forceCache?: boolean, + ignoreCache?: boolean, accessInfo?: any, jumps?: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lesson.id, + pageid: Number(pageId), + review: review ? 1 : 0, + returncontents: includeContents ? 1 : 0 + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPageDataCacheKey(lesson.id, pageId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + if (review) { + // Force online mode in review. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_page_data', params, preSets).then((data) => { + if (forceCache && accessInfo && data.page) { + // Offline mode and valid page. Calculate the data that might be affected. + return this.calculateOfflineData(lesson, accessInfo, password, review, undefined, siteId).then((calcData) => { + Object.assign(data, calcData); + + return this.getPageViewMessages(lesson, accessInfo, data.page, review, jumps, password, siteId); + }).then((messages) => { + data.messages = messages; + + return data; + }); + } + + return data; + }); + }); + } + + /** + * Get cache key for get page data WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @return {string} Cache key. + */ + protected getPageDataCacheKey(lessonId: number, pageId: number): string { + return this.getPageDataCommonCacheKey(lessonId) + ':' + pageId; + } + + /** + * Get common cache key for get page data WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPageDataCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pageData:' + lessonId; + } + + /** + * Get lesson pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the pages. + */ + getPages(lessonId: number, password?: string, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesCacheKey(lessonId) + }; + + if (typeof password == 'string') { + params.password = password; + } + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_pages', params, preSets).then((response) => { + return response.pages; + }); + }); + } + + /** + * Get cache key for get pages WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPagesCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pages:' + lessonId; + } + + /** + * Get possible jumps for a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the jumps. + */ + getPagesPossibleJumps(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPagesPossibleJumpsCacheKey(lessonId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_pages_possible_jumps', params, preSets).then((response) => { + // Index the jumps by page and jumpto. + if (response.jumps) { + const jumps = {}; + + response.jumps.forEach((jump) => { + if (typeof jumps[jump.pageid] == 'undefined') { + jumps[jump.pageid] = {}; + } + jumps[jump.pageid][jump.jumpto] = jump; + }); + + return jumps; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get pages possible jumps WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getPagesPossibleJumpsCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'pagesJumps:' + lessonId; + } + + /** + * Get different informative messages when processing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_process. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. + * @param {any} result Result of process page. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} jumps Result of get pages possible jumps. + * @return {any[]} Array with the messages. + */ + getPageProcessMessages(lesson: any, accessInfo: any, result: any, review: boolean, jumps: any): any[] { + const messages = []; + + if (accessInfo.canmanage) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + if (this.lessonDisplayTeacherWarning(jumps)) { + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {$a: { + cluster: this.translate.instant('addon.mod_lesson.clusterjump'), + unseen: this.translate.instant('addon.mod_lesson.unseenpageinbranch') + }}); + } + + // Inform teacher that s/he will not see the timer. + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + } + // Report attempts remaining. + if (result.attemptsremaining > 0 && lesson.review && !review) { + this.addMessage(messages, 'addon.mod_lesson.attemptsremaining', {$a: result.attemptsremaining}); + } + + return messages; + } + + /** + * Get the IDs of all the pages that have at least 1 question attempt. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the IDs. + */ + getPagesIdsWithQuestionAttempts(lessonId: number, retake: number, correct?: boolean, siteId?: string, userId?: number) + : Promise { + + return this.getQuestionsAttempts(lessonId, retake, correct, undefined, siteId, userId).then((result) => { + const ids = {}, + attempts = result.online.concat(result.offline); + + attempts.forEach((attempt) => { + if (!ids[attempt.pageid]) { + ids[attempt.pageid] = true; + } + }); + + return Object.keys(ids).map((id) => { + return Number(id); + }); + }); + } + + /** + * Get different informative messages when viewing a lesson page. + * Please try to use WS response messages instead of this function if possible. + * Based on Moodle's add_messages_on_page_view. + * + * @param {any} lesson Lesson. + * @param {any} accessInfo Result of get access info. Required if offline is true. + * @param {any} page Page loaded. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} jumps Result of get pages possible jumps. + * @param {string} [password] Lesson password (if any). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of messages. + */ + getPageViewMessages(lesson: any, accessInfo: any, page: any, review: boolean, jumps: any, password?: string, siteId?: string) + : Promise { + + const messages = []; + let promise = Promise.resolve(); + + if (!accessInfo.canmanage) { + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE && lesson.minquestions) { + // Tell student how many questions they have seen, how many are required and their grade. + const retake = accessInfo.attemptscount; + + promise = this.lessonGrade(lesson, retake, password, review, undefined, siteId).then((gradeInfo) => { + if (gradeInfo.attempts) { + if (gradeInfo.nquestions < lesson.minquestions) { + this.addMessage(messages, 'addon.mod_lesson.numberofpagesviewednotice', {$a: { + nquestions: gradeInfo.nquestions, + minquestions: lesson.minquestions + }}); + } + + if (!review && !lesson.retake) { + this.addMessage(messages, 'addon.mod_lesson.numberofcorrectanswers', {$a: gradeInfo.earned}); + + if (lesson.grade != CoreGradesProvider.TYPE_NONE) { + this.addMessage(messages, 'addon.mod_lesson.yourcurrentgradeisoutof', {$a: { + grade: this.textUtils.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1), + total: lesson.grade + }}); + } + } + } + }).catch(() => { + // Ignore errors. + }); + } + } else { + if (lesson.timelimit) { + this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning'); + } + + if (this.lessonDisplayTeacherWarning(jumps)) { + // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher. + this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {$a: { + cluster: this.translate.instant('addon.mod_lesson.clusterjump'), + unseen: this.translate.instant('addon.mod_lesson.unseenpageinbranch') + }}); + } + } + + return promise.then(() => { + return messages; + }); + } + + /** + * Get questions attempts, including offline attempts. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise<{online: any[], offline: any[]}>} Promise resolved with the questions attempts. + */ + getQuestionsAttempts(lessonId: number, retake: number, correct?: boolean, pageId?: number, siteId?: string, userId?: number) + : Promise<{online: any[], offline: any[]}> { + + const promises = [], + result = { + online: [], + offline: [] + }; + + promises.push(this.getQuestionsAttemptsOnline(lessonId, retake, correct, pageId, false, false, siteId, userId) + .then((attempts) => { + result.online = attempts; + })); + + promises.push(this.lessonOfflineProvider.getQuestionsAttempts(lessonId, retake, correct, pageId, siteId).catch(() => { + // Error, assume no attempts. + return []; + }).then((attempts) => { + result.offline = attempts; + })); + + return Promise.all(promises).then(() => { + return result; + }); + } + + /** + * Get cache key for get questions attempts WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getQuestionsAttemptsCacheKey(lessonId: number, retake: number, userId: number): string { + return this.getQuestionsAttemptsCommonCacheKey(lessonId) + ':' + userId + ':' + retake; + } + + /** + * Get common cache key for get questions attempts WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getQuestionsAttemptsCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'questionsAttempts:' + lessonId; + } + + /** + * Get questions attempts from the site. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {boolean} [correct] True to only fetch correct attempts, false to get them all. + * @param {number} [pageId] If defined, only get attempts on this page. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the questions attempts. + */ + getQuestionsAttemptsOnline(lessonId: number, retake: number, correct?: boolean, pageId?: number, forceCache?: boolean, + ignoreCache?: boolean, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + // Don't pass "pageId" and "correct" params, they will be filtered locally. + const params = { + lessonid: lessonId, + attempt: retake, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuestionsAttemptsCacheKey(lessonId, retake, userId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_questions_attempts', params, preSets).then((response) => { + if (pageId || correct) { + // Filter the attempts. + return response.attempts.filter((attempt) => { + if (correct && !attempt.correct) { + return false; + } + + if (pageId && attempt.pageid != pageId) { + return false; + } + + return true; + }); + } + + return response.attempts; + }); + }); + } + + /** + * Get the overview of retakes in a lesson (named "attempts overview" in Moodle). + * + * @param {number} lessonId Lesson ID. + * @param {number} [groupId] The group to get. If not defined, all participants. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retakes overview. + */ + getRetakesOverview(lessonId: number, groupId?: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + groupId = groupId || 0; + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + lessonid: lessonId, + groupid: groupId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_attempts_overview', params, preSets).then((response) => { + return response.data; + }); + }); + } + + /** + * Get cache key for get retakes overview WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} groupId Group ID. + * @return {string} Cache key. + */ + protected getRetakesOverviewCacheKey(lessonId: number, groupId: number): string { + return this.getRetakesOverviewCommonCacheKey(lessonId) + ':' + groupId; + } + + /** + * Get common cache key for get retakes overview WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getRetakesOverviewCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'retakesOverview:' + lessonId; + } + + /** + * Get a password stored in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with password on success, rejected otherwise. + */ + getStoredPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.PASSWORD_TABLE, {lessonId}).then((entry) => { + return entry.password; + }); + }); + } + + /** + * Finds all pages that appear to be a subtype of the provided pageId until an end point specified within "ends" is + * encountered or no more pages exist. + * Based on Moodle's get_sub_pages_of. + * + * @param {any} pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param {number} pageId Page ID to get subpages of. + * @param {number[]} end An array of LESSON_PAGE_* types that signify an end of the subtype. + * @return {Object[]} List of subpages. + */ + getSubpagesOf(pages: any, pageId: number, ends: number[]): any[] { + const subPages = []; + + pageId = pages[pageId].nextpageid; // Move to the first page after the given page. + ends = ends || []; + + while (true) { + if (!pageId || ends.indexOf(pages[pageId].qtype) != -1) { + // No more pages or it reached a page of the searched types. Stop. + break; + } + + subPages.push(pages[pageId]); + pageId = pages[pageId].nextpageid; + } + + return subPages; + } + + /** + * Get lesson timers. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's current user. + * @return {Promise} Promise resolved with the pages. + */ + getTimers(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + lessonid: lessonId, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getTimersCacheKey(lessonId, userId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_user_timers', params, preSets).then((response) => { + return response.timers; + }); + }); + } + + /** + * Get cache key for get timers WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getTimersCacheKey(lessonId: number, userId: number): string { + return this.getTimersCommonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get common cache key for get timers WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getTimersCommonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'timers:' + lessonId; + } + + /** + * Get the list of used answers (with valid answer) in a multichoice question page. + * + * @param {any} pageData Result of getPageData for the page to process. + * @return {any[]} List of used answers. + */ + protected getUsedAnswersMultichoice(pageData: any): any[] { + const answers = this.utils.clone(pageData.answers); + + return answers.filter((entry) => { + return entry.answer !== ''; + }); + } + + /** + * Get the user's response in a matching question page. + * + * @param {any} data Data containing the user answer. + * @return {any} User response. + */ + protected getUserResponseMatching(data: any): any { + if (data.response) { + // The data is already stored as expected. Return it. + return data.response; + } + + // Data is stored in properties like 'response[379]'. Recreate the response object. + const response = {}; + + for (const key in data) { + const match = key.match(/^response\[(\d+)\]/); + + if (match && match.length > 1) { + response[match[1]] = data[key]; + } + } + + return response; + } + + /** + * Get the user's response in a multichoice page if multiple answers are allowed. + * + * @param {any} data Data containing the user answer. + * @return {any[]} User response. + */ + protected getUserResponseMultichoice(data: any): any[] { + if (data.answer) { + // The data is already stored as expected. If it's valid, parse the values to int. + if (Array.isArray(data.answer)) { + return data.answer.map((value) => { + return parseInt(value, 10); + }); + } + + return data.answer; + } + + // Data is stored in properties like 'answer[379]'. Recreate the answer array. + const answer = []; + for (const key in data) { + const match = key.match(/^answer\[(\d+)\]/); + if (match && match.length > 1) { + answer.push(parseInt(match[1], 10)); + } + } + + return answer; + } + + /** + * Get a user's retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number + * @param {number} [userId] User ID. Undefined for current user. + * @param {boolean} [forceCache] Whether it should always return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake data. + */ + getUserRetake(lessonId: number, retake: number, userId?: number, forceCache?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + lessonid: lessonId, + userid: userId, + lessonattempt: retake + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake) + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_lesson_get_user_attempt', params, preSets); + }); + } + + /** + * Get cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @param {number} retake Retake number + * @return {string} Cache key. + */ + protected getUserRetakeCacheKey(lessonId: number, userId: number, retake: number): string { + return this.getUserRetakeUserCacheKey(lessonId, userId) + ':' + retake; + } + + /** + * Get user cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getUserRetakeUserCacheKey(lessonId: number, userId: number): string { + return this.getUserRetakeLessonCacheKey(lessonId) + ':' + userId; + } + + /** + * Get lesson cache key for get user retake WS calls. + * + * @param {number} lessonId Lesson ID. + * @return {string} Cache key. + */ + protected getUserRetakeLessonCacheKey(lessonId: number): string { + return this.ROOT_CACHE_KEY + 'userRetake:' + lessonId; + } + + /** + * Check if a jump is correct. + * Based in Moodle's jumpto_is_correct. + * + * @param {number} pageId ID of the page from which you are jumping from. + * @param {number} jumpTo The jumpto number. + * @param {any} pageIndex Object containing all the pages indexed by ID. See createPagesIndex. + * @return {boolean} Whether jump is correct. + */ + jumptoIsCorrect(pageId: number, jumpTo: number, pageIndex: any): boolean { + // First test the special values. + if (!jumpTo) { + // Same page + return false; + } else if (jumpTo == AddonModLessonProvider.LESSON_NEXTPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_RANDOMPAGE) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_CLUSTERJUMP) { + return true; + } else if (jumpTo == AddonModLessonProvider.LESSON_EOL) { + return true; + } + + let aPageId = pageIndex[pageId].nextpageid; + while (aPageId) { + if (jumpTo == aPageId) { + return true; + } + + aPageId = pageIndex[aPageId].nextpageid; + } + + return false; + } + + /** + * Invalidates Lesson data. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(lessonId)); + }); + } + + /** + * Invalidates content pages viewed for all retakes. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContentPagesViewed(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getContentPagesViewedCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates content pages viewed for a certain retake. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContentPagesViewedForRetake(lessonId: number, retake: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getContentPagesViewedCacheKey(lessonId, retake)); + }); + } + + /** + * Invalidates Lesson data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateLessonData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getLessonDataCacheKey(courseId)); + }); + } + + /** + * Invalidates lesson with password. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateLessonWithPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getLessonWithPasswordCacheKey(lessonId)); + }); + } + + /** + * Invalidates page data for all pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePageData(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getPageDataCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates page data for a certain page. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePageDataForPage(lessonId: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPageDataCacheKey(lessonId, pageId)); + }); + } + + /** + * Invalidates pages. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePages(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPagesCacheKey(lessonId)); + }); + } + + /** + * Invalidates pages possible jumps. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePagesPossibleJumps(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPagesPossibleJumpsCacheKey(lessonId)); + }); + } + + /** + * Invalidates questions attempts for all retakes. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateQuestionsAttempts(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getQuestionsAttemptsCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates question attempts for a certain retake and user. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site.. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateQuestionsAttemptsForRetake(lessonId: number, retake: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getQuestionsAttemptsCacheKey(lessonId, retake, userId)); + }); + } + + /** + * Invalidates retakes overview for all groups in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateRetakesOverview(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getRetakesOverviewCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates retakes overview for a certain group in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} groupId Group ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateRetakesOverviewForGroup(lessonId: number, groupId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getRetakesOverviewCacheKey(lessonId, groupId)); + }); + } + + /** + * Invalidates timers for all users in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTimers(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getTimersCommonCacheKey(lessonId)); + }); + } + + /** + * Invalidates timers for a certain user. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateTimersForUser(lessonId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getTimersCacheKey(lessonId, userId)); + }); + } + + /** + * Invalidates a certain retake for a certain user. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {number} [userId] User ID. Undefined for current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetake(lessonId: number, retake: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getUserRetakeCacheKey(lessonId, userId, retake)); + }); + } + + /** + * Invalidates all retakes for all users in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetakesForLesson(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeLessonCacheKey(lessonId)); + }); + } + + /** + * Invalidates all retakes for a certain user in a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {number} [userId] User ID. Undefined for current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserRetakesForUser(lessonId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeUserCacheKey(lessonId, userId)); + }); + } + + /** + * Check if a page answer is correct. + * + * @param {any} lesson Lesson. + * @param {number} pageId The page ID. + * @param {any} answer The answer to check. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @return {boolean} Whether the answer is correct. + */ + protected isAnswerCorrect(lesson: any, pageId: number, answer: any, pageIndex: any): boolean { + if (lesson.custom) { + // Custom scores. If score on answer is positive, it is correct. + return answer.score > 0; + } else { + return this.jumptoIsCorrect(pageId, answer.jumpto, pageIndex); + } + } + + /** + * Check if a lesson is enabled to be used in offline. + * + * @param {any} lesson Lesson. + * @return {boolean} Whether offline is enabled. + */ + isLessonOffline(lesson: any): boolean { + return !!lesson.allowofflineattempts; + } + + /** + * Check if a lesson is password protected based in the access info. + * + * @param {any} info Lesson access info. + * @return {boolean} Whether the lesson is password protected. + */ + isPasswordProtected(info: any): boolean { + if (info && info.preventaccessreasons) { + for (let i = 0; i < info.preventaccessreasons.length; i++) { + const entry = info.preventaccessreasons[i]; + + if (entry.reason == 'passwordprotectedlesson') { + return true; + } + } + } + + return false; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the lesson WS are available. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // All WS were introduced at the same time so checking one is enough. + return site.wsAvailable('mod_lesson_get_lesson_access_information'); + }); + } + + /** + * Check if a page is a question page or a content page. + * + * @param {number} type Type of the page. + * @return {boolean} True if question page, false if content page. + */ + isQuestionPage(type: number): boolean { + return type == AddonModLessonProvider.TYPE_QUESTION; + } + + /** + * Start or continue a retake. + * + * @param {string} id Lesson ID. + * @param {string} [password] Lesson password (if any). + * @param {number} [pageId] Page id to continue from (only when continuing a retake). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + launchRetake(id: number, password?: string, pageId?: number, review?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: id, + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + if (typeof pageId == 'number') { + params.pageid = pageId; + } + + return site.write('mod_lesson_launch_attempt', params); + }); + } + + /** + * Check if the user left during a timed session. + * + * @param {any} info Lesson access info. + * @return {boolean} True if left during timed, false otherwise. + */ + leftDuringTimed(info: any): boolean { + return info && info.lastpageseen && info.lastpageseen != AddonModLessonProvider.LESSON_EOL && info.leftduringtimedsession; + } + + /** + * Checks to see if a LESSON_CLUSTERJUMP or a LESSON_UNSEENBRANCHPAGE is used in a lesson. + * Based on Moodle's lesson_display_teacher_warning. + * + * @param {any} jumps Result of get pages possible jumps. + * @return {boolean} Whether the lesson uses one of those jumps. + */ + lessonDisplayTeacherWarning(jumps: any): boolean { + if (!jumps) { + return false; + } + + // Check if any jump is to cluster or unseen content page. + for (const pageId in jumps) { + for (const jumpto in jumps[pageId]) { + const jumptoNum = Number(jumpto); + + if (jumptoNum == AddonModLessonProvider.LESSON_CLUSTERJUMP || + jumptoNum == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) { + return true; + } + } + } + + return false; + } + + /** + * Calculates a user's grade for a lesson. + * Based on Moodle's lesson_grade. + * + * @param {any} lesson Lesson. + * @param {number} retake Retake number. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {any} [pageIndex] Object containing all the pages indexed by ID. If not provided, it will be calculated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, site's user. + * @return {Promise} Promise resolved with the grade data. + */ + lessonGrade(lesson: any, retake: number, password?: string, review?: boolean, pageIndex?: any, siteId?: string, + userId?: number): Promise { + + // Initialize all variables. + let nViewed = 0, + nManual = 0, + manualPoints = 0, + theGrade = 0, + nQuestions = 0, + total = 0, + earned = 0; + + // Get the questions attempts for the user. + return this.getQuestionsAttempts(lesson.id, retake, false, undefined, siteId, userId).then((attemptsData) => { + const attempts = attemptsData.online.concat(attemptsData.offline); + + if (!attempts.length) { + // No attempts. + return; + } + + const attemptSet = {}; + let promise; + + // Create the pageIndex if it isn't provided. + if (!pageIndex) { + promise = this.getPages(lesson.id, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + const pageIds = []; + + // Group each try with its page. + attempts.forEach((attempt) => { + if (!attemptSet[attempt.pageid]) { + attemptSet[attempt.pageid] = []; + pageIds.push(attempt.pageid); + } + attemptSet[attempt.pageid].push(attempt); + }); + + // Drop all attempts that go beyond max attempts for the lesson. + for (const pageId in attemptSet) { + // Sort the list by time in ascending order. + const attempts = attemptSet[pageId].sort((a, b) => { + return (a.timeseen || a.timemodified) - (b.timeseen || b.timemodified); + }); + + attemptSet[pageId] = attempts.slice(0, lesson.maxattempts); + } + + // Get all the answers from the pages the user answered. + return this.getPagesAnswers(lesson, pageIds, password, review, siteId); + }).then((answers) => { + // Number of pages answered. + nQuestions = Object.keys(attemptSet).length; + + for (const pageId in attemptSet) { + const attempts = attemptSet[pageId], + lastAttempt = attempts[attempts.length - 1]; + + if (lesson.custom) { + // If essay question, handle it, otherwise add to score. + if (pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + if (lastAttempt.useranswer && typeof lastAttempt.useranswer.score != 'undefined') { + earned += lastAttempt.useranswer.score; + } + nManual++; + manualPoints += answers[lastAttempt.answerid].score; + } else if (lastAttempt.answerid) { + earned += answers[lastAttempt.answerid].score; + } + } else { + attempts.forEach((attempt) => { + earned += attempt.correct ? 1 : 0; + }); + + // If essay question, increase numbers. + if (pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) { + nManual++; + manualPoints++; + } + } + + // Number of times answered. + nViewed += attempts.length; + } + + if (lesson.custom) { + const bestScores = {}; + + // Find the highest possible score per page to get our total. + for (const answerId in answers) { + const answer = answers[answerId]; + + if (typeof bestScores[answer.pageid] == 'undefined') { + bestScores[answer.pageid] = answer.score; + } else if (bestScores[answer.pageid] < answer.score) { + bestScores[answer.pageid] = answer.score; + } + } + + // Sum all the scores. + for (const pageId in bestScores) { + total += bestScores[pageId]; + } + } else { + // Check to make sure the student has answered the minimum questions. + if (lesson.minquestions && nQuestions < lesson.minquestions) { + // Nope, increase number viewed by the amount of unanswered questions. + total = nViewed + (lesson.minquestions - nQuestions); + } else { + total = nViewed; + } + } + }); + }).then(() => { + if (total) { // Not zero. + theGrade = this.textUtils.roundToDecimals(earned * 100 / total, 5); + } + + return { + nquestions: nQuestions, + attempts: nViewed, + total: total, + earned: earned, + grade: theGrade, + nmanual: nManual, + manualpoints: manualPoints + }; + }); + } + + /** + * Report a lesson as being viewed. + * + * @param {string} id Module ID. + * @param {string} [password] Lesson password (if any). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logViewLesson(id: number, password?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: id + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_view_lesson', params).then((result) => { + if (!result.status) { + return Promise.reject(null); + } + + return result; + }); + }); + } + + /** + * Process a lesson page, saving its data. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data to save. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {boolean} [offline] Whether it's offline mode. + * @param {any} [accessInfo] Result of get access info. Required if offline is true. + * @param {any} [jumps] Result of get pages possible jumps. Required if offline is true. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + processPage(lesson: any, courseId: number, pageData: any, data: any, password?: string, review?: boolean, offline?: boolean, + accessInfo?: boolean, jumps?: any, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const page = pageData.page, + pageId = page.id; + let result, + pageIndex; + + if (offline) { + // Get the list of pages of the lesson. + return this.getPages(lesson.id, password, true, false, siteId).then((pages) => { + pageIndex = this.createPagesIndex(pages); + + if (pageData.answers.length) { + return this.recordAttempt(lesson, courseId, pageData, data, review, accessInfo, jumps, pageIndex, siteId); + } else { + // The page has no answers so we will just progress to the next page (as set by newpageid). + return { + nodefaultresponse: true, + newpageid: data.newpageid + }; + } + }).then((res) => { + result = res; + result.newpageid = this.getNewPageId(pageData.page.id, result.newpageid, jumps); + + // Calculate some needed offline data. + return this.calculateOfflineData(lesson, accessInfo, password, review, pageIndex, siteId); + }).then((calculatedData) => { + // Add some default data to match the WS response. + result.warnings = []; + result.displaymenu = pageData.displaymenu; // Keep the same value since we can't calculate it in offline. + result.messages = this.getPageProcessMessages(lesson, accessInfo, result, review, jumps); + Object.assign(result, calculatedData); + + return result; + }); + } + + return this.processPageOnline(lesson.id, pageId, data, password, review, siteId); + } + + /** + * Process a lesson page, saving its data. It will fail if offline or cannot connect. + * + * @param {number} lessonId Lesson ID. + * @param {number} pageId Page ID. + * @param {any} data Data to save. + * @param {string} [password] Lesson password (if any). + * @param {boolean} [review] If the user wants to review just after finishing (1 hour margin). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + processPageOnline(lessonId: number, pageId: number, data: any, password?: string, review?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + lessonid: lessonId, + pageid: pageId, + data: this.utils.objectToArrayOfObjects(data, 'name', 'value', true), + review: review ? 1 : 0 + }; + + if (typeof password == 'string') { + params.password = password; + } + + return site.write('mod_lesson_process_page', params); + }); + } + + /** + * Records an attempt on a certain page. + * Based on Moodle's record_attempt. + * + * @param {any} lesson Lesson. + * @param {number} courseId Course ID the lesson belongs to. + * @param {any} pageData Result of getPageData for the page to process. + * @param {any} data Data to save. + * @param {boolean} review If the user wants to review just after finishing (1 hour margin). + * @param {any} accessInfo Result of get access info. + * @param {any} jumps Result of get pages possible jumps. + * @param {any} pageIndex Object containing all the pages indexed by ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the result. + */ + protected recordAttempt(lesson: any, courseId: number, pageData: any, data: any, review: boolean, accessInfo: any, jumps: any, + pageIndex: any, siteId?: string): Promise { + + // Check the user answer. Each page type has its own implementation. + const result: AddonModLessonRecordAttemptResult = this.checkAnswer(lesson, pageData, data, jumps, pageIndex), + retake = accessInfo.attemptscount; + + // Processes inmediate jumps. + if (result.inmediatejump) { + if (pageData.page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) { + // Store the content page data. In Moodle this is stored in a separate table, during checkAnswer. + return this.lessonOfflineProvider.processPage(lesson.id, courseId, retake, pageData.page, data, + result.newpageid, result.answerid, false, result.userresponse, siteId).then(() => { + return result; + }); + } + + return Promise.resolve(result); + } + + let promise = Promise.resolve(), + stop = false, + nAttempts; + + result.attemptsremaining = 0; + result.maxattemptsreached = false; + + if (result.noanswer) { + result.newpageid = pageData.page.id; // Display same page again. + result.feedback = this.translate.instant('addon.mod_lesson.noanswer'); + } else { + if (!accessInfo.canmanage) { + // Get the number of attempts that have been made on this question for this student and retake. + promise = this.getQuestionsAttempts(lesson.id, retake, false, pageData.page.id, siteId).then((attempts) => { + nAttempts = attempts.online.length + attempts.offline.length; + + // Check if they have reached (or exceeded) the maximum number of attempts allowed. + if (nAttempts >= lesson.maxattempts) { + result.maxattemptsreached = true; + result.feedback = this.translate.instant('addon.mod_lesson.maximumnumberofattemptsreached'); + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + stop = true; // Set stop to true to prevent further calculations. + + return; + } + + let subPromise; + + // Only insert a record if we are not reviewing the lesson. + if (!review) { + if (lesson.retake || (!lesson.retake && !retake)) { + // Store the student's attempt and increase the number of attempts made. + // Calculate and store the new page ID to prevent having to recalculate it later. + const newPageId = this.getNewPageId(pageData.page.id, result.newpageid, jumps); + subPromise = this.lessonOfflineProvider.processPage(lesson.id, courseId, retake, pageData.page, data, + newPageId, result.answerid, result.correctanswer, result.userresponse, siteId); + nAttempts++; + } + } + + // Check if "number of attempts remaining" message is needed. + if (!result.correctanswer && !result.newpageid) { + // Retreive the number of attempts left counter. + if (nAttempts >= lesson.maxattempts) { + if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt. + result.maxattemptsreached = true; + } + result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE; + } else if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt + result.attemptsremaining = lesson.maxattempts - nAttempts; + } + } + + return subPromise; + }); + } + + promise = promise.then(() => { + if (stop) { + return; + } + + // Determine default feedback if necessary. + if (!result.response) { + if (!lesson.feedback && !result.noanswer && + !(lesson.review && !result.correctanswer && !result.isessayquestion)) { + // These conditions have been met: + // 1. The lesson manager has not supplied feedback to the student. + // 2. Not displaying default feedback. + // 3. The user did provide an answer. + // 4. We are not reviewing with an incorrect answer (and not reviewing an essay question). + + result.nodefaultresponse = true; + } else if (result.isessayquestion) { + result.response = this.translate.instant('addon.mod_lesson.defaultessayresponse'); + } else if (result.correctanswer) { + result.response = this.translate.instant('addon.mod_lesson.thatsthecorrectanswer'); + } else { + result.response = this.translate.instant('addon.mod_lesson.thatsthewronganswer'); + } + } + + if (result.response) { + let subPromise; + + if (lesson.review && !result.correctanswer && !result.isessayquestion) { + // Calculate the number of question attempt in the page if it isn't calculated already. + if (typeof nAttempts == 'undefined') { + subPromise = this.getQuestionsAttempts(lesson.id, retake, false, pageData.page.id, siteId) + .then((result) => { + nAttempts = result.online.length + result.offline.length; + }); + } else { + subPromise = Promise.resolve(); + } + + subPromise.then(() => { + const messageId = nAttempts == 1 ? 'firstwrong' : 'secondpluswrong'; + + result.feedback = ''; + }); + } else { + result.feedback = ''; + subPromise = Promise.resolve(); + } + + let className = 'response'; + if (result.correctanswer) { + className += ' correct'; + } else if (!result.isessayquestion) { + className += ' incorrect'; + } + + return subPromise.then(() => { + result.feedback += '
' + pageData.page.contents + '
'; + result.feedback += '
' + + this.translate.instant('addon.mod_lesson.youranswer') + ' : ' + + (result.studentanswerformat ? result.studentanswer : this.textUtils.cleanTags(result.studentanswer)) + + '
' + result.response + '
'; + }); + } + }); + } + + return promise.then(() => { + return result; + }); + } + + /** + * Remove a password stored in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when removed. + */ + removeStoredPassword(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.PASSWORD_TABLE, {lessonId}); + }); + } + + /** + * Store a password in DB. + * + * @param {number} lessonId Lesson ID. + * @param {string} password Password to store. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when stored. + */ + storePassword(lessonId: number, password: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + lessonId: lessonId, + password: password, + timemodified: Date.now() + }; + + return site.getDb().insertRecord(this.PASSWORD_TABLE, entry); + }); + } + + /** + * Function to determine if a page is a valid page. It will add the page to validPages if valid. It can also + * modify the list of viewedPagesIds for cluster pages. + * Based on Moodle's valid_page_and_view. + * + * @param {any} pages Index of lesson pages, indexed by page ID. See createPagesIndex. + * @param {any} page Page to check. + * @param {any} validPages Valid pages, indexed by page ID. + * @param {number[]} viewedPagesIds List of viewed pages IDs. + * @return {number} Next page ID. + */ + validPageAndView(pages: any, page: any, validPages: any, viewedPagesIds: number[]): number { + + if (page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER && + page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFBRANCH) { + // Add this page as a valid page. + validPages[page.id] = 1; + } + + if (page.qtype == AddonModLessonProvider.LESSON_PAGE_CLUSTER) { + // Get list of pages in the cluster. + const subPages = this.getSubpagesOf(pages, page.id, [AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER]); + + subPages.forEach((subPage) => { + const position = viewedPagesIds.indexOf(subPage.id); + + if (position != -1) { + delete viewedPagesIds[position]; // Remove it. + + // Since the user did see one page in the cluster, add the cluster pageid to the viewedPagesIds. + if (viewedPagesIds.indexOf(page.id) == -1) { + viewedPagesIds.push(page.id); + } + } + }); + } + + return page.nextpageid; + } +} diff --git a/src/core/grades/providers/grades.ts b/src/core/grades/providers/grades.ts index b040678c7..4647b6ed6 100644 --- a/src/core/grades/providers/grades.ts +++ b/src/core/grades/providers/grades.ts @@ -22,6 +22,12 @@ import { CoreCoursesProvider } from '@core/courses/providers/courses'; */ @Injectable() export class CoreGradesProvider { + + static TYPE_NONE = 0; // Moodle's GRADE_TYPE_NONE. + static TYPE_VALUE = 1; // Moodle's GRADE_TYPE_VALUE. + static TYPE_SCALE = 2; // Moodle's GRADE_TYPE_SCALE. + static TYPE_TEXT = 3; // Moodle's GRADE_TYPE_TEXT. + protected ROOT_CACHE_KEY = 'mmGrades:'; protected logger;