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..bbd988366 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'; @@ -66,6 +67,7 @@ import { CoreCoursesComponentsModule } from '@core/courses/components/components import { CoreSitePluginsDirectivesModule } from '@core/siteplugins/directives/directives.module'; import { CoreSiteHomeComponentsModule } from '@core/sitehome/components/components.module'; import { CoreUserComponentsModule } from '@core/user/components/components.module'; +import { CoreQuestionComponentsModule } from '@core/question/components/components.module'; // Import some components listed in entryComponents so they can be injected dynamically. import { CoreCourseUnsupportedModuleComponent } from '@core/course/components/unsupported-module/unsupported-module'; @@ -92,7 +94,7 @@ export class CoreCompileProvider { protected IMPORTS = [ IonicModule, TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, CorePipesModule, CoreCourseComponentsModule, CoreCoursesComponentsModule, CoreSiteHomeComponentsModule, CoreUserComponentsModule, - CoreCourseDirectivesModule, CoreSitePluginsDirectivesModule + CoreCourseDirectivesModule, CoreSitePluginsDirectivesModule, CoreQuestionComponentsModule ]; constructor(protected injector: Injector, logger: CoreLoggerProvider, protected compiler: Compiler) { @@ -164,7 +166,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/course/providers/default-format.ts b/src/core/course/providers/default-format.ts index c8e596d22..7c9ad4648 100644 --- a/src/core/course/providers/default-format.ts +++ b/src/core/course/providers/default-format.ts @@ -43,7 +43,7 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { * @param {any} course The course. * @return {string} Title. */ - getCourseTitle?(course: any): string { + getCourseTitle(course: any): string { return course.fullname || ''; } diff --git a/src/core/question/components/components.module.ts b/src/core/question/components/components.module.ts new file mode 100644 index 000000000..77d33aa74 --- /dev/null +++ b/src/core/question/components/components.module.ts @@ -0,0 +1,40 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreQuestionComponent } from './question/question'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + CoreQuestionComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + ], + exports: [ + CoreQuestionComponent + ] +}) +export class CoreQuestionComponentsModule {} diff --git a/src/core/question/components/question/question.html b/src/core/question/components/question/question.html new file mode 100644 index 000000000..33fc72525 --- /dev/null +++ b/src/core/question/components/question/question.html @@ -0,0 +1,32 @@ + + + + +

{{ 'core.question.errorquestionnotsupported' | translate:{$a: question.type} }}

+
+ + + + + + + + + +

{{ question.validationError }}

+
+ + + + {{ button.value }} + + + + +

+
+ + + +

+
diff --git a/src/core/question/components/question/question.ts b/src/core/question/components/question/question.ts new file mode 100644 index 000000000..ddfb7ecea --- /dev/null +++ b/src/core/question/components/question/question.ts @@ -0,0 +1,149 @@ +// (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 { Component, Input, Output, OnInit, Injector, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreQuestionProvider } from '../../providers/question'; +import { CoreQuestionDelegate } from '../../providers/delegate'; +import { CoreQuestionBehaviourDelegate } from '../../providers/behaviour-delegate'; +import { CoreQuestionHelperProvider } from '../../providers/helper'; + +/** + * Component to render a question. + */ +@Component({ + selector: 'core-question', + templateUrl: 'question.html' +}) +export class CoreQuestionComponent implements OnInit { + @Input() question: any; // The question to render. + @Input() component: string; // The component the question belongs to. + @Input() componentId: number; // ID of the component the question belongs to. + @Input() attemptId: number; // Attempt ID. + @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. + @Output() buttonClicked: EventEmitter; // Will emit an event when a behaviour button is clicked. + @Output() onAbort: EventEmitter; // Will emit an event if the question should be aborted. + + componentClass: any; // The class of the component to render. + data: any = {}; // Data to pass to the component. + seqCheck: {name: string, value: string}; // Sequenche check name and value (if any). + behaviourComponents: any[] = []; // Components to render the question behaviour. + loaded = false; + + protected logger; + + constructor(logger: CoreLoggerProvider, protected injector: Injector, protected questionDelegate: CoreQuestionDelegate, + protected utils: CoreUtilsProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, + protected questionHelper: CoreQuestionHelperProvider, protected translate: TranslateService, + protected questionProvider: CoreQuestionProvider, protected domUtils: CoreDomUtilsProvider) { + logger = logger.getInstance('CoreQuestionComponent'); + + this.buttonClicked = new EventEmitter(); + this.onAbort = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.offlineEnabled = this.utils.isTrueOrOne(this.offlineEnabled); + + if (!this.question) { + this.loaded = true; + + return; + } + + // Get the component to render the question. + this.questionDelegate.getComponentForQuestion(this.injector, this.question).then((componentClass) => { + this.componentClass = componentClass; + + if (componentClass) { + // Set up the data needed by the question and behaviour components. + this.data = { + question: this.question, + component: this.component, + componentId: this.componentId, + attemptId: this.attemptId, + offlineEnabled: this.offlineEnabled, + buttonClicked: this.buttonClicked, + onAbort: this.onAbort + }; + + // Treat the question. + this.questionHelper.extractQuestionScripts(this.question); + + // Handle question behaviour. + const behaviour = this.questionDelegate.getBehaviourForQuestion(this.question, this.question.preferredBehaviour); + if (!this.behaviourDelegate.isBehaviourSupported(behaviour)) { + // Behaviour not supported, abort. + this.logger.warn('Aborting question because the behaviour is not supported.', this.question.name); + this.questionHelper.showComponentError(this.onAbort, + this.translate.instant('addon.mod_quiz.errorbehaviournotsupported') + ' ' + behaviour); + + return; + } + + // Get the sequence check (hidden input). This is required. + this.seqCheck = this.questionHelper.getQuestionSequenceCheckFromHtml(this.question.html); + if (!this.seqCheck) { + this.logger.warn('Aborting question because couldn\'t retrieve sequence check.', this.question.name); + this.questionHelper.showComponentError(this.onAbort); + + return; + } + + // Load local answers if offline is enabled. + let promise; + if (this.offlineEnabled) { + promise = this.questionProvider.getQuestionAnswers(this.component, this.attemptId, this.question.slot) + .then((answers) => { + this.question.localAnswers = this.questionProvider.convertAnswersArrayToObject(answers, true); + }).catch(() => { + this.question.localAnswers = {}; + }); + } else { + this.question.localAnswers = {}; + promise = Promise.resolve(); + } + + promise.then(() => { + // Handle behaviour. + this.behaviourDelegate.handleQuestion(this.question, this.question.preferredBehaviour).then((comps) => { + this.behaviourComponents = comps; + }); + this.questionHelper.extractQbehaviourRedoButton(this.question); + this.question.html = this.domUtils.removeElementFromHtml(this.question.html, '.im-controls'); + + // Extract the validation error of the question. + this.question.validationError = this.questionHelper.getValidationErrorFromHtml(this.question.html); + + // Load the local answers in the HTML. + this.questionHelper.loadLocalAnswersInHtml(this.question); + + // Try to extract the feedback and comment for the question. + this.questionHelper.extractQuestionFeedback(this.question); + this.questionHelper.extractQuestionComment(this.question); + + this.loaded = true; + }); + } + }).catch(() => { + // Ignore errors. + }); + } +} 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/behaviour-delegate.ts b/src/core/question/providers/behaviour-delegate.ts new file mode 100644 index 000000000..3ec8e6249 --- /dev/null +++ b/src/core/question/providers/behaviour-delegate.ts @@ -0,0 +1,112 @@ +// (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 { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreQuestionState } from './question'; +import { CoreQuestionDelegate } from './delegate'; +import { CoreQuestionBehaviourDefaultHandler } from './default-behaviour-handler'; + +/** + * Interface that all question behaviour handlers must implement. + */ +export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler { + /** + * Type of the behaviour the handler supports. E.g. 'adaptive'. + * @type {string} + */ + type: string; + + /** + * Determine a question new state based on its answer(s). + * + * @param {string} component Component the question belongs to. + * @param {number} attemptId Attempt ID the question belongs to. + * @param {any} question The question. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {CoreQuestionState|Promise} State (or promise resolved with state). + */ + determineNewState?(component: string, attemptId: number, question: any, siteId?: string) + : CoreQuestionState | Promise; + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return the components to render it. + * + * @param {any} question The question. + * @return {any[]|Promise} Components (or promise resolved with components) to render some extra data in the question + * (e.g. certainty options). Don't return anything if no extra data is required. + */ + handleQuestion?(question: any): any[] | Promise; +} + +/** + * Delegate to register question behaviour handlers. + */ +@Injectable() +export class CoreQuestionBehaviourDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected questionDelegate: CoreQuestionDelegate, protected defaultHandler: CoreQuestionBehaviourDefaultHandler) { + super('CoreQuestionBehaviourDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Determine a question new state based on its answer(s). + * + * @param {string} component Component the question belongs to. + * @param {number} attemptId Attempt ID the question belongs to. + * @param {any} question The question. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with state. + */ + determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string) + : Promise { + behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); + + return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState', + [component, attemptId, question, siteId])); + } + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return a directive to render it. + * + * @param {string} behaviour Default behaviour. + * @param {any} question The question. + * @return {Promise} Promise resolved with components to render some extra data in the question. + */ + handleQuestion(behaviour: string, question: any): Promise { + behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); + + return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'handleQuestion', [question])); + } + + /** + * Check if a question behaviour is supported. + * + * @param {string} behaviour Name of the behaviour. + * @return {boolean} Whether it's supported. + */ + isBehaviourSupported(behaviour: string): boolean { + return this.hasHandler(behaviour, true); + } +} diff --git a/src/core/question/providers/default-behaviour-handler.ts b/src/core/question/providers/default-behaviour-handler.ts new file mode 100644 index 000000000..1eb790924 --- /dev/null +++ b/src/core/question/providers/default-behaviour-handler.ts @@ -0,0 +1,66 @@ +// (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 { CoreQuestionBehaviourHandler } from './behaviour-delegate'; +import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; + +/** + * Default handler used when the question behaviour doesn't have a specific implementation. + */ +@Injectable() +export class CoreQuestionBehaviourDefaultHandler implements CoreQuestionBehaviourHandler { + name = 'CoreQuestionBehaviourDefault'; + type = 'default'; + + constructor(private questionProvider: CoreQuestionProvider) { } + + /** + * Determine a question new state based on its answer(s). + * + * @param {string} component Component the question belongs to. + * @param {number} attemptId Attempt ID the question belongs to. + * @param {any} question The question. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {CoreQuestionState|Promise} New state (or promise resolved with state). + */ + determineNewState(component: string, attemptId: number, question: any, siteId?: string) + : CoreQuestionState | Promise { + // Return the current state. + return this.questionProvider.getState(question.state); + } + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return the components to render it. + * + * @param {any} question The question. + * @return {any[]|Promise} Components (or promise resolved with components) to render some extra data in the question + * (e.g. certainty options). Don't return anything if no extra data is required. + */ + handleQuestion(question: any): any[] | Promise { + // Nothing to do. + return; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/core/question/providers/default-question-handler.ts b/src/core/question/providers/default-question-handler.ts new file mode 100644 index 000000000..e6688eaa0 --- /dev/null +++ b/src/core/question/providers/default-question-handler.ts @@ -0,0 +1,134 @@ +// (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, Injector } from '@angular/core'; +import { CoreQuestionHandler } from './delegate'; + +/** + * Default handler used when the question type doesn't have a specific implementation. + */ +@Injectable() +export class CoreQuestionDefaultHandler implements CoreQuestionHandler { + name = 'CoreQuestionDefault'; + type = 'default'; + + constructor() { + // Nothing to do. + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise { + // There is no default component for questions. + } + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour(question: any, behaviour: string): string { + return behaviour; + } + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param {any} question The question. + * @return {string} Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage(question: any): string { + // Never prevent by default. + return ''; + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + return -1; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + return -1; + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + return false; + } + + /** + * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * + * @param {any} question Question. + * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param {boolean} [offline] Whether the data should be saved in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} Return a promise resolved when done if async, void if sync. + */ + prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise { + // Nothing to do. + } + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * This function only needs to be implemented if a specific compare is required. + * + * @param {any} question The question. + * @param {string} offlineSequenceCheck Sequence check stored in offline. + * @return {boolean} Whether sequencecheck is valid. + */ + validateSequenceCheck(question: any, offlineSequenceCheck: string): boolean { + return question.sequencecheck == offlineSequenceCheck; + } +} diff --git a/src/core/question/providers/delegate.ts b/src/core/question/providers/delegate.ts new file mode 100644 index 000000000..54637e90c --- /dev/null +++ b/src/core/question/providers/delegate.ts @@ -0,0 +1,264 @@ +// (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, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreQuestionDefaultHandler } from './default-question-handler'; + +/** + * Interface that all question type handlers must implement. + */ +export interface CoreQuestionHandler extends CoreDelegateHandler { + /** + * Type of the question the handler supports. E.g. 'qtype_calculated'. + * @type {string} + */ + type: string; + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise; + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour?(question: any, behaviour: string): string; + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param {any} question The question. + * @return {string} Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage?(question: any): string; + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse?(question: any, answers: any): number; + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse?(question: any, answers: any): number; + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean; + + /** + * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * + * @param {any} question Question. + * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param {boolean} [offline] Whether the data should be saved in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} Return a promise resolved when done if async, void if sync. + */ + prepareAnswers?(question: any, answers: any, offline: boolean, siteId?: string): void | Promise; + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * This function only needs to be implemented if a specific compare is required. + * + * @param {any} question The question. + * @param {string} offlineSequenceCheck Sequence check stored in offline. + * @return {boolean} Whether sequencecheck is valid. + */ + validateSequenceCheck?(question: any, offlineSequenceCheck: string): boolean; +} + +/** + * Delegate to register question handlers. + */ +@Injectable() +export class CoreQuestionDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected defaultHandler: CoreQuestionDefaultHandler) { + super('CoreQuestionDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Get the behaviour to use for a certain question type. + * E.g. 'qtype_essay' uses 'manualgraded'. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviourForQuestion(question: any, behaviour: string): string { + const type = this.getTypeName(question), + questionBehaviour = this.executeFunctionOnEnabled(type, 'getBehaviour', [question, behaviour]); + + return questionBehaviour || behaviour; + } + + /** + * Get the directive to use for a certain question type. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {Promise} Promise resolved with component to use, undefined if not found. + */ + getComponentForQuestion(injector: Injector, question: any): Promise { + const type = this.getTypeName(question); + + return Promise.resolve(this.executeFunctionOnEnabled(type, 'getComponent', [injector, question])); + } + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param {any} question Question. + * @return {string} Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage(question: any): string { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'getPreventSubmitMessage', [question]); + } + + /** + * Given a type name, return the full name of that type. E.g. 'calculated' -> 'qtype_calculated'. + * + * @param {string} type Type to treat. + * @return {string} Type full name. + */ + protected getFullTypeName(type: string): string { + return 'qtype_' + type; + } + + /** + * Given a question, return the full name of its question type. + * + * @param {any} question Question. + * @return {string} Type name. + */ + protected getTypeName(question: any): string { + return this.getFullTypeName(question.type); + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]); + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]); + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]); + } + + /** + * Check if a question type is supported. + * + * @param {string} type Question type. + * @return {boolean} Whether it's supported. + */ + isQuestionSupported(type: string): boolean { + return this.hasHandler(this.getFullTypeName(type), true); + } + + /** + * Prepare the answers for a certain question. + * + * @param {any} question Question. + * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param {boolean} [offline] Whether the data should be saved in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data has been prepared. + */ + prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise { + const type = this.getTypeName(question); + + return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId])); + } + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * + * @param {any} question The question. + * @param {string} offlineSequenceCheck Sequence check stored in offline. + * @return {boolean} Whether sequencecheck is valid. + */ + validateSequenceCheck(question: any, offlineSequenceCheck: string): boolean { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'validateSequenceCheck', [question, offlineSequenceCheck]); + } +} diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts new file mode 100644 index 000000000..5216fb5fd --- /dev/null +++ b/src/core/question/providers/helper.ts @@ -0,0 +1,306 @@ +// (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, EventEmitter } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreQuestionProvider } from './question'; + +/** + * Service with some common functions to handle questions. + */ +@Injectable() +export class CoreQuestionHelperProvider { + protected lastErrorShown = 0; + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, + private questionProvider: CoreQuestionProvider) { } + + /** + * Add a behaviour button to the question's "behaviourButtons" property. + * + * @param {any} question Question. + * @param {HTMLInputElement} button Behaviour button (DOM element). + */ + protected addBehaviourButton(question: any, button: HTMLInputElement): void { + if (!button || !question) { + return; + } + + if (!question.behaviourButtons) { + question.behaviourButtons = []; + } + + // Extract the data we want. + question.behaviourButtons.push({ + id: button.id, + name: button.name, + value: button.value, + disabled: button.disabled + }); + } + + /** + * Check if the question has a redo button and, if so, add it to "behaviourButtons" property + * and remove it from the HTML. + * + * @param {any} question Question to treat. + */ + extractQbehaviourRedoButton(question: any): void { + // Create a fake div element so we can search using querySelector. + const redoSelector = 'input[type="submit"][name*=redoslot], input[type="submit"][name*=tryagain]'; + + // Search redo button in feedback. + if (!this.searchBehaviourButton(question, 'html', '.outcome ' + redoSelector)) { + // Not found in question HTML. + if (question.feedbackHtml) { + // We extracted the feedback already, search it in there. + if (this.searchBehaviourButton(question, 'feedbackHtml', redoSelector)) { + // Button found, stop. + return; + } + } + + // Button still not found. Now search in the info box if it exists. + if (question.infoHtml) { + this.searchBehaviourButton(question, 'infoHtml', redoSelector); + } + } + } + + /** + * Removes the comment from the question HTML code and adds it in a new "commentHtml" property. + * + * @param {any} question Question. + */ + extractQuestionComment(question: any): void { + this.extractQuestionLastElementNotInContent(question, '.comment', 'commentHtml'); + } + + /** + * Removes the feedback from the question HTML code and adds it in a new "feedbackHtml" property. + * + * @param {any} question Question. + */ + extractQuestionFeedback(question: any): void { + this.extractQuestionLastElementNotInContent(question, '.outcome', 'feedbackHtml'); + } + + /** + * Extracts the info box from a question and add it to an "infoHtml" property. + * + * @param {any} question Question. + * @param {string} selector Selector to search the element. + */ + extractQuestionInfoBox(question: any, selector: string): void { + this.extractQuestionLastElementNotInContent(question, selector, 'infoHtml'); + } + + /** + * Searches the last occurrence of a certain element and check it's not in the question contents. + * If found, removes it from the question HTML and adds it to a new property inside question. + * + * @param {any} question Question. + * @param {string} selector Selector to search the element. + * @param {string} attrName Name of the attribute to store the HTML in. + */ + protected extractQuestionLastElementNotInContent(question: any, selector: string, attrName: string): void { + this.div.innerHTML = question.html; + + const matches = Array.from(this.div.querySelectorAll(selector)); + + // Get the last element and check it's not in the question contents. + let last = matches.pop(); + while (last) { + if (!this.domUtils.closest(last, '.formulation')) { + // Not in question contents. Add it to a separate attribute and remove it from the HTML. + question[attrName] = last.innerHTML; + last.parentElement.removeChild(last); + question.html = this.div.innerHTML; + + return; + } + + // It's inside the question content, treat next element. + last = matches.pop(); + } + } + + /** + * Removes the scripts from a question's HTML and adds it in a new 'scriptsCode' property. + * It will also search for init_question functions of the question type and add the object to an 'initObjects' property. + * + * @param {any} question Question. + */ + extractQuestionScripts(question: any): void { + question.scriptsCode = ''; + question.initObjects = []; + + if (question.html) { + // Search the scripts. + const matches = question.html.match(/]*>[\s\S]*?<\/script>/mg); + if (!matches) { + // No scripts, stop. + return; + } + + matches.forEach((match: string) => { + // Add the script to scriptsCode and remove it from html. + question.scriptsCode += match; + question.html = question.html.replace(match, ''); + + // Search init_question functions for this type. + const initMatches = match.match(new RegExp('M\.qtype_' + question.type + '\.init_question\\(.*?}\\);', 'mg')); + if (initMatches) { + let initMatch = initMatches.pop(); + + // Remove start and end of the match, we only want the object. + initMatch = initMatch.replace('M.qtype_' + question.type + '.init_question(', ''); + initMatch = initMatch.substr(0, initMatch.length - 2); + + // Try to convert it to an object and add it to the question. + question.initObjects = this.textUtils.parseJSON(initMatch); + } + }); + } + } + + /** + * Get the sequence check from a question HTML. + * + * @param {string} html Question's HTML. + * @return {{name: string, value: string}} Object with the sequencecheck name and value. + */ + getQuestionSequenceCheckFromHtml(html: string): {name: string, value: string} { + if (html) { + this.div.innerHTML = html; + + // Search the input holding the sequencecheck. + const input = this.div.querySelector('input[name*=sequencecheck]'); + if (input && typeof input.name != 'undefined' && typeof input.value != 'undefined') { + return { + name: input.name, + value: input.value + }; + } + } + } + + /** + * Get the validation error message from a question HTML if it's there. + * + * @param {string} html Question's HTML. + * @return {string} Validation error message if present. + */ + getValidationErrorFromHtml(html: string): string { + this.div.innerHTML = html; + + return this.domUtils.getContentsOfElement(this.div, '.validationerror'); + } + + /** + * For each input element found in the HTML, search if there's a local answer stored and + * override the HTML's value with the local one. + * + * @param {any} question Question. + */ + loadLocalAnswersInHtml(question: any): void { + const form = document.createElement('form'); + form.innerHTML = question.html; + + // Search all input elements. + Array.from(form.elements).forEach((element: HTMLInputElement | HTMLButtonElement) => { + let name = element.name || ''; + // Ignore flag and submit inputs. + if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') { + return; + } + + // Search if there's a local answer. + name = this.questionProvider.removeQuestionPrefix(name); + if (question.localAnswers && typeof question.localAnswers[name] != 'undefined') { + + if (element.tagName == 'TEXTAREA') { + // Just put the answer inside the textarea. + element.innerHTML = question.localAnswers[name]; + } else if (element.tagName == 'SELECT') { + // Search the selected option and select it. + const selected = element.querySelector('option[value="' + question.localAnswers[name] + '"]'); + if (selected) { + selected.setAttribute('selected', 'selected'); + } + } else if (element.type == 'radio' || element.type == 'checkbox') { + // Check if this radio or checkbox is selected. + if (element.value == question.localAnswers[name]) { + element.setAttribute('checked', 'checked'); + } + } else { + // Put the answer in the value. + element.setAttribute('value', question.localAnswers[name]); + } + } + }); + + // Update the question HTML. + question.html = form.innerHTML; + } + + /** + * Search a behaviour button in a certain question property containing HTML. + * + * @param {any} question Question. + * @param {string} htmlProperty The name of the property containing the HTML to search. + * @param {string} selector The selector to find the button. + * @return {boolean} Whether the button is found. + */ + protected searchBehaviourButton(question: any, htmlProperty: string, selector: string): boolean { + this.div.innerHTML = question[htmlProperty]; + + const button = this.div.querySelector(selector); + if (button) { + // Add a behaviour button to the question's "behaviourButtons" property. + this.addBehaviourButton(question, button); + + // Remove the button from the HTML. + button.parentElement.removeChild(button); + + // Update the question's html. + question[htmlProperty] = this.div.innerHTML; + + return true; + } + + return false; + } + + /** + * Convenience function to show a parsing error and abort. + * + * @param {EventEmitter} [onAbort] If supplied, will emit an event. + * @param {string} [error] Error to show. + */ + showComponentError(onAbort: EventEmitter, error?: string): void { + error = error || 'Error processing the question. This could be caused by custom modifications in your site.'; + + // Prevent consecutive errors. + const now = Date.now(); + if (now - this.lastErrorShown > 500) { + this.lastErrorShown = now; + this.domUtils.showErrorModal(error); + } + + onAbort && onAbort.emit(); + } +} 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..0029cf9c3 --- /dev/null +++ b/src/core/question/question.module.ts @@ -0,0 +1,41 @@ +// (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'; +import { CoreQuestionDelegate } from './providers/delegate'; +import { CoreQuestionBehaviourDelegate } from './providers/behaviour-delegate'; +import { CoreQuestionDefaultHandler } from './providers/default-question-handler'; +import { CoreQuestionBehaviourDefaultHandler } from './providers/default-behaviour-handler'; +import { CoreQuestionHelperProvider } from './providers/helper'; + +// List of providers (without handlers). +export const CORE_QUESTION_PROVIDERS: any[] = [ + CoreQuestionProvider, + CoreQuestionDelegate, + CoreQuestionBehaviourDelegate, + CoreQuestionHelperProvider +]; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: CORE_QUESTION_PROVIDERS.concat([ + CoreQuestionDefaultHandler, + CoreQuestionBehaviourDefaultHandler + ]), + exports: [] +}) +export class CoreQuestionModule {}