MOBILE-2348 quiz: Implement offline provider

main
Dani Palou 2018-03-26 14:26:32 +02:00
parent 2ae27ed66b
commit fd41274d9e
3 changed files with 479 additions and 20 deletions

View File

@ -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<any[]>} Promise resolved with the offline attempts.
*/
getAllAttempts(siteId?: string): Promise<any[]> {
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<any[]>} Promise resolved with the answers.
*/
getAttemptAnswers(attemptId: number, siteId?: string): Promise<any[]> {
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<any>} Promise resolved with the attempt.
*/
getAttemptById(attemptId: number, siteId?: string): Promise<any> {
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<any[]>} Promise resolved with the attempts.
*/
getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise<any[]> {
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<any>} Promise resolved when done.
*/
loadQuestionsLocalStates(attemptId: number, questions: any[], siteId?: string): Promise<any[]> {
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<any>} Promise resolved in success, rejected otherwise.
*/
processAttempt(quiz: any, attempt: any, questions: any, data: any, finish?: boolean, siteId?: string): Promise<any> {
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<any>} Promise resolved when done.
*/
removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise<any> {
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<any>} Promise resolved when finished.
*/
removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise<any> {
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<any>} Promise resolved when done.
*/
saveAnswers(quiz: any, attempt: any, questions: any, answers: any, timeMod?: number, siteId?: string): Promise<any> {
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<any>} Promise resolved in success, rejected otherwise.
*/
setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSiteDb(siteId).then((db) => {
return db.updateRecords(this.ATTEMPTS_TABLE, {currentpage: page}, {id: attemptId});
});
}
}

View File

@ -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<any>} Promise resolved with the attempt summary.
* @return {Promise<any[]>} 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<any> {
siteId?: string): Promise<any[]> {
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<boolean>} Promise resolved with boolean: true if finished in offline but not synced, false otherwise.
*/
isAttemptFinishedOffline(attemptId: number, siteId?: string): Promise<boolean> {
// @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<boolean>} Promise resolved with boolean: true if last offline attempt is unfinished, false otherwise.
*/
isLastAttemptOfflineUnfinished(quiz: any, siteId?: string, userId?: number): Promise<boolean> {
// @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<any>} 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<any> {
// @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<any>} Promise resolved in success, rejected otherwise.
*/
protected processAttemptOnline(attemptId: number, data: any, preflightData: any, finish?: boolean, timeUp?: boolean,
siteId?: string): Promise<any> {
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<any>} 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<any> {
// 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<any> {
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<void>} Promise resolved in success, rejected otherwise.
*/
protected saveAttemptOnline(attemptId: number, data: any, preflightData: any, siteId?: string): Promise<void> {
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;
}

View File

@ -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 { }