// (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 { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; /** * An object to represent a question state. */ export interface CoreQuestionState { /** * Name of the state. * @type {string} */ name: string; /** * Class of the state. * @type {string} */ class: string; /** * The string key to translate the status. * @type {string} */ status: string; /** * Whether the question with this state is active. * @type {boolean} */ active: boolean; /** * Whether the question with this state is finished. * @type {boolean} */ finished: boolean; } /** * Service to handle questions. */ @Injectable() export class CoreQuestionProvider { static COMPONENT = 'mmQuestion'; // Variables for database. protected QUESTION_TABLE = 'questions'; protected QUESTION_ANSWERS_TABLE = 'question_answers'; protected tablesSchema = [ { name: this.QUESTION_TABLE, columns: [ { name: 'component', type: 'TEXT', notNull: true }, { name: 'attemptid', type: 'INTEGER', notNull: true }, { name: 'slot', type: 'INTEGER', notNull: true }, { name: 'componentid', type: 'INTEGER' }, { name: 'userid', type: 'INTEGER' }, { name: 'number', type: 'INTEGER' }, { name: 'state', type: 'TEXT' } ], primaryKeys: ['component', 'attemptid', 'slot'] }, { name: this.QUESTION_ANSWERS_TABLE, columns: [ { name: 'component', type: 'TEXT', notNull: true }, { name: 'attemptid', type: 'INTEGER', notNull: true }, { name: 'name', type: 'TEXT', notNull: true }, { name: 'componentid', type: 'INTEGER' }, { name: 'userid', type: 'INTEGER' }, { name: 'questionslot', type: 'INTEGER' }, { name: 'value', type: 'TEXT' }, { name: 'timemodified', type: 'INTEGER' } ], primaryKeys: ['component', 'attemptid', 'name'] } ]; protected QUESTION_PREFIX_REGEX = /q\d+:(\d+)_/; protected STATES: {[name: string]: CoreQuestionState} = { todo: { name: 'todo', class: 'core-question-notyetanswered', status: 'notyetanswered', active: true, finished: false }, invalid: { name: 'invalid', class: 'core-question-invalidanswer', status: 'invalidanswer', active: true, finished: false }, complete: { name: 'complete', class: 'core-question-answersaved', status: 'answersaved', active: true, finished: false }, needsgrading: { name: 'needsgrading', class: 'core-question-requiresgrading', status: 'requiresgrading', active: false, finished: true }, finished: { name: 'finished', class: 'core-question-complete', status: 'complete', active: false, finished: true }, gaveup: { name: 'gaveup', class: 'core-question-notanswered', status: 'notanswered', active: false, finished: true }, gradedwrong: { name: 'gradedwrong', class: 'core-question-incorrect', status: 'incorrect', active: false, finished: true }, gradedpartial: { name: 'gradedpartial', class: 'core-question-partiallycorrect', status: 'partiallycorrect', active: false, finished: true }, gradedright: { name: 'gradedright', class: 'core-question-correct', status: 'correct', active: false, finished: true }, mangrwrong: { name: 'mangrwrong', class: 'core-question-incorrect', status: 'incorrect', active: false, finished: true }, mangrpartial: { name: 'mangrpartial', class: 'core-question-partiallycorrect', status: 'partiallycorrect', active: false, finished: true }, mangrright: { name: 'mangrright', class: 'core-question-correct', status: 'correct', active: false, finished: true }, cannotdeterminestatus: { // Special state for Mobile, sometimes we won't have enough data to detemrine the state. name: 'cannotdeterminestatus', class: 'core-question-unknown', status: 'cannotdeterminestatus', active: true, finished: false } }; protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('CoreQuestionProvider'); this.sitesProvider.createTablesFromSchema(this.tablesSchema); } /** * Compare that all the answers in two objects are equal, except some extra data like sequencecheck or certainty. * * @param {any} prevAnswers Object with previous answers. * @param {any} newAnswers Object with new answers. * @return {boolean} Whether all answers are equal. */ compareAllAnswers(prevAnswers: any, newAnswers: any): boolean { // Get all the keys. const keys = this.utils.mergeArraysWithoutDuplicates(Object.keys(prevAnswers), Object.keys(newAnswers)); // Check that all the keys have the same value on both objects. for (const i in keys) { const key = keys[i]; // Ignore extra answers like sequencecheck or certainty. if (!this.isExtraAnswer(key[0])) { if (!this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, key)) { return false; } } } return true; } /** * Convert a list of answers retrieved from local DB to an object with name - value. * * @param {any[]} answers List of answers. * @param {boolean} [removePrefix] Whether to remove the prefix in the answer's name. * @return {any} Object with name -> value. */ convertAnswersArrayToObject(answers: any[], removePrefix?: boolean): any { const result = {}; answers.forEach((answer) => { if (removePrefix) { const nameWithoutPrefix = this.removeQuestionPrefix(answer.name); result[nameWithoutPrefix] = answer.value; } else { result[answer.name] = answer.value; } }); return result; } /** * Retrieve an answer from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} name Answer's name. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the answer. */ getAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecord(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId, name: name}); }); } /** * Retrieve an attempt answers from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the answers. */ getAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecords(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId}); }); } /** * Retrieve an attempt questions from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the questions. */ getAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecords(this.QUESTION_TABLE, {component: component, attemptid: attemptId}); }); } /** * Get all the answers that aren't "extra" (sequencecheck, certainty, ...). * * @param {any} answers Object with all the answers. * @return {any} Object with the basic answers. */ getBasicAnswers(answers: any): any { const result = {}; for (const name in answers) { if (!this.isExtraAnswer(name)) { result[name] = answers[name]; } } return result; } /** * Get all the answers that aren't "extra" (sequencecheck, certainty, ...). * * @param {any[]} answers List of answers. * @return {any[]} List with the basic answers. */ getBasicAnswersFromArray(answers: any[]): any[] { const result = []; answers.forEach((answer) => { if (this.isExtraAnswer(answer.name)) { result.push(answer); } }); return result; } /** * Retrieve a question from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} slot Question slot. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the question. */ getQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecord(this.QUESTION_TABLE, {component: component, attemptid: attemptId, slot: slot}); }); } /** * Retrieve a question answers from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} slot Question slot. * @param {boolean} [filter] Whether it should ignore "extra" answers like sequencecheck or certainty. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the answers. */ getQuestionAnswers(component: string, attemptId: number, slot: number, filter?: boolean, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecords(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId, questionslot: slot}).then((answers) => { if (filter) { // Get only answers that isn't "extra" data like sequencecheck or certainty. return this.getBasicAnswersFromArray(answers); } else { return answers; } }); }); } /** * Extract the question slot from a question name. * * @param {string} name Question name. * @return {number} Question slot. */ getQuestionSlotFromName(name: string): number { if (name) { const match = name.match(this.QUESTION_PREFIX_REGEX); if (match && match[1]) { return parseInt(match[1], 10); } } return -1; } /** * Get question state based on state name. * * @param {string} name State name. * @return {CoreQuestionState} State. */ getState(name: string): CoreQuestionState { return this.STATES[name || 'cannotdeterminestatus']; } /** * Check if an answer is extra data like sequencecheck or certainty. * * @param {string} name Answer name. * @return {boolean} Whether it's extra data. */ isExtraAnswer(name: string): boolean { // Maybe the name still has the prefix. name = this.removeQuestionPrefix(name); return name[0] == '-' || name[0] == ':'; } /** * Remove an attempt answers from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ removeAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId}); }); } /** * Remove an attempt questions from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ removeAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(this.QUESTION_TABLE, {component: component, attemptid: attemptId}); }); } /** * Remove an answer from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} name Answer's name. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ removeAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId, name: name}); }); } /** * Remove a question from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} slot Question slot. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ removeQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(this.QUESTION_TABLE, {component: component, attemptid: attemptId, slot: slot}); }); } /** * Remove a question answers from site DB. * * @param {string} component Component the attempt belongs to. * @param {number} attemptId Attempt ID. * @param {string} slot Question slot. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ removeQuestionAnswers(component: string, attemptId: number, slot: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().deleteRecords(this.QUESTION_ANSWERS_TABLE, {component: component, attemptid: attemptId, questionslot: slot}); }); } /** * Remove the prefix from a question answer name. * * @param {string} name Question name. * @return {string} Name without prefix. */ removeQuestionPrefix(name: string): string { if (name) { return name.replace(this.QUESTION_PREFIX_REGEX, ''); } return ''; } /** * Save answers in local DB. * * @param {string} component Component the answers belong to. E.g. 'mmaModQuiz'. * @param {number} componentId ID of the component the answers belong to. * @param {number} attemptId Attempt ID. * @param {number} userId User ID. * @param {any} answers Object with the answers to save. * @param {number} [timemodified] 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(component: string, componentId: number, attemptId: number, userId: number, answers: any, timemodified?: number, siteId?: string): Promise { timemodified = timemodified || this.timeUtils.timestamp(); return this.sitesProvider.getSite(siteId).then((site) => { const db = site.getDb(), promises = []; for (const name in answers) { const value = answers[name], entry = { component: component, componentid: componentId, attemptid: attemptId, userid: userId, questionslot: this.getQuestionSlotFromName(name), name: name, value: value, timemodified: timemodified }; promises.push(db.insertRecord(this.QUESTION_ANSWERS_TABLE, entry)); } return Promise.all(promises); }); } /** * Save a question in local DB. * * @param {string} component Component the question belongs to. E.g. 'mmaModQuiz'. * @param {number} componentId ID of the component the question belongs to. * @param {number} attemptId Attempt ID. * @param {number} userId User ID. * @param {any} question The question to save. * @param {string} state Question's state. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ saveQuestion(component: string, componentId: number, attemptId: number, userId: number, question: any, state: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const entry = { component: component, componentid: componentId, attemptid: attemptId, userid: userId, number: question.number, slot: question.slot, state: state }; return site.getDb().insertRecord(this.QUESTION_TABLE, entry); }); } }