// (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, CoreSiteSchema } 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. static RETAKES_TABLE = 'addon_mod_lesson_retakes'; static PAGE_ATTEMPTS_TABLE = 'addon_mod_lesson_page_attempts'; protected siteSchema: CoreSiteSchema = { name: 'AddonModLessonOfflineProvider', version: 1, tables: [ { name: AddonModLessonOfflineProvider.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: AddonModLessonOfflineProvider.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' }, ], // A user can attempt several times per page and retake. primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'] } ] }; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('AddonModLessonOfflineProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); } /** * 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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.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(AddonModLessonOfflineProvider.RETAKES_TABLE, entry); }); }); } }