diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c853aadeb..d54c324a9 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -67,6 +67,7 @@ import { CoreGradesModule } from '@core/grades/grades.module'; import { CoreSettingsModule } from '@core/settings/settings.module'; import { CoreSitePluginsModule } from '@core/siteplugins/siteplugins.module'; import { CoreCompileModule } from '@core/compile/compile.module'; +import { CoreQuestionModule } from '@core/question/question.module'; // Addon modules. import { AddonBadgesModule } from '@addon/badges/badges.module'; @@ -155,6 +156,7 @@ export const CORE_PROVIDERS: any[] = [ CoreSettingsModule, CoreSitePluginsModule, CoreCompileModule, + CoreQuestionModule, AddonBadgesModule, AddonCalendarModule, AddonCompetencyModule, diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts index d864d999f..de77dd370 100644 --- a/src/core/compile/providers/compile.ts +++ b/src/core/compile/providers/compile.ts @@ -29,6 +29,7 @@ import { CORE_FILEUPLOADER_PROVIDERS } from '@core/fileuploader/fileuploader.mod import { CORE_GRADES_PROVIDERS } from '@core/grades/grades.module'; import { CORE_LOGIN_PROVIDERS } from '@core/login/login.module'; import { CORE_MAINMENU_PROVIDERS } from '@core/mainmenu/mainmenu.module'; +import { CORE_QUESTION_PROVIDERS } from '@core/question/question.module'; import { CORE_SHAREDFILES_PROVIDERS } from '@core/sharedfiles/sharedfiles.module'; import { CORE_SITEHOME_PROVIDERS } from '@core/sitehome/sitehome.module'; import { CORE_USER_PROVIDERS } from '@core/user/user.module'; @@ -164,7 +165,7 @@ export class CoreCompileProvider { .concat(CORE_COURSES_PROVIDERS).concat(CORE_FILEUPLOADER_PROVIDERS).concat(CORE_GRADES_PROVIDERS) .concat(CORE_LOGIN_PROVIDERS).concat(CORE_MAINMENU_PROVIDERS).concat(CORE_SHAREDFILES_PROVIDERS) .concat(CORE_SITEHOME_PROVIDERS).concat([CoreSitePluginsProvider]).concat(CORE_USER_PROVIDERS) - .concat(IONIC_NATIVE_PROVIDERS).concat(this.OTHER_PROVIDERS); + .concat(CORE_QUESTION_PROVIDERS).concat(IONIC_NATIVE_PROVIDERS).concat(this.OTHER_PROVIDERS); // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in providers) { diff --git a/src/core/question/lang/en.json b/src/core/question/lang/en.json new file mode 100644 index 000000000..fa363ebfd --- /dev/null +++ b/src/core/question/lang/en.json @@ -0,0 +1,22 @@ +{ + "answer": "Answer", + "answersaved": "Answer saved", + "certainty": "Certainty", + "complete": "Complete", + "correct": "Correct", + "errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", + "errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", + "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", + "feedback": "Feedback", + "howtodraganddrop": "Tap to select then tap to drop.", + "incorrect": "Incorrect", + "information": "Information", + "invalidanswer": "Incomplete answer", + "notanswered": "Not answered", + "notyetanswered": "Not yet answered", + "partiallycorrect": "Partially correct", + "questionmessage": "Question {{$a}}: {{$b}}", + "questionno": "Question {{$a}}", + "requiresgrading": "Requires grading", + "unknown": "Cannot determine status" +} \ No newline at end of file diff --git a/src/core/question/providers/question.ts b/src/core/question/providers/question.ts new file mode 100644 index 000000000..9dd4aa95a --- /dev/null +++ b/src/core/question/providers/question.ts @@ -0,0 +1,613 @@ +// (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 { + // 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: 'state', + type: 'TEXT' + }, + { + 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 + }, + unknown: { // Special state for Mobile, sometimes we won't have enough data to detemrine the state. + name: 'unknown', + class: 'core-question-unknown', + status: 'unknown', + 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, attemptId, 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, 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, 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, attemptId, 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, attemptId, 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 || 'unknown']; + } + + /** + * 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, 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, 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, attemptId, 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, attemptId, 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, attemptId, 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); + }); + } +} diff --git a/src/core/question/question.module.ts b/src/core/question/question.module.ts new file mode 100644 index 000000000..6731e2f27 --- /dev/null +++ b/src/core/question/question.module.ts @@ -0,0 +1,30 @@ +// (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 { NgModule } from '@angular/core'; +import { CoreQuestionProvider } from './providers/question'; + +// List of providers. +export const CORE_QUESTION_PROVIDERS = [ + CoreQuestionProvider +]; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: CORE_QUESTION_PROVIDERS, + exports: [] +}) +export class CoreQuestionModule {}