616 lines
21 KiB
TypeScript
616 lines
21 KiB
TypeScript
// (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<any>} Promise resolved with the answer.
|
|
*/
|
|
getAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise<any> {
|
|
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<any[]>} Promise resolved with the answers.
|
|
*/
|
|
getAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise<any[]> {
|
|
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<any[]>} Promise resolved with the questions.
|
|
*/
|
|
getAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise<any[]> {
|
|
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<any>} Promise resolved with the question.
|
|
*/
|
|
getQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise<any> {
|
|
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<any[]>} Promise resolved with the answers.
|
|
*/
|
|
getQuestionAnswers(component: string, attemptId: number, slot: number, filter?: boolean, siteId?: string): Promise<any[]> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
removeAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
removeAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
removeAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
removeQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
removeQuestionAnswers(component: string, attemptId: number, slot: number, siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
saveAnswers(component: string, componentId: number, attemptId: number, userId: number, answers: any, timemodified?: number,
|
|
siteId?: string): Promise<any> {
|
|
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<any>} Promise resolved when done.
|
|
*/
|
|
saveQuestion(component: string, componentId: number, attemptId: number, userId: number, question: any, state: string,
|
|
siteId?: string): Promise<any> {
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|