From 596ef954baa8765e50b6907df0e1be7c42a66223 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 9 Feb 2021 08:21:00 +0100 Subject: [PATCH] MOBILE-3651 quiz: Implement base services and access rule delegate --- .../mod/lesson/services/handlers/prefetch.ts | 6 +- src/addons/mod/quiz/lang.json | 83 + .../quiz/services/access-rules-delegate.ts | 326 +++ src/addons/mod/quiz/services/database/quiz.ts | 83 + src/addons/mod/quiz/services/quiz-helper.ts | 436 +++ src/addons/mod/quiz/services/quiz-offline.ts | 372 +++ src/addons/mod/quiz/services/quiz.ts | 2402 +++++++++++++++++ src/core/services/utils/utils.ts | 6 +- 8 files changed, 3708 insertions(+), 6 deletions(-) create mode 100644 src/addons/mod/quiz/lang.json create mode 100644 src/addons/mod/quiz/services/access-rules-delegate.ts create mode 100644 src/addons/mod/quiz/services/database/quiz.ts create mode 100644 src/addons/mod/quiz/services/quiz-helper.ts create mode 100644 src/addons/mod/quiz/services/quiz-offline.ts create mode 100644 src/addons/mod/quiz/services/quiz.ts diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts index 943a3ab32..6cb182a06 100644 --- a/src/addons/mod/lesson/services/handlers/prefetch.ts +++ b/src/addons/mod/lesson/services/handlers/prefetch.ts @@ -60,13 +60,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref await modal.present(); - const password = await modal.onWillDismiss(); + const result = await modal.onWillDismiss(); - if (typeof password != 'string') { + if (typeof result.data != 'string') { throw new CoreCanceledError(); } - return password; + return result.data; } /** diff --git a/src/addons/mod/quiz/lang.json b/src/addons/mod/quiz/lang.json new file mode 100644 index 000000000..080059c18 --- /dev/null +++ b/src/addons/mod/quiz/lang.json @@ -0,0 +1,83 @@ +{ + "answercolon": "Answer:", + "attemptfirst": "First attempt", + "attemptlast": "Last attempt", + "attemptnumber": "Attempt", + "attemptquiznow": "Attempt quiz now", + "attemptstate": "State", + "canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:", + "cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", + "clearchoice": "Clear my choice", + "comment": "Comment", + "completedon": "Completed on", + "confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.", + "confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.", + "confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?", + "confirmstart": "Your attempt will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?", + "confirmstartheader": "Time limit", + "connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.", + "continueattemptquiz": "Continue the last attempt", + "continuepreview": "Continue the last preview", + "errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:", + "errordownloading": "Error downloading required data.", + "errorgetattempt": "Error getting attempt data.", + "errorgetquestions": "Error getting questions.", + "errorgetquiz": "Error getting quiz data.", + "errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.", + "errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:", + "errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:", + "errorsaveattempt": "An error occurred while saving the attempt data.", + "feedback": "Feedback", + "finishattemptdots": "Finish attempt...", + "finishnotsynced": "Finished but not synchronised", + "grade": "Grade", + "gradeaverage": "Average grade", + "gradehighest": "Highest grade", + "grademethod": "Grading method", + "gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.", + "marks": "Marks", + "modulenameplural": "Quizzes", + "mustbesubmittedby": "This attempt must be submitted by {{$a}}.", + "noquestions": "No questions have been added yet", + "noreviewattempt": "You are not allowed to review this attempt.", + "notyetgraded": "Not yet graded", + "opentoc": "Open navigation popover", + "outof": "{{$a.grade}} out of {{$a.maxgrade}}", + "outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)", + "outofshort": "{{$a.grade}}/{{$a.maxgrade}}", + "overallfeedback": "Overall feedback", + "overdue": "Overdue", + "overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.", + "preview": "Preview", + "previewquiznow": "Preview quiz now", + "question": "Question", + "quiznavigation": "Quiz navigation", + "quizpassword": "Quiz password", + "reattemptquiz": "Re-attempt quiz", + "requirepasswordmessage": "To attempt this quiz you need to know the quiz password", + "returnattempt": "Return to attempt", + "review": "Review", + "reviewofattempt": "Review of attempt {{$a}}", + "reviewofpreview": "Review of preview", + "showall": "Show all questions on one page", + "showeachpage": "Show one page at a time", + "startattempt": "Start attempt", + "startedon": "Started on", + "stateabandoned": "Never submitted", + "statefinished": "Finished", + "statefinisheddetails": "Submitted {{$a}}", + "stateinprogress": "In progress", + "stateoverdue": "Overdue", + "stateoverduedetails": "Must be submitted by {{$a}}", + "status": "Status", + "submitallandfinish": "Submit all and finish", + "summaryofattempt": "Summary of attempt", + "summaryofattempts": "Summary of your previous attempts", + "timeleft": "Time left", + "timetaken": "Time taken", + "warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.", + "warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.", + "warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.", + "warningquestionsnotsupported": "This quiz contains questions not supported by the app:", + "yourfinalgradeis": "Your final grade for this quiz is {{$a}}." +} \ No newline at end of file diff --git a/src/addons/mod/quiz/services/access-rules-delegate.ts b/src/addons/mod/quiz/services/access-rules-delegate.ts new file mode 100644 index 000000000..3f6142853 --- /dev/null +++ b/src/addons/mod/quiz/services/access-rules-delegate.ts @@ -0,0 +1,326 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz'; + +/** + * Interface that all access rules handlers must implement. + */ +export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler { + + /** + * Name of the rule the handler supports. E.g. 'password'. + */ + ruleName: string; + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired( + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): boolean | Promise; + + /** + * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param. + * + * @param quiz The quiz the rule belongs to. + * @param preflightData Object where to add the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + getFixedPreflightData?( + quiz: AddonModQuizQuizWSData, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Return the Component to use to display the access rule preflight. + * Implement this if your access rule requires a preflight check with user interaction. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getPreflightComponent?(): Type | Promise>; + + /** + * Function called when the preflight check has passed. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + notifyPreflightCheckPassed?( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Function called when the preflight check fails. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + notifyPreflightCheckFailed?( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft?(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean; +} + +/** + * Delegate to register access rules for quiz module. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessRuleDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'ruleName'; + + constructor() { + super('AddonModQuizAccessRulesDelegate', true); + } + + /** + * Get the handler for a certain rule. + * + * @param ruleName Name of the access rule. + * @return Handler. Undefined if no handler found for the rule. + */ + getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler { + return this.getHandler(ruleName, true); + } + + /** + * Given a list of rules, get some fixed preflight data (data that doesn't require user interaction). + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param preflightData Object where to store the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when all the data has been gathered. + */ + async getFixedPreflightData( + rules: string[], + quiz: AddonModQuizQuizWSData, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]); + }))); + } + + /** + * Get the Component to use to display the access rule preflight. + * + * @param rule Rule. + * @return Promise resolved with the component to use, undefined if not found. + */ + getPreflightComponent(rule: string): Promise | undefined> { + return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', [])); + } + + /** + * Check if an access rule is supported. + * + * @param ruleName Name of the rule. + * @return Whether it's supported. + */ + isAccessRuleSupported(ruleName: string): boolean { + return this.hasHandler(ruleName, true); + } + + /** + * Given a list of rules, check if preflight check is required. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's required. + */ + async isPreflightCheckRequired( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + let isRequired = false; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + const ruleRequired = await this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId); + + isRequired = isRequired || ruleRequired; + }))); + + return isRequired; + } + + /** + * Check if preflight check is required for a certain rule. + * + * @param rule Rule name. + * @param quiz Quiz. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's required. + */ + async isPreflightCheckRequiredForRule( + rule: string, + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + const isRequired = await this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]); + + return !!isRequired; + } + + /** + * Notify all rules that the preflight check has passed. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async notifyPreflightCheckPassed( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled( + rule, + 'notifyPreflightCheckPassed', + [quiz, attempt, preflightData, prefetch, siteId], + ); + }))); + } + + /** + * Notify all rules that the preflight check has failed. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async notifyPreflightCheckFailed( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled( + rule, + 'notifyPreflightCheckFailed', + [quiz, attempt, preflightData, prefetch, siteId], + ); + }))); + } + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param rules List of active rules names. + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean { + rules = rules || []; + + for (const i in rules) { + const rule = rules[i]; + + if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) { + return true; + } + } + + return false; + } + +} + +export class AddonModQuizAccessRuleDelegate extends makeSingleton(AddonModQuizAccessRuleDelegateService) {} diff --git a/src/addons/mod/quiz/services/database/quiz.ts b/src/addons/mod/quiz/services/database/quiz.ts new file mode 100644 index 000000000..7d4ae57b8 --- /dev/null +++ b/src/addons/mod/quiz/services/database/quiz.ts @@ -0,0 +1,83 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModQuizOfflineProvider. + */ +export const ATTEMPTS_TABLE_NAME = 'addon_mod_quiz_attempts'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModQuizOfflineProvider', + version: 1, + tables: [ + { + name: ATTEMPTS_TABLE_NAME, + columns: [ + { + name: 'id', // Attempt ID. + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'attempt', // Attempt number. + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'quizid', + type: 'INTEGER', + }, + { + name: 'currentpage', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'finished', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Quiz attempt. + */ +export type AddonModQuizAttemptDBRecord = { + id: number; + attempt: number; + courseid: number; + userid: number; + quizid: number; + currentpage?: number; + timecreated: number; + timemodified: number; + finished: number; +}; diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts new file mode 100644 index 000000000..74967b107 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -0,0 +1,436 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreCanceledError } from '@classes/errors/cancelederror'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, ModalController, Translate } from '@singletons'; +import { AddonModQuizPreflightModalComponent } from '../components/preflight-modal/preflight-modal'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, +} from './quiz'; +import { AddonModQuizOffline } from './quiz-offline'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizHelperProvider { + + /** + * Validate a preflight data or show a modal to input the preflight data if required. + * It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param preflightData Object where to store the preflight data. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param offline Whether the attempt is offline. + * @param prefetch Whether user is prefetching. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @param retrying Whether we're retrying after a failure. + * @return Promise resolved when the preflight data is validated. The resolve param is the attempt. + */ + async getAndCheckPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + offline?: boolean, + prefetch?: boolean, + title?: string, + siteId?: string, + retrying?: boolean, + ): Promise { + + const rules = accessInfo?.activerulenames; + + // Check if the user needs to input preflight data. + const preflightCheckRequired = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequired( + rules, + quiz, + attempt, + prefetch, + siteId, + ); + + if (preflightCheckRequired) { + // Preflight check is required. Show a modal with the preflight form. + const data = await this.getPreflightData(quiz, accessInfo, attempt, prefetch, title, siteId); + + // Data entered by the user, add it to preflight data and check it again. + Object.assign(preflightData, data); + } + + // Get some fixed preflight data from access rules (data that doesn't require user interaction). + await AddonModQuizAccessRuleDelegate.instance.getFixedPreflightData(rules, quiz, preflightData, attempt, prefetch, siteId); + + try { + // All the preflight data is gathered, now validate it. + return await this.validatePreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, siteId); + } catch (error) { + + if (prefetch) { + throw error; + } else if (retrying && !preflightCheckRequired) { + // We're retrying after a failure, but the preflight check wasn't required. + // This means there's something wrong with some access rule or user is offline and data isn't cached. + // Don't retry again because it would lead to an infinite loop. + throw error; + } + + // Show error and ask for the preflight again. + // Wait to show the error because we want it to be shown over the preflight modal. + setTimeout(() => { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + }, 100); + + return this.getAndCheckPreflightData( + quiz, + accessInfo, + preflightData, + attempt, + offline, + prefetch, + title, + siteId, + true, + ); + } + } + + /** + * Get the preflight data from the user using a modal. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preflight data. Rejected if user cancels. + */ + async getPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + title?: string, + siteId?: string, + ): Promise> { + const notSupported: string[] = []; + const rules = accessInfo?.activerulenames; + + // Check if there is any unsupported rule. + rules.forEach((rule) => { + if (!AddonModQuizAccessRuleDelegate.instance.isAccessRuleSupported(rule)) { + notSupported.push(rule); + } + }); + + if (notSupported.length) { + throw new CoreError( + Translate.instance.instant('addon.mod_quiz.errorrulesnotsupported') + ' ' + JSON.stringify(notSupported), + ); + } + + // Create and show the modal. + const modal = await ModalController.instance.create({ + component: AddonModQuizPreflightModalComponent, + componentProps: { + title: title, + quiz, + attempt, + prefetch: !!prefetch, + siteId: siteId, + rules: rules, + }, + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + if (!result.data) { + throw new CoreCanceledError(); + } + + return > result.data; + } + + /** + * Gets the mark string from a question HTML. + * Example result: "Marked out of 1.00". + * + * @param html Question's HTML. + * @return Question's mark. + */ + getQuestionMarkFromHtml(html: string): string | undefined { + const element = CoreDomUtils.instance.convertToElement(html); + + return CoreDomUtils.instance.getContentsOfElement(element, '.grade'); + } + + /** + * Get a quiz ID by attempt ID. + * + * @param attemptId Attempt ID. + * @param options Other options. + * @return Promise resolved with the quiz ID. + */ + async getQuizIdByAttemptId(attemptId: number, options: { cmId?: number; siteId?: string } = {}): Promise { + // Use getAttemptReview to retrieve the quiz ID. + const reviewData = await AddonModQuiz.instance.getAttemptReview(attemptId, options); + + if (reviewData.attempt.quiz) { + return reviewData.attempt.quiz; + } + + throw new CoreError('Cannot get quiz ID.'); + } + + /** + * Handle a review link. + * + * @param attemptId Attempt ID. + * @param page Page to load, -1 to all questions in same page. + * @param courseId Course ID. + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async handleReviewLink(attemptId: number, page?: number, courseId?: number, quizId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + if (!quizId) { + quizId = await this.getQuizIdByAttemptId(attemptId, { siteId }); + } + if (!courseId) { + courseId = await CoreCourseHelper.instance.getModuleCourseIdByInstance(quizId, 'quiz', siteId); + } + + // Go to the review page. + const pageParams = { + quizId, + attemptId, + courseId, + page: page == undefined || isNaN(page) ? -1 : page, + }; + + await CoreNavigator.instance.navigateToSitePath('@todo AddonModQuizReviewPage', { params: pageParams, siteId }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'An error occurred while loading the required data.'); + } finally { + modal.dismiss(); + } + } + + /** + * Add some calculated data to the attempt. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param highlight Whether we should check if attempt should be highlighted. + * @param bestGrade Quiz's best grade (formatted). Required if highlight=true. + * @param isLastAttempt Whether the attempt is the last one. + * @param siteId Site ID. + */ + async setAttemptCalculatedData( + quiz: AddonModQuizQuizData, + attempt: AddonModQuizAttemptWSData, + highlight?: boolean, + bestGrade?: string, + isLastAttempt?: boolean, + siteId?: string, + ): Promise { + const formattedAttempt = attempt; + + formattedAttempt.rescaledGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + formattedAttempt.finished = AddonModQuiz.instance.isAttemptFinished(attempt.state); + formattedAttempt.readableState = AddonModQuiz.instance.getAttemptReadableState(quiz, attempt); + + if (quiz.showMarkColumn && formattedAttempt.finished) { + formattedAttempt.readableMark = AddonModQuiz.instance.formatGrade(attempt.sumgrades, quiz.decimalpoints); + } else { + formattedAttempt.readableMark = ''; + } + + if (quiz.showGradeColumn && formattedAttempt.finished) { + formattedAttempt.readableGrade = AddonModQuiz.instance.formatGrade( + Number(formattedAttempt.rescaledGrade), + quiz.decimalpoints, + ); + + // Highlight the highest grade if appropriate. + formattedAttempt.highlightGrade = !!(highlight && !attempt.preview && + attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED && formattedAttempt.readableGrade == bestGrade); + } else { + formattedAttempt.readableGrade = ''; + } + + if (isLastAttempt || isLastAttempt === undefined) { + formattedAttempt.finishedOffline = await AddonModQuiz.instance.isAttemptFinishedOffline(attempt.id, siteId); + } + + return formattedAttempt; + } + + /** + * Add some calculated data to the quiz. + * + * @param quiz Quiz. + * @param options Review options. + */ + setQuizCalculatedData(quiz: AddonModQuizQuizWSData, options: AddonModQuizCombinedReviewOptions): AddonModQuizQuizData { + const formattedQuiz = quiz; + + formattedQuiz.sumGradesFormatted = AddonModQuiz.instance.formatGrade(quiz.sumgrades, quiz.decimalpoints); + formattedQuiz.gradeFormatted = AddonModQuiz.instance.formatGrade(quiz.grade, quiz.decimalpoints); + + formattedQuiz.showAttemptColumn = quiz.attempts != 1; + formattedQuiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + AddonModQuiz.instance.quizHasGrades(quiz); + formattedQuiz.showMarkColumn = formattedQuiz.showGradeColumn && quiz.grade != quiz.sumgrades; + formattedQuiz.showFeedbackColumn = !!quiz.hasfeedback && !!options.alloptions.overallfeedback; + + return formattedQuiz; + } + + /** + * Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param preflightData Object where to store the preflight data. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param offline Whether the attempt is offline. + * @param sent Whether preflight data has been entered by the user. + * @param prefetch Whether user is prefetching. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the preflight data is validated. + */ + async validatePreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + preflightData: Record, + attempt?: AddonModQuizAttempt, + offline?: boolean, + prefetch?: boolean, + siteId?: string, + ): Promise { + + const rules = accessInfo.activerulenames; + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + try { + if (attempt) { + if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) { + // We're continuing an attempt. Call getAttemptData to validate the preflight data. + await AddonModQuiz.instance.getAttemptData(attempt.id, attempt.currentpage!, preflightData, modOptions); + + if (offline) { + // Get current page stored in local. + const storedAttempt = await CoreUtils.instance.ignoreErrors( + AddonModQuizOffline.instance.getAttemptById(attempt.id), + ); + + attempt.currentpage = storedAttempt?.currentpage ?? attempt.currentpage; + } + } else { + // Attempt is overdue or finished in offline, we can only see the summary. + // Call getAttemptSummary to validate the preflight data. + await AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions); + } + } else { + // We're starting a new attempt, call startAttempt. + attempt = await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId); + } + + // Preflight data validated. + AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckPassed( + rules, + quiz, + attempt, + preflightData, + prefetch, + siteId, + ); + + return attempt; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService returned an error, assume the preflight failed. + AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckFailed( + rules, + quiz, + attempt, + preflightData, + prefetch, + siteId, + ); + } + + throw error; + } + } + +} + +export class AddonModQuizHelper extends makeSingleton(AddonModQuizHelperProvider) {} + +/** + * Quiz data with calculated data. + */ +export type AddonModQuizQuizData = AddonModQuizQuizWSData & { + sumGradesFormatted?: string; + gradeFormatted?: string; + showAttemptColumn?: boolean; + showGradeColumn?: boolean; + showMarkColumn?: boolean; + showFeedbackColumn?: boolean; +}; + +/** + * Attempt data with calculated data. + */ +export type AddonModQuizAttempt = AddonModQuizAttemptWSData & { + finishedOffline?: boolean; + rescaledGrade?: string; + finished?: boolean; + readableState?: string[]; + readableMark?: string; + readableGrade?: string; + highlightGrade?: boolean; +}; diff --git a/src/addons/mod/quiz/services/quiz-offline.ts b/src/addons/mod/quiz/services/quiz-offline.ts new file mode 100644 index 000000000..b4ce83f33 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-offline.ts @@ -0,0 +1,372 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionAnswerDBRecord } from '@features/question/services/database/question'; +import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizAttemptDBRecord, ATTEMPTS_TABLE_NAME } from './database/quiz'; +import { AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz'; + +/** + * Service to handle offline quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonModQuizOfflineProvider'); + } + + /** + * Classify the answers in questions. + * + * @param answers List of answers. + * @return Object with the questions, the keys are the slot. Each question contains its answers. + */ + classifyAnswersInQuestions(answers: CoreQuestionsAnswers): AddonModQuizQuestionsWithAnswers { + const questionsWithAnswers: AddonModQuizQuestionsWithAnswers = {}; + + // Classify the answers in each question. + for (const name in answers) { + const slot = CoreQuestion.instance.getQuestionSlotFromName(name); + const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name); + + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = { + answers: {}, + prefix: name.substr(0, name.indexOf(nameWithoutPrefix)), + }; + } + questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name]; + } + + return questionsWithAnswers; + } + + /** + * Given a list of questions with answers classified in it, returns a list of answers (including prefix in the name). + * + * @param questions Questions. + * @return Answers. + */ + extractAnswersFromQuestions(questions: AddonModQuizQuestionsWithAnswers): CoreQuestionsAnswers { + const answers: CoreQuestionsAnswers = {}; + + for (const slot in questions) { + const question = questions[slot]; + + for (const name in question.answers) { + answers[question.prefix + name] = question.answers[name]; + } + } + + return answers; + } + + /** + * Get all the offline attempts in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the offline attempts. + */ + async getAllAttempts(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getAllRecords(ATTEMPTS_TABLE_NAME); + } + + /** + * Retrieve an attempt answers from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the answers. + */ + getAttemptAnswers(attemptId: number, siteId?: string): Promise { + return CoreQuestion.instance.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId); + } + + /** + * Retrieve an attempt from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt. + */ + async getAttemptById(attemptId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecord(ATTEMPTS_TABLE_NAME, { id: attemptId }); + } + + /** + * Retrieve an attempt from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, user current site's user. + * @return Promise resolved with the attempts. + */ + async getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { quizid: quizId, userid: userId || site.getUserId() }); + } + + /** + * Load local state in the questions. + * + * @param attemptId Attempt ID. + * @param questions List of questions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async loadQuestionsLocalStates( + attemptId: number, + questions: CoreQuestionQuestionParsed[], + siteId?: string, + ): Promise { + + await Promise.all(questions.map(async (question) => { + const dbQuestion = await CoreUtils.instance.ignoreErrors( + CoreQuestion.instance.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId), + ); + + if (!dbQuestion) { + // Question not found. + return; + } + + const state = CoreQuestion.instance.getState(dbQuestion.state); + question.state = dbQuestion.state; + question.status = Translate.instance.instant('core.question.' + state.status); + })); + + return questions; + } + + /** + * Process an attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param questions Object with the questions of the quiz. The keys should be the question slot. + * @param data Data to save. + * @param finish Whether to finish the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async processAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + questions: Record, + data: CoreQuestionsAnswers, + finish?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + const now = CoreTimeUtils.instance.timestamp(); + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Check if an attempt already exists. Return a new one if it doesn't. + let entry = await CoreUtils.instance.ignoreErrors(this.getAttemptById(attempt.id, siteId)); + + if (entry) { + entry.timemodified = now; + entry.finished = finish ? 1 : 0; + } else { + entry = { + quizid: quiz.id, + userid: attempt.userid!, + id: attempt.id, + courseid: quiz.course, + timecreated: now, + attempt: attempt.attempt!, + currentpage: attempt.currentpage, + timemodified: now, + finished: finish ? 1 : 0, + }; + } + + // Save attempt in DB. + await db.insertRecord(ATTEMPTS_TABLE_NAME, entry); + + // Attempt has been saved, now we need to save the answers. + await this.saveAnswers(quiz, attempt, questions, data, now, siteId); + } + + /** + * Remove an attempt and its answers from local DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all([ + CoreQuestion.instance.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId), + CoreQuestion.instance.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId), + db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }), + ]); + } + + /** + * Remove a question and its answers from local DB. + * + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when finished. + */ + async removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await Promise.all([ + CoreQuestion.instance.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), + CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), + ]); + } + + /** + * Save an attempt's answers and calculate state for questions modified. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param questions Object with the questions of the quiz. The keys should be the question slot. + * @param answers Answers to save. + * @param timeMod Time modified to set in the answers. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveAnswers( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + questions: Record, + answers: CoreQuestionsAnswers, + timeMod?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + timeMod = timeMod || CoreTimeUtils.instance.timestamp(); + + const questionsWithAnswers: Record = {}; + const newStates: Record = {}; + + // Classify the answers in each question. + for (const name in answers) { + const slot = CoreQuestion.instance.getQuestionSlotFromName(name); + const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name); + + if (questions[slot]) { + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = questions[slot]; + questionsWithAnswers[slot].answers = {}; + } + questionsWithAnswers[slot].answers![nameWithoutPrefix] = answers[name]; + } + } + + // First determine the new state of each question. We won't save the new state yet. + await Promise.all(Object.values(questionsWithAnswers).map(async (question) => { + + const state = await CoreQuestionBehaviourDelegate.instance.determineNewState( + quiz.preferredbehaviour!, + AddonModQuizProvider.COMPONENT, + attempt.id, + question, + quiz.coursemodule, + siteId, + ); + + // Check if state has changed. + if (state && state.name != question.state) { + newStates[question.slot] = state.name; + } + + // Delete previously stored answers for this question. + await CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId); + })); + + // Now save the answers. + await CoreQuestion.instance.saveAnswers( + AddonModQuizProvider.COMPONENT, + quiz.id, + attempt.id, + attempt.userid!, + answers, + timeMod, + siteId, + ); + + try { + // Answers have been saved, now we can save the questions with the states. + await CoreUtils.instance.allPromises(Object.keys(newStates).map(async (slot) => { + const question = questionsWithAnswers[Number(slot)]; + + await CoreQuestion.instance.saveQuestion( + AddonModQuizProvider.COMPONENT, + quiz.id, + attempt.id, + attempt.userid!, + question, + newStates[slot], + siteId, + ); + })); + } catch (error) { + // Ignore errors when saving question state. + this.logger.error('Error saving question state', error); + } + + } + + /** + * Set attempt's current page. + * + * @param attemptId Attempt ID. + * @param page Page to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.updateRecords(ATTEMPTS_TABLE_NAME, { currentpage: page }, { id: attemptId }); + } + +} + +export class AddonModQuizOffline extends makeSingleton(AddonModQuizOfflineProvider) {} + +/** + * Answers classified by question slot. + */ +export type AddonModQuizQuestionsWithAnswers = Record; diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts new file mode 100644 index 000000000..19fbe96f5 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz.ts @@ -0,0 +1,2402 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreGradesFormattedItem, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; +import { + CoreQuestion, + CoreQuestionQuestionParsed, + CoreQuestionQuestionWSData, + CoreQuestionsAnswers, +} from '@features/question/services/question'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEventSiteData } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { AddonModQuizAttempt } from './quiz-helper'; +import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline'; + +const ROOT_CACHE_KEY = 'mmaModQuiz:'; + +/** + * Service that provides some features for quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizProvider { + + static readonly COMPONENT = 'mmaModQuiz'; + static readonly ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished'; + + // Grade methods. + static readonly GRADEHIGHEST = 1; + static readonly GRADEAVERAGE = 2; + static readonly ATTEMPTFIRST = 3; + static readonly ATTEMPTLAST = 4; + + // Question options. + static readonly QUESTION_OPTIONS_MAX_ONLY = 1; + static readonly QUESTION_OPTIONS_MARK_AND_MAX = 2; + + // Attempt state. + static readonly ATTEMPT_IN_PROGRESS = 'inprogress'; + static readonly ATTEMPT_OVERDUE = 'overdue'; + static readonly ATTEMPT_FINISHED = 'finished'; + static readonly ATTEMPT_ABANDONED = 'abandoned'; + + // Show the countdown timer if there is less than this amount of time left before the the quiz close date. + static readonly QUIZ_SHOW_TIME_BEFORE_DEADLINE = 3600; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonModQuizProvider'); + } + + /** + * Formats a grade to be displayed. + * + * @param grade Grade. + * @param decimals Decimals to use. + * @return Grade to display. + */ + formatGrade(grade?: number, decimals?: number): string { + if (typeof grade == 'undefined' || grade == -1 || grade === null || isNaN(grade)) { + return Translate.instance.instant('addon.mod_quiz.notyetgraded'); + } + + return CoreUtils.instance.formatFloat(CoreTextUtils.instance.roundToDecimals(grade, decimals)); + } + + /** + * Get attempt questions. Returns all of them or just the ones in certain pages. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the questions. + */ + async getAllQuestionsData( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + options: AddonModQuizAllQuestionsDataOptions = {}, + ): Promise> { + + const questions: Record = {}; + const isSequential = this.isNavigationSequential(quiz); + const pages = options.pages || this.getPagesFromLayout(attempt.layout); + + await Promise.all(pages.map(async (page) => { + if (isSequential && page < (attempt.currentpage || 0)) { + // Sequential quiz, cannot get pages before the current one. + return; + } + + // Get the questions in the page. + const data = await this.getAttemptData(attempt.id, page, preflightData, options); + + // Add the questions to the result object. + data.questions.forEach((question) => { + questions[question.slot] = question; + }); + })); + + return questions; + } + + /** + * Get cache key for get attempt access information WS calls. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptAccessInformationCacheKey(quizId: number, attemptId: number): string { + return this.getAttemptAccessInformationCommonCacheKey(quizId) + ':' + attemptId; + } + + /** + * Get common cache key for get attempt access information WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getAttemptAccessInformationCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'attemptAccessInformation:' + quizId; + } + + /** + * Get access information for an attempt. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. 0 for user's last attempt. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getAttemptAccessInformation( + quizId: number, + attemptId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptAccessInformationWSParams = { + quizid: quizId, + attemptid: attemptId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptAccessInformationCacheKey(quizId, attemptId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_attempt_access_information', params, preSets); + } + + /** + * Get cache key for get attempt data WS calls. + * + * @param attemptId Attempt ID. + * @param page Page. + * @return Cache key. + */ + protected getAttemptDataCacheKey(attemptId: number, page: number): string { + return this.getAttemptDataCommonCacheKey(attemptId) + ':' + page; + } + + /** + * Get common cache key for get attempt data WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptDataCommonCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptData:' + attemptId; + } + + /** + * Get an attempt's data. + * + * @param attemptId Attempt ID. + * @param page Page number. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the attempt data. + */ + async getAttemptData( + attemptId: number, + page: number, + preflightData: Record, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptDataWSParams = { + attemptid: attemptId, + page: page, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + true, + ), + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptDataCacheKey(attemptId, page), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read('mod_quiz_get_attempt_data', params, preSets); + + result.questions = CoreQuestion.instance.parseQuestions(result.questions); + + return result; + } + + /** + * Get an attempt's due date. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Attempt's due date, 0 if no due date or invalid data. + */ + getAttemptDueDate(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): number { + const deadlines: number[] = []; + + if (quiz.timelimit && attempt.timestart) { + deadlines.push(attempt.timestart + quiz.timelimit); + } + if (quiz.timeclose) { + deadlines.push(quiz.timeclose); + } + + if (!deadlines.length) { + return 0; + } + + // Get min due date. + const dueDate: number = Math.min.apply(null, deadlines); + if (!dueDate) { + return 0; + } + + switch (attempt.state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return dueDate * 1000; + + case AddonModQuizProvider.ATTEMPT_OVERDUE: + return (dueDate + quiz.graceperiod!) * 1000; + + default: + this.logger.warn('Unexpected state when getting due date: ' + attempt.state); + + return 0; + } + } + + /** + * Get an attempt's warning because of due date. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Attempt's warning, undefined if no due date. + */ + getAttemptDueDateWarning(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): string | undefined { + const dueDate = this.getAttemptDueDate(quiz, attempt); + + if (attempt.state === AddonModQuizProvider.ATTEMPT_OVERDUE) { + return Translate.instance.instant( + 'addon.mod_quiz.overduemustbesubmittedby', + { $a: CoreTimeUtils.instance.userDate(dueDate) }, + ); + } else if (dueDate) { + return Translate.instance.instant('addon.mod_quiz.mustbesubmittedby', { $a: CoreTimeUtils.instance.userDate(dueDate) }); + } + } + + /** + * Turn attempt's state into a readable state, including some extra data depending on the state. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return List of state sentences. + */ + getAttemptReadableState(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttempt): string[] { + if (attempt.finishedOffline) { + return [Translate.instance.instant('addon.mod_quiz.finishnotsynced')]; + } + + switch (attempt.state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return [Translate.instance.instant('addon.mod_quiz.stateinprogress')]; + + case AddonModQuizProvider.ATTEMPT_OVERDUE: { + const sentences: string[] = []; + const dueDate = this.getAttemptDueDate(quiz, attempt); + + sentences.push(Translate.instance.instant('addon.mod_quiz.stateoverdue')); + + if (dueDate) { + sentences.push(Translate.instance.instant( + 'addon.mod_quiz.stateoverduedetails', + { $a: CoreTimeUtils.instance.userDate(dueDate) }, + )); + } + + return sentences; + } + + case AddonModQuizProvider.ATTEMPT_FINISHED: + return [ + Translate.instance.instant('addon.mod_quiz.statefinished'), + Translate.instance.instant( + 'addon.mod_quiz.statefinisheddetails', + { $a: CoreTimeUtils.instance.userDate(attempt.timefinish! * 1000) }, + ), + ]; + + case AddonModQuizProvider.ATTEMPT_ABANDONED: + return [Translate.instance.instant('addon.mod_quiz.stateabandoned')]; + + default: + return []; + } + } + + /** + * Turn attempt's state into a readable state name, without any more data. + * + * @param state State. + * @return Readable state name. + */ + getAttemptReadableStateName(state: string): string { + switch (state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return Translate.instance.instant('addon.mod_quiz.stateinprogress'); + + case AddonModQuizProvider.ATTEMPT_OVERDUE: + return Translate.instance.instant('addon.mod_quiz.stateoverdue'); + + case AddonModQuizProvider.ATTEMPT_FINISHED: + return Translate.instance.instant('addon.mod_quiz.statefinished'); + + case AddonModQuizProvider.ATTEMPT_ABANDONED: + return Translate.instance.instant('addon.mod_quiz.stateabandoned'); + + default: + return ''; + } + } + + /** + * Get cache key for get attempt review WS calls. + * + * @param attemptId Attempt ID. + * @param page Page. + * @return Cache key. + */ + protected getAttemptReviewCacheKey(attemptId: number, page: number): string { + return this.getAttemptReviewCommonCacheKey(attemptId) + ':' + page; + } + + /** + * Get common cache key for get attempt review WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptReviewCommonCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptReview:' + attemptId; + } + + /** + * Get an attempt's review. + * + * @param attemptId Attempt ID. + * @param options Other options. + * @return Promise resolved with the attempt review. + */ + async getAttemptReview( + attemptId: number, + options: AddonModQuizGetAttemptReviewOptions = {}, + ): Promise { + const page = typeof options.page == 'undefined' ? -1 : options.page; + + const site = await CoreSites.instance.getSite(options.siteId); + + const params = { + attemptid: attemptId, + page: page, + }; + const preSets = { + cacheKey: this.getAttemptReviewCacheKey(attemptId, page), + cacheErrors: ['noreview'], + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read('mod_quiz_get_attempt_review', params, preSets); + + result.questions = CoreQuestion.instance.parseQuestions(result.questions); + + return result; + } + + /** + * Get cache key for get attempt summary WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptSummaryCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptSummary:' + attemptId; + } + + /** + * Get an attempt's summary. + * + * @param attemptId Attempt ID. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the list of questions for the attempt summary. + */ + async getAttemptSummary( + attemptId: number, + preflightData: Record, + options: AddonModQuizGetAttemptSummaryOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptSummaryWSParams = { + attemptid: attemptId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + true, + ), + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptSummaryCacheKey(attemptId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_quiz_get_attempt_summary', params, preSets); + + const questions = CoreQuestion.instance.parseQuestions(response.questions); + + if (options.loadLocal) { + return AddonModQuizOffline.instance.loadQuestionsLocalStates(attemptId, questions, site.getId()); + } + + return questions; + } + + /** + * Get cache key for get combined review options WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getCombinedReviewOptionsCacheKey(quizId: number, userId: number): string { + return this.getCombinedReviewOptionsCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get combined review options WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getCombinedReviewOptionsCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'combinedReviewOptions:' + quizId; + } + + /** + * Get a quiz combined review options. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the combined review options. + */ + async getCombinedReviewOptions( + quizId: number, + options: AddonModQuizUserOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetCombinedReviewOptionsWSParams = { + quizid: quizId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCombinedReviewOptionsCacheKey(quizId, userId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_combined_review_options', + params, + preSets, + ); + + // Convert the arrays to objects with name -> value. + return { + someoptions: > CoreUtils.instance.objectToKeyValueMap(response.someoptions, 'name', 'value'), + alloptions: > CoreUtils.instance.objectToKeyValueMap(response.alloptions, 'name', 'value'), + warnings: response.warnings, + }; + } + + /** + * Get cache key for get feedback for grade WS calls. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @return Cache key. + */ + protected getFeedbackForGradeCacheKey(quizId: number, grade: number): string { + return this.getFeedbackForGradeCommonCacheKey(quizId) + ':' + grade; + } + + /** + * Get common cache key for get feedback for grade WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getFeedbackForGradeCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'feedbackForGrade:' + quizId; + } + + /** + * Get the feedback for a certain grade. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @param options Other options. + * @return Promise resolved with the feedback. + */ + async getFeedbackForGrade( + quizId: number, + grade: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizFeedbackForGradeWSParams = { + quizid: quizId, + grade: grade, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFeedbackForGradeCacheKey(quizId, grade), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_quiz_feedback_for_grade', params, preSets); + } + + /** + * Determine the correct number of decimal places required to format a grade. + * Based on Moodle's quiz_get_grade_format. + * + * @param quiz Quiz. + * @return Number of decimals. + */ + getGradeDecimals(quiz: AddonModQuizQuizWSData): number { + if (typeof quiz.questiondecimalpoints == 'undefined') { + quiz.questiondecimalpoints = -1; + } + + if (quiz.questiondecimalpoints == -1) { + return quiz.decimalpoints!; + } + + return quiz.questiondecimalpoints; + } + + /** + * Gets a quiz grade and feedback from the gradebook. + * + * @param courseId Course ID. + * @param moduleId Quiz module ID. + * @param ignoreCache Whether it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with an object containing the grade and the feedback. + */ + async getGradeFromGradebook( + courseId: number, + moduleId: number, + ignoreCache?: boolean, + siteId?: string, + userId?: number, + ): Promise { + + const items = await CoreGradesHelper.instance.getGradeModuleItems( + courseId, + moduleId, + userId, + undefined, + siteId, + ignoreCache, + ); + + return items.shift(); + } + + /** + * Given a list of attempts, returns the last finished attempt. + * + * @param attempts Attempts. + * @return Last finished attempt. + */ + getLastFinishedAttemptFromList(attempts?: AddonModQuizAttemptWSData[]): AddonModQuizAttemptWSData | undefined { + return attempts?.find(attempt => this.isAttemptFinished(attempt.state)); + } + + /** + * Given a list of questions, check if the quiz can be submitted. + * Will return an array with the messages to prevent the submit. Empty array if quiz can be submitted. + * + * @param questions Questions. + * @return List of prevent submit messages. Empty array if quiz can be submitted. + */ + getPreventSubmitMessages(questions: CoreQuestionQuestionParsed[]): string[] { + const messages: string[] = []; + + questions.forEach((question) => { + if (question.type != 'random' && !CoreQuestionDelegate.instance.isQuestionSupported(question.type)) { + // The question isn't supported. + messages.push(Translate.instance.instant('core.question.questionmessage', { + $a: question.slot, + $b: Translate.instance.instant('core.question.errorquestionnotsupported', { $a: question.type }), + })); + } else { + let message = CoreQuestionDelegate.instance.getPreventSubmitMessage(question); + if (message) { + message = Translate.instance.instant(message); + messages.push(Translate.instance.instant('core.question.questionmessage', { $a: question.slot, $b: message })); + } + } + }); + + return messages; + } + + /** + * Get cache key for quiz data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getQuizDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'quiz:' + courseId; + } + + /** + * Get a Quiz with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the Quiz is retrieved. + */ + protected async getQuizByField( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizzesByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModQuizProvider.COMPONENT, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_quizzes_by_courses', + params, + preSets, + ); + + // Search the quiz. + const quiz = response.quizzes.find(quiz => quiz[key] == value); + + if (!quiz) { + throw new CoreError('Quiz not found.'); + } + + return quiz; + } + + /** + * Get a quiz by module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the quiz is retrieved. + */ + getQuiz(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getQuizByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a quiz by quiz ID. + * + * @param courseId Course ID. + * @param id Quiz ID. + * @param options Other options. + * @return Promise resolved when the quiz is retrieved. + */ + getQuizById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getQuizByField(courseId, 'id', id, options); + } + + /** + * Get cache key for get quiz access information WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getQuizAccessInformationCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'quizAccessInformation:' + quizId; + } + + /** + * Get access information for an attempt. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getQuizAccessInformation( + quizId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizAccessInformationWSParams = { + quizid: quizId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizAccessInformationCacheKey(quizId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_quiz_access_information', params, preSets); + } + + /** + * Get a readable Quiz grade method. + * + * @param method Grading method. + * @return Readable grading method. + */ + getQuizGradeMethod(method?: number | string): string { + if (method === undefined) { + return ''; + } + + if (typeof method == 'string') { + method = parseInt(method, 10); + } + + switch (method) { + case AddonModQuizProvider.GRADEHIGHEST: + return Translate.instance.instant('addon.mod_quiz.gradehighest'); + case AddonModQuizProvider.GRADEAVERAGE: + return Translate.instance.instant('addon.mod_quiz.gradeaverage'); + case AddonModQuizProvider.ATTEMPTFIRST: + return Translate.instance.instant('addon.mod_quiz.attemptfirst'); + case AddonModQuizProvider.ATTEMPTLAST: + return Translate.instance.instant('addon.mod_quiz.attemptlast'); + default: + return ''; + } + } + + /** + * Get cache key for get quiz required qtypes WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getQuizRequiredQtypesCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'quizRequiredQtypes:' + quizId; + } + + /** + * Get the potential question types that would be required for a given quiz. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getQuizRequiredQtypes(quizId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizRequiredQtypesWSParams = { + quizid: quizId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizRequiredQtypesCacheKey(quizId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_quiz_required_qtypes', + params, + preSets, + ); + + return response.questiontypes; + } + + /** + * Given an attempt's layout, return the list of pages. + * + * @param layout Attempt's layout. + * @return Pages. + * @description + * An attempt's layout is a string with the question numbers separated by commas. A 0 indicates a change of page. + * Example: 1,2,3,0,4,5,6,0 + * In the example above, first page has questions 1, 2 and 3. Second page has questions 4, 5 and 6. + * + * This function returns a list of pages. + */ + getPagesFromLayout(layout?: string): number[] { + if (!layout) { + return []; + } + + const split = layout.split(','); + const pages: number[] = []; + let page = 0; + + for (let i = 0; i < split.length; i++) { + if (split[i] == '0') { + pages.push(page); + page++; + } + } + + return pages; + } + + /** + * Given an attempt's layout and a list of questions identified by question slot, + * return the list of pages that have at least 1 of the questions. + * + * @param layout Attempt's layout. + * @param questions List of questions. It needs to be an object where the keys are question slot. + * @return Pages. + * @description + * An attempt's layout is a string with the question numbers separated by commas. A 0 indicates a change of page. + * Example: 1,2,3,0,4,5,6,0 + * In the example above, first page has questions 1, 2 and 3. Second page has questions 4, 5 and 6. + * + * This function returns a list of pages. + */ + getPagesFromLayoutAndQuestions(layout: string, questions: AddonModQuizQuestionsWithAnswers): number[] { + const split = layout.split(','); + const pages: number[] = []; + let page = 0; + let pageAdded = false; + + for (let i = 0; i < split.length; i++) { + const value = Number(split[i]); + + if (value == 0) { + page++; + pageAdded = false; + } else if (!pageAdded && questions[value]) { + pages.push(page); + pageAdded = true; + } + } + + return pages; + } + + /** + * Given a list of question types, returns the types that aren't supported. + * + * @param questionTypes Question types to check. + * @return Not supported question types. + */ + getUnsupportedQuestions(questionTypes: string[]): string[] { + const notSupported: string[] = []; + + questionTypes.forEach((type) => { + if (type != 'random' && !CoreQuestionDelegate.instance.isQuestionSupported(type)) { + notSupported.push(type); + } + }); + + return notSupported; + } + + /** + * Given a list of access rules names, returns the rules that aren't supported. + * + * @param rulesNames Rules to check. + * @return Not supported rules names. + */ + getUnsupportedRules(rulesNames: string[]): string[] { + const notSupported: string[] = []; + + rulesNames.forEach((name) => { + if (!AddonModQuizAccessRuleDelegate.instance.isAccessRuleSupported(name)) { + notSupported.push(name); + } + }); + + return notSupported; + } + + /** + * Get cache key for get user attempts WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserAttemptsCacheKey(quizId: number, userId: number): string { + return this.getUserAttemptsCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get user attempts WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getUserAttemptsCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'userAttempts:' + quizId; + } + + /** + * Get quiz attempts for a certain user. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the attempts. + */ + async getUserAttempts( + quizId: number, + options: AddonModQuizGetUserAttemptsOptions = {}, + ): Promise { + + const status = options.status || 'all'; + const includePreviews = typeof options.includePreviews == 'undefined' ? true : options.includePreviews; + + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetUserAttemptsWSParams = { + quizid: quizId, + userid: userId, + status: status, + includepreviews: !!includePreviews, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAttemptsCacheKey(quizId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_quiz_get_user_attempts', params, preSets); + + return response.attempts; + } + + /** + * Get cache key for get user best grade WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserBestGradeCacheKey(quizId: number, userId: number): string { + return this.getUserBestGradeCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get user best grade WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getUserBestGradeCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'userBestGrade:' + quizId; + } + + /** + * Get best grade in a quiz for a certain user. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the best grade data. + */ + async getUserBestGrade(quizId: number, options: AddonModQuizUserOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetUserBestGradeWSParams = { + quizid: quizId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserBestGradeCacheKey(quizId, userId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_user_best_grade', params, preSets); + } + + /** + * Invalidates all the data related to a certain quiz. + * + * @param quizId Quiz ID. + * @param courseId Course ID. + * @param attemptId Attempt ID to invalidate some WS calls. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllQuizData( + quizId: number, + courseId?: number, + attemptId?: number, + siteId?: string, + userId?: number, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const promises: Promise[] = []; + + promises.push(this.invalidateAttemptAccessInformation(quizId, siteId)); + promises.push(this.invalidateCombinedReviewOptionsForUser(quizId, siteId, userId)); + promises.push(this.invalidateFeedback(quizId, siteId)); + promises.push(this.invalidateQuizAccessInformation(quizId, siteId)); + promises.push(this.invalidateQuizRequiredQtypes(quizId, siteId)); + promises.push(this.invalidateUserAttemptsForUser(quizId, siteId, userId)); + promises.push(this.invalidateUserBestGradeForUser(quizId, siteId, userId)); + + if (attemptId) { + promises.push(this.invalidateAttemptData(attemptId, siteId)); + promises.push(this.invalidateAttemptReview(attemptId, siteId)); + promises.push(this.invalidateAttemptSummary(attemptId, siteId)); + } + + if (courseId) { + promises.push(this.invalidateGradeFromGradebook(courseId, siteId, userId)); + } + + await Promise.all(promises); + } + + /** + * Invalidates attempt access information for all attempts in a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptAccessInformation(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptAccessInformationCommonCacheKey(quizId)); + } + + /** + * Invalidates attempt access information for an attempt. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptAccessInformationForAttempt(quizId: number, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptAccessInformationCacheKey(quizId, attemptId)); + } + + /** + * Invalidates attempt data for all pages. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptData(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptDataCommonCacheKey(attemptId)); + } + + /** + * Invalidates attempt data for a certain page. + * + * @param attemptId Attempt ID. + * @param page Page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptDataForPage(attemptId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptDataCacheKey(attemptId, page)); + } + + /** + * Invalidates attempt review for all pages. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptReview(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptReviewCommonCacheKey(attemptId)); + } + + /** + * Invalidates attempt review for a certain page. + * + * @param attemptId Attempt ID. + * @param page Page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptReviewForPage(attemptId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptReviewCacheKey(attemptId, page)); + } + + /** + * Invalidates attempt summary. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptSummary(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptSummaryCacheKey(attemptId)); + } + + /** + * Invalidates combined review options for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCombinedReviewOptions(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getCombinedReviewOptionsCommonCacheKey(quizId)); + } + + /** + * Invalidates combined review options for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCombinedReviewOptionsForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCombinedReviewOptionsCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModQuizProvider.invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get required data to call the invalidate functions. + const quiz = await this.getQuiz(courseId, moduleId, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + const attempts = await this.getUserAttempts(quiz.id, { cmId: moduleId, siteId }); + + // Now invalidate it. + const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; + + await this.invalidateAllQuizData(quiz.id, courseId, lastAttemptId, siteId); + } + + /** + * Invalidates feedback for all grades of a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedback(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getFeedbackForGradeCommonCacheKey(quizId)); + } + + /** + * Invalidates feedback for a certain grade. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedbackForGrade(quizId: number, grade: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getFeedbackForGradeCacheKey(quizId, grade)); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number): Promise { + await CoreFilepool.instance.invalidateFilesByComponent( + CoreSites.instance.getCurrentSiteId(), + AddonModQuizProvider.COMPONENT, + moduleId, + ); + } + + /** + * Invalidates grade from gradebook for a certain user. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateGradeFromGradebook(courseId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await CoreGradesHelper.instance.invalidateGradeModuleItems(courseId, userId || site.getUserId(), undefined, siteId); + } + + /** + * Invalidates quiz access information for a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizAccessInformation(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizAccessInformationCacheKey(quizId)); + } + + /** + * Invalidates required qtypes for a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizRequiredQtypes(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizRequiredQtypesCacheKey(quizId)); + } + + /** + * Invalidates user attempts for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttempts(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserAttemptsCommonCacheKey(quizId)); + } + + /** + * Invalidates user attempts for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttemptsForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidates user best grade for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserBestGrade(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserBestGradeCommonCacheKey(quizId)); + } + + /** + * Invalidates user best grade for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserBestGradeForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserBestGradeCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidates quiz data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizDataCacheKey(courseId)); + } + + /** + * Check if an attempt is finished based on its state. + * + * @param state Attempt's state. + * @return Whether it's finished. + */ + isAttemptFinished(state?: string): boolean { + return state == AddonModQuizProvider.ATTEMPT_FINISHED || state == AddonModQuizProvider.ATTEMPT_ABANDONED; + } + + /** + * Check if an attempt is finished in offline but not synced. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if finished in offline but not synced, false otherwise. + */ + async isAttemptFinishedOffline(attemptId: number, siteId?: string): Promise { + try { + const attempt = await AddonModQuizOffline.instance.getAttemptById(attemptId, siteId); + + return !!attempt.finished; + } catch { + return false; + } + } + + /** + * Check if an attempt is nearly over. We consider an attempt nearly over or over if: + * - Is not in progress + * OR + * - It finished before autosaveperiod passes. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Whether it's nearly over or over. + */ + isAttemptTimeNearlyOver(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): boolean { + if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + // Attempt not in progress, return true. + return true; + } + + const dueDate = this.getAttemptDueDate(quiz, attempt); + const autoSavePeriod = quiz.autosaveperiod || 0; + + if (dueDate > 0 && Date.now() + autoSavePeriod >= dueDate) { + return true; + } + + return false; + } + + /** + * Check if last attempt is offline and unfinished. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, user current site's user. + * @return Promise resolved with boolean: true if last offline attempt is unfinished, false otherwise. + */ + async isLastAttemptOfflineUnfinished(quiz: AddonModQuizQuizWSData, siteId?: string, userId?: number): Promise { + try { + const attempts = await AddonModQuizOffline.instance.getQuizAttempts(quiz.id, siteId, userId); + + const last = attempts.pop(); + + return !!last && !last.finished; + } catch { + return false; + } + } + + /** + * Check if a quiz navigation is sequential. + * + * @param quiz Quiz. + * @return Whether navigation is sequential. + */ + isNavigationSequential(quiz: AddonModQuizQuizWSData): boolean { + return quiz.navmethod == 'sequential'; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the quiz WS are available. + * + * @return Whether the plugin is enabled. + */ + isPluginEnabled(): boolean { + // Quiz WebServices were introduced in 3.1, it will always be enabled. + return true; + } + + /** + * Check if a question is blocked. + * + * @param question Question. + * @return Whether it's blocked. + */ + isQuestionBlocked(question: CoreQuestionQuestionParsed): boolean { + const element = CoreDomUtils.instance.convertToElement(question.html); + + return !!element.querySelector('.mod_quiz-blocked_question_warning'); + } + + /** + * Check if a quiz is enabled to be used in offline. + * + * @param quiz Quiz. + * @return Whether offline is enabled. + */ + isQuizOffline(quiz: AddonModQuizQuizWSData): boolean { + // Don't allow downloading the quiz if offline is disabled to prevent wasting a lot of data when opening it. + return !!quiz.allowofflineattempts && !CoreSites.instance.getCurrentSite()?.isOfflineDisabled(); + } + + /** + * Report an attempt as being viewed. It did not store logs offline because order of the log is important. + * + * @param attemptId Attempt ID. + * @param page Page number. + * @param preflightData Preflight required data (like password). + * @param offline Whether attempt is offline. + * @param quiz Quiz instance. If set, a Firebase event will be stored. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logViewAttempt( + attemptId: number, + page: number = 0, + preflightData: Record = {}, + offline?: boolean, + quiz?: AddonModQuizQuizWSData, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizViewAttemptWSParams = { + attemptid: attemptId, + page: page, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + const promises: Promise[] = []; + + promises.push(site.write('mod_quiz_view_attempt', params)); + if (offline) { + promises.push(AddonModQuizOffline.instance.setAttemptCurrentPage(attemptId, page, site.getId())); + } + if (quiz) { + CorePushNotifications.instance.logViewEvent( + quiz.id, + quiz.name, + 'quiz', + 'mod_quiz_view_attempt', + { attemptid: attemptId, page }, + siteId, + ); + } + + await Promise.all(promises); + } + + /** + * Report an attempt's review as being viewed. + * + * @param attemptId Attempt ID. + * @param quizId Quiz ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewAttemptReview(attemptId: number, quizId: number, name?: string, siteId?: string): Promise { + const params: AddonModQuizViewAttemptReviewWSParams = { + attemptid: attemptId, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_attempt_review', + params, + AddonModQuizProvider.COMPONENT, + quizId, + name, + 'quiz', + params, + siteId, + ); + } + + /** + * Report an attempt's summary as being viewed. + * + * @param attemptId Attempt ID. + * @param preflightData Preflight required data (like password). + * @param quizId Quiz ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewAttemptSummary( + attemptId: number, + preflightData: Record, + quizId: number, + name?: string, + siteId?: string, + ): Promise { + const params: AddonModQuizViewAttemptSummaryWSParams = { + attemptid: attemptId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_attempt_summary', + params, + AddonModQuizProvider.COMPONENT, + quizId, + name, + 'quiz', + { attemptid: attemptId }, + siteId, + ); + } + + /** + * Report a quiz as being viewed. + * + * @param id Module ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewQuiz(id: number, name?: string, siteId?: string): Promise { + const params: AddonModQuizViewQuizWSParams = { + quizid: id, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_quiz', + params, + AddonModQuizProvider.COMPONENT, + id, + name, + 'quiz', + {}, + siteId, + ); + } + + /** + * Process an attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param timeUp Whether the quiz time is up, false otherwise. + * @param offline Whether the attempt is offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async processAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + timeUp?: boolean, + offline?: boolean, + siteId?: string, + ): Promise { + if (offline) { + return this.processAttemptOffline(quiz, attempt, data, preflightData, finish, siteId); + } + + await this.processAttemptOnline(attempt.id, data, preflightData, finish, timeUp, siteId); + } + + /** + * Process an online attempt, saving its data. + * + * @param attemptId Attempt ID. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param timeUp Whether the quiz time is up, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async processAttemptOnline( + attemptId: number, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + timeUp?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizProcessAttemptWSParams = { + attemptid: attemptId, + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value'), + finishattempt: !!finish, + timeup: !!timeUp, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_quiz_process_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } + + return response.state; + } + + /** + * Process an offline attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async processAttemptOffline( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + siteId?: string, + ): Promise { + + // Get attempt summary to have the list of questions. + const questionsArray = await this.getAttemptSummary(attempt.id, preflightData, { + cmId: quiz.coursemodule, + loadLocal: true, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + // Convert the question array to an object. + const questions = CoreUtils.instance.arrayToObject(questionsArray, 'slot'); + + return AddonModQuizOffline.instance.processAttempt(quiz, attempt, questions, data, finish, siteId); + } + + /** + * Check if it's a graded quiz. Based on Moodle's quiz_has_grades. + * + * @param quiz Quiz. + * @return Whether quiz is graded. + */ + quizHasGrades(quiz: AddonModQuizQuizWSData): boolean { + return quiz.grade! >= 0.000005 && quiz.sumgrades! >= 0.000005; + } + + /** + * Convert the raw grade into a grade out of the maximum grade for this quiz. + * Based on Moodle's quiz_rescale_grade. + * + * @param rawGrade The unadjusted grade, for example attempt.sumgrades. + * @param quiz Quiz. + * @param format True to format the results for display, 'question' to format a question grade + * (different number of decimal places), false to not format it. + * @return Grade to display. + */ + rescaleGrade( + rawGrade: string | number | undefined, + quiz: AddonModQuizQuizWSData, + format: boolean | string = true, + ): string | undefined { + let grade: number | undefined; + + const rawGradeNum = typeof rawGrade == 'string' ? parseFloat(rawGrade) : rawGrade; + if (rawGradeNum !== undefined && !isNaN(rawGradeNum)) { + if (quiz.sumgrades! >= 0.000005) { + grade = rawGradeNum * quiz.grade! / quiz.sumgrades!; + } else { + grade = 0; + } + } + + if (grade === null || grade === undefined) { + return; + } + + if (format === 'question') { + return this.formatGrade(grade, this.getGradeDecimals(quiz)); + } else if (format) { + return this.formatGrade(grade, quiz.decimalpoints!); + } + + return String(grade); + } + + /** + * Save an attempt data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param offline Whether attempt is offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async saveAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + offline?: boolean, + siteId?: string, + ): Promise { + try { + if (offline) { + return await this.processAttemptOffline(quiz, attempt, data, preflightData, false, siteId); + } + + await this.saveAttemptOnline(attempt.id, data, preflightData, siteId); + } catch (error) { + this.logger.error(error); + + throw error; + } + } + + /** + * Save an attempt data. + * + * @param attemptId Attempt ID. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async saveAttemptOnline( + attemptId: number, + data: CoreQuestionsAnswers, + preflightData: Record, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizSaveAttemptWSParams = { + attemptid: attemptId, + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value'), + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_quiz_save_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } else if (!response.status) { + // It shouldn't happen that status is false and no warnings were returned. + throw new CoreError('Cannot save data.'); + } + } + + /** + * Check if time left should be shown. + * + * @param rules List of active rules names. + * @param attempt Attempt. + * @param endTime The attempt end time (in seconds). + * @return Whether time left should be displayed. + */ + shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number): boolean { + const timeNow = CoreTimeUtils.instance.timestamp(); + + if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + return false; + } + + return AddonModQuizAccessRuleDelegate.instance.shouldShowTimeLeft(rules, attempt, endTime, timeNow); + } + + /** + * Start an attempt. + * + * @param quizId Quiz ID. + * @param preflightData Preflight required data (like password). + * @param forceNew Whether to force a new attempt or not. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt data. + */ + async startAttempt( + quizId: number, + preflightData: Record, + forceNew?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizStartAttemptWSParams = { + quizid: quizId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + forcenew: !!forceNew, + }; + + const response = await site.write('mod_quiz_start_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } + + return response.attempt; + } + +} + +export class AddonModQuiz extends makeSingleton(AddonModQuizProvider) {} + +/** + * Common options with user ID. + */ +export type AddonModQuizUserOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User ID. If not defined use site's current user. +}; + +/** + * Options to pass to getAllQuestionsData. + */ +export type AddonModQuizAllQuestionsDataOptions = CoreCourseCommonModWSOptions & { + pages?: number[]; // List of pages to get. If not defined, all pages. +}; + +/** + * Options to pass to getAttemptReview. + */ +export type AddonModQuizGetAttemptReviewOptions = CoreCourseCommonModWSOptions & { + page?: number; // List of pages to get. If not defined, all pages. +}; + +/** + * Options to pass to getAttemptSummary. + */ +export type AddonModQuizGetAttemptSummaryOptions = CoreCourseCommonModWSOptions & { + loadLocal?: boolean; // Whether it should load local state for each question. +}; + +/** + * Options to pass to getUserAttempts. + */ +export type AddonModQuizGetUserAttemptsOptions = CoreCourseCommonModWSOptions & { + status?: string; // Status of the attempts to get. By default, 'all'. + includePreviews?: boolean; // Whether to include previews. Defaults to true. + userId?: number; // User ID. If not defined use site's current user. +}; + +/** + * Preflight data in the format accepted by the WebServices. + */ +type AddonModQuizPreflightDataWSParam = { + name: string; // Data name. + value: string; // Data value. +}; + +/** + * Params of mod_quiz_get_attempt_access_information WS. + */ +export type AddonModQuizGetAttemptAccessInformationWSParams = { + quizid: number; // Quiz instance id. + attemptid?: number; // Attempt id, 0 for the user last attempt if exists. +}; + +/** + * Data returned by mod_quiz_get_attempt_access_information WS. + */ +export type AddonModQuizGetAttemptAccessInformationWSResponse = { + endtime?: number; // When the attempt must be submitted (determined by rules). + isfinished: boolean; // Whether there is no way the user will ever be allowed to attempt. + ispreflightcheckrequired?: boolean; // Whether a check is required before the user starts/continues his attempt. + preventnewattemptreasons: string[]; // List of reasons. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_attempt_data WS. + */ +export type AddonModQuizGetAttemptDataWSParams = { + attemptid: number; // Attempt id. + page: number; // Page number. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_get_attempt_data WS. + */ +export type AddonModQuizGetAttemptDataWSResponse = { + attempt: AddonModQuizAttemptWSData; + messages: string[]; // Access messages, will only be returned for users with mod/quiz:preview capability. + nextpage: number; // Next page number. + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempt data returned by several WebServices. + */ +export type AddonModQuizAttemptWSData = { + id: number; // Attempt id. + quiz?: number; // Foreign key reference to the quiz that was attempted. + userid?: number; // Foreign key reference to the user whose attempt this is. + attempt?: number; // Sequentially numbers this students attempts at this quiz. + uniqueid?: number; // Foreign key reference to the question_usage that holds the details of the the question_attempts. + layout?: string; // Attempt layout. + currentpage?: number; // Attempt current page. + preview?: number; // Whether is a preview attempt or not. + state?: string; // The current state of the attempts. 'inprogress', 'overdue', 'finished' or 'abandoned'. + timestart?: number; // Time when the attempt was started. + timefinish?: number; // Time when the attempt was submitted. 0 if the attempt has not been submitted yet. + timemodified?: number; // Last modified time. + timemodifiedoffline?: number; // Last modified time via webservices. + timecheckstate?: number; // Next time quiz cron should check attempt for state changes. NULL means never check. + sumgrades?: number; // Total marks for this attempt. +}; + +/** + * Get attempt data response with parsed questions. + */ +export type AddonModQuizGetAttemptDataResponse = Omit & { + questions: CoreQuestionQuestionParsed[]; +}; + +/** + * Params of mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizGetAttemptReviewWSParams = { + attemptid: number; // Attempt id. + page?: number; // Page number, empty for all the questions in all the pages. +}; + +/** + * Data returned by mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizGetAttemptReviewWSResponse = { + grade: string; // Grade for the quiz (or empty or "notyetgraded"). + attempt: AddonModQuizAttemptWSData; + additionaldata: AddonModQuizWSAdditionalData[]; + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Additional data returned by mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizWSAdditionalData = { + id: string; // Id of the data. + title: string; // Data title. + content: string; // Data content. +}; + +/** + * Get attempt review response with parsed questions. + */ +export type AddonModQuizGetAttemptReviewResponse = Omit & { + questions: CoreQuestionQuestionParsed[]; +}; + +/** + * Params of mod_quiz_get_attempt_summary WS. + */ +export type AddonModQuizGetAttemptSummaryWSParams = { + attemptid: number; // Attempt id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_get_attempt_summary WS. + */ +export type AddonModQuizGetAttemptSummaryWSResponse = { + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_combined_review_options WS. + */ +export type AddonModQuizGetCombinedReviewOptionsWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id (empty for current user). +}; + +/** + * Data returned by mod_quiz_get_combined_review_options WS. + */ +export type AddonModQuizGetCombinedReviewOptionsWSResponse = { + someoptions: AddonModQuizWSReviewOption[]; + alloptions: AddonModQuizWSReviewOption[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Option data returned by mod_quiz_get_combined_review_options. + */ +export type AddonModQuizWSReviewOption = { + name: string; // Option name. + value: number; // Option value. +}; + +/** + * Data returned by mod_quiz_get_combined_review_options WS, formatted to convert the options to objects. + */ +export type AddonModQuizCombinedReviewOptions = Omit & { + someoptions: Record; + alloptions: Record; +}; + +/** + * Params of mod_quiz_get_quiz_feedback_for_grade WS. + */ +export type AddonModQuizGetQuizFeedbackForGradeWSParams = { + quizid: number; // Quiz instance id. + grade: number; // The grade to check. +}; + +/** + * Data returned by mod_quiz_get_quiz_feedback_for_grade WS. + */ +export type AddonModQuizGetQuizFeedbackForGradeWSResponse = { + feedbacktext: string; // The comment that corresponds to this grade (empty for none). + feedbacktextformat?: number; // Feedbacktext format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + feedbackinlinefiles?: CoreWSExternalFile[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizGetQuizzesByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizGetQuizzesByCoursesWSResponse = { + quizzes: AddonModQuizQuizWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Quiz data returned by mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizQuizWSData = { + id: number; // Standard Moodle primary key. + course: number; // Foreign key reference to the course this quiz is part of. + coursemodule: number; // Course module id. + name: string; // Quiz name. + intro?: string; // Quiz introduction text. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; + timeopen?: number; // The time when this quiz opens. (0 = no restriction.). + timeclose?: number; // The time when this quiz closes. (0 = no restriction.). + timelimit?: number; // The time limit for quiz attempts, in seconds. + overduehandling?: string; // The method used to handle overdue attempts. 'autosubmit', 'graceperiod' or 'autoabandon'. + graceperiod?: number; // The amount of time (in seconds) after time limit during which attempts can still be submitted. + preferredbehaviour?: string; // The behaviour to ask questions to use. + canredoquestions?: number; // Allows students to redo any completed question within a quiz attempt. + attempts?: number; // The maximum number of attempts a student is allowed. + attemptonlast?: number; // Whether subsequent attempts start from the answer to the previous attempt (1) or start blank (0). + grademethod?: number; // One of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. + decimalpoints?: number; // Number of decimal points to use when displaying grades. + questiondecimalpoints?: number; // Number of decimal points to use when displaying question grades. + reviewattempt?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewcorrectness?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewmarks?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewspecificfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewgeneralfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewrightanswer?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewoverallfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + questionsperpage?: number; // How often to insert a page break when editing the quiz, or when shuffling the question order. + navmethod?: string; // Any constraints on how the user is allowed to navigate around the quiz. + shuffleanswers?: number; // Whether the parts of the question should be shuffled, in those question types that support it. + sumgrades?: number; // The total of all the question instance maxmarks. + grade?: number; // The total that the quiz overall grade is scaled to be out of. + timecreated?: number; // The time when the quiz was added to the course. + timemodified?: number; // Last modified time. + password?: string; // A password that the student must enter before starting or continuing a quiz attempt. + subnet?: string; // Used to restrict the IP addresses from which this quiz can be attempted. + browsersecurity?: string; // Restriciton on the browser the student must use. E.g. 'securewindow'. + delay1?: number; // Delay that must be left between the first and second attempt, in seconds. + delay2?: number; // Delay that must be left between the second and subsequent attempt, in seconds. + showuserpicture?: number; // Option to show the user's picture during the attempt and on the review page. + showblocks?: number; // Whether blocks should be shown on the attempt.php and review.php pages. + completionattemptsexhausted?: number; // Mark quiz complete when the student has exhausted the maximum number of attempts. + completionpass?: number; // Whether to require passing grade. + allowofflineattempts?: number; // Whether to allow the quiz to be attempted offline in the mobile app. + autosaveperiod?: number; // Auto-save delay. + hasfeedback?: number; // Whether the quiz has any non-blank feedback text. + hasquestions?: number; // Whether the quiz has questions. + section?: number; // Course section id. + visible?: number; // Module visibility. + groupmode?: number; // Group mode. + groupingid?: number; // Grouping id. +}; + +/** + * Params of mod_quiz_get_quiz_access_information WS. + */ +export type AddonModQuizGetQuizAccessInformationWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data returned by mod_quiz_get_quiz_access_information WS. + */ +export type AddonModQuizGetQuizAccessInformationWSResponse = { + canattempt: boolean; // Whether the user can do the quiz or not. + canmanage: boolean; // Whether the user can edit the quiz settings or not. + canpreview: boolean; // Whether the user can preview the quiz or not. + canreviewmyattempts: boolean; // Whether the users can review their previous attempts or not. + canviewreports: boolean; // Whether the user can view the quiz reports or not. + accessrules: string[]; // List of rules. + activerulenames: string[]; // List of active rules. + preventaccessreasons: string[]; // List of reasons. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_quiz_required_qtypes WS. + */ +export type AddonModQuizGetQuizRequiredQtypesWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data returned by mod_quiz_get_quiz_required_qtypes WS. + */ +export type AddonModQuizGetQuizRequiredQtypesWSResponse = { + questiontypes: string[]; // List of question types used in the quiz. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_user_attempts WS. + */ +export type AddonModQuizGetUserAttemptsWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id, empty for current user. + status?: string; // Quiz status: all, finished or unfinished. + includepreviews?: boolean; // Whether to include previews or not. +}; + +/** + * Data returned by mod_quiz_get_user_attempts WS. + */ +export type AddonModQuizGetUserAttemptsWSResponse = { + attempts: AddonModQuizAttemptWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_user_best_grade WS. + */ +export type AddonModQuizGetUserBestGradeWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id. +}; + +/** + * Data returned by mod_quiz_get_user_best_grade WS. + */ +export type AddonModQuizGetUserBestGradeWSResponse = { + hasgrade: boolean; // Whether the user has a grade on the given quiz. + grade?: number; // The grade (only if the user has a grade). + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_view_attempt WS. + */ +export type AddonModQuizViewAttemptWSParams = { + attemptid: number; // Attempt id. + page: number; // Page number. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_process_attempt WS. + */ +export type AddonModQuizProcessAttemptWSParams = { + attemptid: number; // Attempt id. + data?: { // The data to be saved. + name: string; // Data name. + value: string; // Data value. + }[]; + finishattempt?: boolean; // Whether to finish or not the attempt. + timeup?: boolean; // Whether the WS was called by a timer when the time is up. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_process_attempt WS. + */ +export type AddonModQuizProcessAttemptWSResponse = { + state: string; // The new attempt state: inprogress, finished, overdue, abandoned. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_save_attempt WS. + */ +export type AddonModQuizSaveAttemptWSParams = { + attemptid: number; // Attempt id. + data: { // The data to be saved. + name: string; // Data name. + value: string; // Data value. + }[]; + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_start_attempt WS. + */ +export type AddonModQuizStartAttemptWSParams = { + quizid: number; // Quiz instance id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). + forcenew?: boolean; // Whether to force a new attempt or not. +}; + +/** + * Data returned by mod_quiz_start_attempt WS. + */ +export type AddonModQuizStartAttemptWSResponse = { + attempt: AddonModQuizAttemptWSData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_view_attempt_review WS. + */ +export type AddonModQuizViewAttemptReviewWSParams = { + attemptid: number; // Attempt id. +}; + +/** + * Params of mod_quiz_view_attempt_summary WS. + */ +export type AddonModQuizViewAttemptSummaryWSParams = { + attemptid: number; // Attempt id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_view_quiz WS. + */ +export type AddonModQuizViewQuizWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data passed to ATTEMPT_FINISHED_EVENT event. + */ +export type AddonModQuizAttemptFinishedData = CoreEventSiteData & { + quizId: number; + attemptId: number; + synced: boolean; +}; diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index bb8ea56a1..cbe98637f 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1072,13 +1072,13 @@ export class CoreUtilsProvider { * @param sortByValue True to sort values alphabetically, false otherwise. * @return Array of objects with the name & value of each property. */ - objectToArrayOfObjects( + objectToArrayOfObjects>( obj: Record, keyName: string, valueName: string, sortByKey?: boolean, sortByValue?: boolean, - ): Record[] { + ): T[] { // Get the entries from an object or primitive value. const getEntries = (elKey: string, value: unknown): Record[] | unknown => { if (typeof value == 'undefined' || value == null) { @@ -1114,7 +1114,7 @@ export class CoreUtilsProvider { } // "obj" will always be an object, so "entries" will always be an array. - const entries = getEntries('', obj) as Record[]; + const entries = getEntries('', obj) as T[]; if (sortByKey || sortByValue) { return entries.sort((a, b) => { if (sortByKey) {