// (C) Copyright 2015 Moodle Pty Ltd. // // 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 { CoreQuestionBehaviourDelegate, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; import { CoreQuestionAnswerDBRecord } from '@features/question/services/database/question'; import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; import { CoreSites } from '@services/sites'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, Translate } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { AddonModQuizAttemptDBRecord, ATTEMPTS_TABLE_NAME } from './database/quiz'; import { AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz'; /** * Service to handle offline quiz. */ @Injectable({ providedIn: 'root' }) export class AddonModQuizOfflineProvider { protected logger: CoreLogger; constructor() { this.logger = CoreLogger.getInstance('AddonModQuizOfflineProvider'); } /** * Classify the answers in questions. * * @param answers List of answers. * @return Object with the questions, the keys are the slot. Each question contains its answers. */ classifyAnswersInQuestions(answers: CoreQuestionsAnswers): AddonModQuizQuestionsWithAnswers { const questionsWithAnswers: AddonModQuizQuestionsWithAnswers = {}; // Classify the answers in each question. for (const name in answers) { const slot = CoreQuestion.instance.getQuestionSlotFromName(name); const nameWithoutPrefix = CoreQuestion.instance.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, returns a list of answers (including prefix in the name). * * @param questions Questions. * @return Answers. */ extractAnswersFromQuestions(questions: AddonModQuizQuestionsWithAnswers): CoreQuestionsAnswers { const answers: CoreQuestionsAnswers = {}; 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 siteId Site ID. If not defined, current site. * @return Promise resolved with the offline attempts. */ async getAllAttempts(siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); return db.getAllRecords(ATTEMPTS_TABLE_NAME); } /** * Retrieve an attempt answers from site DB. * * @param attemptId Attempt ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the answers. */ getAttemptAnswers(attemptId: number, siteId?: string): Promise { return CoreQuestion.instance.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId); } /** * Retrieve an attempt from site DB. * * @param attemptId Attempt ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the attempt. */ async getAttemptById(attemptId: number, siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); return db.getRecord(ATTEMPTS_TABLE_NAME, { id: attemptId }); } /** * Retrieve an attempt from site DB. * * @param attemptId Attempt ID. * @param siteId Site ID. If not defined, current site. * @param userId User ID. If not defined, user current site's user. * @return Promise resolved with the attempts. */ async getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise { const site = await CoreSites.instance.getSite(siteId); return site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { quizid: quizId, userid: userId || site.getUserId() }); } /** * Load local state in the questions. * * @param attemptId Attempt ID. * @param questions List of questions. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ async loadQuestionsLocalStates( attemptId: number, questions: CoreQuestionQuestionParsed[], siteId?: string, ): Promise { await Promise.all(questions.map(async (question) => { const dbQuestion = await CoreUtils.instance.ignoreErrors( CoreQuestion.instance.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId), ); if (!dbQuestion) { // Question not found. return; } const state = CoreQuestion.instance.getState(dbQuestion.state); question.state = dbQuestion.state; question.status = Translate.instance.instant('core.question.' + state.status); })); return questions; } /** * Process an attempt, saving its data. * * @param quiz Quiz. * @param attempt Attempt. * @param questions Object with the questions of the quiz. The keys should be the question slot. * @param data Data to save. * @param finish Whether to finish the quiz. * @param siteId Site ID. If not defined, current site. * @return Promise resolved in success, rejected otherwise. */ async processAttempt( quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData, questions: Record, data: CoreQuestionsAnswers, finish?: boolean, siteId?: string, ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); const now = CoreTimeUtils.instance.timestamp(); const db = await CoreSites.instance.getSiteDb(siteId); // Check if an attempt already exists. Return a new one if it doesn't. let entry = await CoreUtils.instance.ignoreErrors(this.getAttemptById(attempt.id, siteId)); if (entry) { entry.timemodified = now; entry.finished = finish ? 1 : 0; } else { entry = { quizid: quiz.id, userid: attempt.userid!, id: attempt.id, courseid: quiz.course, timecreated: now, attempt: attempt.attempt!, currentpage: attempt.currentpage, timemodified: now, finished: finish ? 1 : 0, }; } // Save attempt in DB. await db.insertRecord(ATTEMPTS_TABLE_NAME, entry); // Attempt has been saved, now we need to save the answers. await this.saveAnswers(quiz, attempt, questions, data, now, siteId); } /** * Remove an attempt and its answers from local DB. * * @param attemptId Attempt ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ async removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); const db = await CoreSites.instance.getSiteDb(siteId); await Promise.all([ CoreQuestion.instance.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId), CoreQuestion.instance.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId), db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }), ]); } /** * Remove a question and its answers from local DB. * * @param attemptId Attempt ID. * @param slot Question slot. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when finished. */ async removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); await Promise.all([ CoreQuestion.instance.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), ]); } /** * Save an attempt's answers and calculate state for questions modified. * * @param quiz Quiz. * @param attempt Attempt. * @param questions Object with the questions of the quiz. The keys should be the question slot. * @param answers Answers to save. * @param timeMod Time modified to set in the answers. If not defined, current time. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ async saveAnswers( quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData, questions: Record, answers: CoreQuestionsAnswers, timeMod?: number, siteId?: string, ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); timeMod = timeMod || CoreTimeUtils.instance.timestamp(); const questionsWithAnswers: Record = {}; const newStates: Record = {}; // Classify the answers in each question. for (const name in answers) { const slot = CoreQuestion.instance.getQuestionSlotFromName(name); const nameWithoutPrefix = CoreQuestion.instance.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. await Promise.all(Object.values(questionsWithAnswers).map(async (question) => { const state = await CoreQuestionBehaviourDelegate.instance.determineNewState( quiz.preferredbehaviour!, AddonModQuizProvider.COMPONENT, attempt.id, question, quiz.coursemodule, siteId, ); // Check if state has changed. if (state && state.name != question.state) { newStates[question.slot] = state.name; } // Delete previously stored answers for this question. await CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId); })); // Now save the answers. await CoreQuestion.instance.saveAnswers( AddonModQuizProvider.COMPONENT, quiz.id, attempt.id, attempt.userid!, answers, timeMod, siteId, ); try { // Answers have been saved, now we can save the questions with the states. await CoreUtils.instance.allPromises(Object.keys(newStates).map(async (slot) => { const question = questionsWithAnswers[Number(slot)]; await CoreQuestion.instance.saveQuestion( AddonModQuizProvider.COMPONENT, quiz.id, attempt.id, attempt.userid!, question, newStates[slot], siteId, ); })); } catch (error) { // Ignore errors when saving question state. this.logger.error('Error saving question state', error); } } /** * Set attempt's current page. * * @param attemptId Attempt ID. * @param page Page to set. * @param siteId Site ID. If not defined, current site. * @return Promise resolved in success, rejected otherwise. */ async setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); await db.updateRecords(ATTEMPTS_TABLE_NAME, { currentpage: page }, { id: attemptId }); } } export class AddonModQuizOffline extends makeSingleton(AddonModQuizOfflineProvider) {} /** * Answers classified by question slot. */ export type AddonModQuizQuestionsWithAnswers = Record;