diff --git a/src/addon/mod/quiz/providers/quiz-offline.ts b/src/addon/mod/quiz/providers/quiz-offline.ts new file mode 100644 index 000000000..7d575ac5e --- /dev/null +++ b/src/addon/mod/quiz/providers/quiz-offline.ts @@ -0,0 +1,381 @@ +// (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 { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreQuestionProvider } from '@core/question/providers/question'; +import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; +import { AddonModQuizProvider } from './quiz'; +import { SQLiteDB } from '@classes/sqlitedb'; + +/** + * Service to handle offline quiz. + */ +@Injectable() +export class AddonModQuizOfflineProvider { + + protected logger; + + // Variables for database. + protected ATTEMPTS_TABLE = 'addon_mod_quiz_attempts'; + protected tablesSchema = [ + { + name: this.ATTEMPTS_TABLE, + columns: [ + { + name: 'id', // Attempt ID. + type: 'INTEGER', + primaryKey: true + }, + { + name: 'attempt', // Attempt number. + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'userid', + type: 'INTEGER' + }, + { + name: 'quizid', + type: 'INTEGER' + }, + { + name: 'currentpage', + type: 'INTEGER' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'finished', + type: 'INTEGER' + } + ] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, + private questionProvider: CoreQuestionProvider, private translate: TranslateService, private utils: CoreUtilsProvider, + private behaviourDelegate: CoreQuestionBehaviourDelegate) { + this.logger = logger.getInstance('AddonModQuizOfflineProvider'); + + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Classify the answers in questions. + * + * @param {any} answers List of answers. + * @return {any} Object with the questions, the keys are the slot. Each question contains its answers. + */ + classifyAnswersInQuestions(answers: any): any { + const questionsWithAnswers = {}; + + // Classify the answers in each question. + for (const name in answers) { + const slot = this.questionProvider.getQuestionSlotFromName(name), + nameWithoutPrefix = this.questionProvider.removeQuestionPrefix(name); + + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = { + answers: {}, + prefix: name.substr(0, name.indexOf(nameWithoutPrefix)) + }; + } + questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name]; + } + + return questionsWithAnswers; + } + + /** + * Given a list of questions with answers classified in it (@see AddonModQuizOfflineProvider.classifyAnswersInQuestions), + * returns a list of answers (including prefix in the name). + * + * @param {any} questions Questions. + * @return {any} Answers. + */ + extractAnswersFromQuestions(questions: any): any { + const answers = {}; + + for (const slot in questions) { + const question = questions[slot]; + + for (const name in question.answers) { + answers[question.prefix + name] = question.answers[name]; + } + } + + return answers; + } + + /** + * Get all the offline attempts in a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the offline attempts. + */ + getAllAttempts(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.ATTEMPTS_TABLE); + }); + } + + /** + * Retrieve an attempt answers from site DB. + * + * @param {number} attemptId Attempt ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the answers. + */ + getAttemptAnswers(attemptId: number, siteId?: string): Promise { + return this.questionProvider.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId); + } + + /** + * Retrieve an attempt from site DB. + * + * @param {number} attemptId Attempt ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the attempt. + */ + getAttemptById(attemptId: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecord(this.ATTEMPTS_TABLE, {id: attemptId}); + }); + } + + /** + * Retrieve an attempt from site DB. + * + * @param {number} attemptId Attempt ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, user current site's user. + * @return {Promise} Promise resolved with the attempts. + */ + getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecords(this.ATTEMPTS_TABLE, {quizid: quizId, userid: userId}); + }); + } + + /** + * Load local state in the questions. + * + * @param {number} attemptId Attempt ID. + * @param {any[]} questions List of questions. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + loadQuestionsLocalStates(attemptId: number, questions: any[], siteId?: string): Promise { + const promises = []; + + questions.forEach((question) => { + promises.push(this.questionProvider.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId) + .then((q) => { + + const state = this.questionProvider.getState(q.state); + question.state = q.state; + question.status = this.translate.instant('core.question.' + state.status); + }).catch(() => { + // Question not found. + })); + }); + + return Promise.all(promises).then(() => { + return questions; + }); + } + + /** + * Process an attempt, saving its data. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} questions Object with the questions of the quiz. The keys should be the question slot. + * @param {any} data Data to save. + * @param {boolean} [finish] Whether to finish the quiz. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + processAttempt(quiz: any, attempt: any, questions: any, data: any, finish?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const now = this.timeUtils.timestamp(); + let db: SQLiteDB; + + return this.sitesProvider.getSiteDb(siteId).then((siteDb) => { + db = siteDb; + + // Check if an attempt already exists. + return this.getAttemptById(attempt.id, siteId).catch(() => { + // Attempt doesn't exist, create a new entry. + return { + quizid: quiz.id, + userid: attempt.userid, + id: attempt.id, + courseid: quiz.course, + timecreated: now, + attempt: attempt.attempt, + currentpage: attempt.currentpage + }; + }); + }).then((entry) => { + // Save attempt in DB. + entry.timemodified = now; + entry.finished = !!finish; + + return db.insertRecord(this.ATTEMPTS_TABLE, entry); + }).then(() => { + // Attempt has been saved, now we need to save the answers. + return this.saveAnswers(quiz, attempt, questions, data, now, siteId); + }); + } + + /** + * Remove an attempt and its answers from local DB. + * + * @param {number} attemptId Attempt ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + // Remove stored answers and questions. + promises.push(this.questionProvider.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId)); + promises.push(this.questionProvider.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId)); + + // Remove the attempt. + promises.push(this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.ATTEMPTS_TABLE, {id: attemptId}); + })); + + return Promise.all(promises); + } + + /** + * Remove a question and its answers from local DB. + * + * @param {number} attemptId Attempt ID. + * @param {number} slot Question slot. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when finished. + */ + removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + promises.push(this.questionProvider.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId)); + promises.push(this.questionProvider.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId)); + + return Promise.all(promises); + } + + /** + * Save an attempt's answers and calculate state for questions modified. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} questions Object with the questions of the quiz. The keys should be the question slot. + * @param {any} answers Answers to save. + * @param {number} [timeMod] Time modified to set in the answers. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + saveAnswers(quiz: any, attempt: any, questions: any, answers: any, timeMod?: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + timeMod = timeMod || this.timeUtils.timestamp(); + + const questionsWithAnswers = {}, + newStates = {}; + let promises = []; + + // Classify the answers in each question. + for (const name in answers) { + const slot = this.questionProvider.getQuestionSlotFromName(name), + nameWithoutPrefix = this.questionProvider.removeQuestionPrefix(name); + + if (questions[slot]) { + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = questions[slot]; + questionsWithAnswers[slot].answers = {}; + } + questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name]; + } + } + + // First determine the new state of each question. We won't save the new state yet. + for (const slot in questionsWithAnswers) { + const question = questionsWithAnswers[slot]; + + promises.push(this.behaviourDelegate.determineNewState( + quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => { + // Check if state has changed. + if (state && state.name != question.state) { + newStates[question.slot] = state.name; + } + })); + } + + return Promise.all(promises).then(() => { + // Now save the answers. + return this.questionProvider.saveAnswers(AddonModQuizProvider.COMPONENT, quiz.id, attempt.id, attempt.userid, + answers, timeMod, siteId); + }).then(() => { + // Answers have been saved, now we can save the questions with the states. + promises = []; + + for (const slot in newStates) { + const question = questionsWithAnswers[slot]; + + promises.push(this.questionProvider.saveQuestion(AddonModQuizProvider.COMPONENT, quiz.id, attempt.id, + attempt.userid, question, newStates[slot], siteId)); + } + + return this.utils.allPromises(promises).catch((err) => { + // Ignore errors when saving question state. + this.logger.error('Error saving question state', err); + }); + }); + } + + /** + * Set attempt's current page. + * + * @param {number} attemptId Attempt ID. + * @param {number} page Page to set. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.updateRecords(this.ATTEMPTS_TABLE, {currentpage: page}, {id: attemptId}); + }); + } +} diff --git a/src/addon/mod/quiz/providers/quiz.ts b/src/addon/mod/quiz/providers/quiz.ts index 43c2e1416..db6bbd65a 100644 --- a/src/addon/mod/quiz/providers/quiz.ts +++ b/src/addon/mod/quiz/providers/quiz.ts @@ -24,6 +24,7 @@ import { CoreSiteWSPreSets } from '@classes/site'; import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { AddonModQuizOfflineProvider } from './quiz-offline'; import * as moment from 'moment'; /** @@ -60,7 +61,7 @@ export class AddonModQuizProvider { private translate: TranslateService, private textUtils: CoreTextUtilsProvider, private gradesHelper: CoreGradesHelperProvider, private questionDelegate: CoreQuestionDelegate, private filepoolProvider: CoreFilepoolProvider, private timeUtils: CoreTimeUtilsProvider, - private accessRulesDelegate: AddonModQuizAccessRuleDelegate) { + private accessRulesDelegate: AddonModQuizAccessRuleDelegate, private quizOfflineProvider: AddonModQuizOfflineProvider) { this.logger = logger.getInstance('AddonModQuizProvider'); } @@ -431,10 +432,10 @@ export class AddonModQuizProvider { * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). * @param {boolean} [loadLocal] Whether it should load local state for each question. Only applicable if offline=true. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with the attempt summary. + * @return {Promise} Promise resolved with the list of questions for the attempt summary. */ getAttemptSummary(attemptId: number, preflightData: any, offline?: boolean, ignoreCache?: boolean, loadLocal?: boolean, - siteId?: string): Promise { + siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { @@ -455,7 +456,7 @@ export class AddonModQuizProvider { return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { if (response && response.questions) { if (offline && loadLocal) { - // @todo return $mmaModQuizOffline.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); + return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); } return response.questions; @@ -1399,8 +1400,11 @@ export class AddonModQuizProvider { * @return {Promise} Promise resolved with boolean: true if finished in offline but not synced, false otherwise. */ isAttemptFinishedOffline(attemptId: number, siteId?: string): Promise { - // @todo - return Promise.resolve(false); + return this.quizOfflineProvider.getAttemptById(attemptId, siteId).then((attempt) => { + return !!attempt.finished; + }).catch(() => { + return false; + }); } /** @@ -1438,8 +1442,13 @@ export class AddonModQuizProvider { * @return {Promise} Promise resolved with boolean: true if last offline attempt is unfinished, false otherwise. */ isLastAttemptOfflineUnfinished(quiz: any, siteId?: string, userId?: number): Promise { - // @todo - return Promise.resolve(false); + return this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId, userId).then((attempts) => { + const last = attempts.pop(); + + return last && !last.finished; + }).catch(() => { + return false; + }); } /** @@ -1524,7 +1533,7 @@ export class AddonModQuizProvider { promises.push(this.sitesProvider.getCurrentSite().write('mod_quiz_view_attempt', params)); if (offline) { - // @todo promises.push($mmaModQuizOffline.setAttemptCurrentPage(attemptId, page)); + promises.push(this.quizOfflineProvider.setAttemptCurrentPage(attemptId, page)); } return Promise.all(promises); @@ -1582,15 +1591,54 @@ export class AddonModQuizProvider { * @param {any} data Data to save. * @param {any} preflightData Preflight required data (like password). * @param {boolean} [finish] Whether to finish the quiz. - * @param {boolean} [timeup] Whether the quiz time is up, false otherwise. + * @param {boolean} [timeUp] Whether the quiz time is up, false otherwise. * @param {boolean} [offline] Whether the attempt is offline. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved in success, rejected otherwise. */ - processAttempt(quiz: any, attempt: any, data: any, preflightData: any, finish?: boolean, timeup?: boolean, offline?: boolean, + processAttempt(quiz: any, attempt: any, data: any, preflightData: any, finish?: boolean, timeUp?: boolean, offline?: boolean, siteId?: string): Promise { - // @todo - return Promise.resolve(); + if (offline) { + return this.processAttemptOffline(quiz, attempt, data, preflightData, finish, siteId); + } + + return this.processAttemptOnline(attempt.id, data, preflightData, finish, timeUp, siteId); + } + + /** + * Process an online attempt, saving its data. + * + * @param {number} attemptId Attempt ID. + * @param {any} data Data to save. + * @param {any} preflightData Preflight required data (like password). + * @param {boolean} [finish] Whether to finish the quiz. + * @param {boolean} [timeUp] Whether the quiz time is up, false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + protected processAttemptOnline(attemptId: number, data: any, preflightData: any, finish?: boolean, timeUp?: boolean, + siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + attemptid: attemptId, + data: this.utils.objectToArrayOfObjects(data, 'name', 'value'), + finishattempt: finish ? 1 : 0, + timeup: timeUp ? 1 : 0, + preflightdata: this.utils.objectToArrayOfObjects(preflightData, 'name', 'value') + }; + + return site.write('mod_quiz_process_attempt', params).then((response) => { + if (response && response.warnings && response.warnings.length) { + // Reject with the first warning. + return Promise.reject(response.warnings[0]); + } else if (response && response.state) { + return response.state; + } + + return Promise.reject(null); + }); + }); } /** @@ -1604,7 +1652,7 @@ export class AddonModQuizProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved in success, rejected otherwise. */ - protected processOfflineAttempt(quiz: any, attempt: any, data: any, preflightData: any, finish?: boolean, siteId?: string) + protected processAttemptOffline(quiz: any, attempt: any, data: any, preflightData: any, finish?: boolean, siteId?: string) : Promise { // Get attempt summary to have the list of questions. @@ -1612,8 +1660,7 @@ export class AddonModQuizProvider { // Convert the question array to an object. const questions = this.utils.arrayToObject(questionArray, 'slot'); - return questions; - // @todo return $mmaModQuizOffline.processAttempt(quiz, attempt, questions, data, finish, siteId); + return this.quizOfflineProvider.processAttempt(quiz, attempt, questions, data, finish, siteId); }); } @@ -1678,10 +1725,10 @@ export class AddonModQuizProvider { saveAttempt(quiz: any, attempt: any, data: any, preflightData: any, offline?: boolean, siteId?: string): Promise { try { if (offline) { - return this.processOfflineAttempt(quiz, attempt, data, preflightData, false, siteId); + return this.processAttemptOffline(quiz, attempt, data, preflightData, false, siteId); } - // @todo return $mmaModQuizOnline.saveAttempt(attempt.id, data, preflightData, siteId); + return this.saveAttemptOnline(attempt.id, data, preflightData, siteId); } catch (ex) { this.logger.error(ex); @@ -1689,6 +1736,35 @@ export class AddonModQuizProvider { } } + /** + * Save an attempt data. + * + * @param {number} attemptId Attempt ID. + * @param {any} data Data to save. + * @param {any} preflightData Preflight required data (like password). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success, rejected otherwise. + */ + protected saveAttemptOnline(attemptId: number, data: any, preflightData: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + attemptid: attemptId, + data: this.utils.objectToArrayOfObjects(data, 'name', 'value'), + preflightdata: this.utils.objectToArrayOfObjects(preflightData, 'name', 'value') + }; + + return site.write('mod_quiz_save_attempt', params).then((response) => { + if (response && response.warnings && response.warnings.length) { + // Reject with the first warning. + return Promise.reject(response.warnings[0]); + } else if (!response || !response.status) { + return Promise.reject(null); + } + }); + }); + } + /** * Check if time left should be shown. * @@ -1727,7 +1803,7 @@ export class AddonModQuizProvider { return site.write('mod_quiz_start_attempt', params).then((response) => { if (response && response.warnings && response.warnings.length) { // Reject with the first warning. - return Promise.reject(response.warnings[0].message); + return Promise.reject(response.warnings[0]); } else if (response && response.attempt) { return response.attempt; } diff --git a/src/addon/mod/quiz/quiz.module.ts b/src/addon/mod/quiz/quiz.module.ts index 24f7f1600..48861aa2b 100644 --- a/src/addon/mod/quiz/quiz.module.ts +++ b/src/addon/mod/quiz/quiz.module.ts @@ -15,6 +15,7 @@ import { NgModule } from '@angular/core'; import { AddonModQuizAccessRuleDelegate } from './providers/access-rules-delegate'; import { AddonModQuizProvider } from './providers/quiz'; +import { AddonModQuizOfflineProvider } from './providers/quiz-offline'; @NgModule({ declarations: [ @@ -23,7 +24,8 @@ import { AddonModQuizProvider } from './providers/quiz'; ], providers: [ AddonModQuizAccessRuleDelegate, - AddonModQuizProvider + AddonModQuizProvider, + AddonModQuizOfflineProvider ] }) export class AddonModQuizModule { }