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 = '' +
+ this.translate.instant('addon.mod_lesson.' + messageId) + '
';
+ });
+ } 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;