MOBILE-3651 quiz: Implement base services and access rule delegate
This commit is contained in:
		
							parent
							
								
									dd060d8168
								
							
						
					
					
						commit
						596ef954ba
					
				| @ -60,13 +60,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref | ||||
| 
 | ||||
|         await modal.present(); | ||||
| 
 | ||||
|         const password = <string | undefined> 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; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
							
								
								
									
										83
									
								
								src/addons/mod/quiz/lang.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/addons/mod/quiz/lang.json
									
									
									
									
									
										Normal file
									
								
							| @ -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}}." | ||||
| } | ||||
							
								
								
									
										326
									
								
								src/addons/mod/quiz/services/access-rules-delegate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										326
									
								
								src/addons/mod/quiz/services/access-rules-delegate.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<boolean>; | ||||
| 
 | ||||
|     /** | ||||
|      * 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<string, string>, | ||||
|         attempt?: AddonModQuizAttemptWSData, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): void | Promise<void>; | ||||
| 
 | ||||
|     /** | ||||
|      * 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<unknown> | Promise<Type<unknown>>; | ||||
| 
 | ||||
|     /** | ||||
|      * 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<string, string>, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): void | Promise<void>; | ||||
| 
 | ||||
|     /** | ||||
|      * 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<string, string>, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): void | Promise<void>; | ||||
| 
 | ||||
|     /** | ||||
|      * 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<AddonModQuizAccessRuleHandler> { | ||||
| 
 | ||||
|     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<string, string>, | ||||
|         attempt?: AddonModQuizAttemptWSData, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         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<Type<unknown> | 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<boolean> { | ||||
|         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<boolean> { | ||||
|         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<string, string>, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         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<string, string>, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         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) {} | ||||
							
								
								
									
										83
									
								
								src/addons/mod/quiz/services/database/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/addons/mod/quiz/services/database/quiz.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
| }; | ||||
							
								
								
									
										436
									
								
								src/addons/mod/quiz/services/quiz-helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										436
									
								
								src/addons/mod/quiz/services/quiz-helper.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<string, string>, | ||||
|         attempt?: AddonModQuizAttemptWSData, | ||||
|         offline?: boolean, | ||||
|         prefetch?: boolean, | ||||
|         title?: string, | ||||
|         siteId?: string, | ||||
|         retrying?: boolean, | ||||
|     ): Promise<AddonModQuizAttemptWSData> { | ||||
| 
 | ||||
|         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<Record<string, string>> { | ||||
|         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 <Record<string, string>> 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<number> { | ||||
|         // 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<void> { | ||||
|         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<AddonModQuizAttempt> { | ||||
|         const formattedAttempt = <AddonModQuizAttempt> 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 = <AddonModQuizQuizData> 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<string, string>, | ||||
|         attempt?: AddonModQuizAttempt, | ||||
|         offline?: boolean, | ||||
|         prefetch?: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<AddonModQuizAttempt> { | ||||
| 
 | ||||
|         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; | ||||
| }; | ||||
							
								
								
									
										372
									
								
								src/addons/mod/quiz/services/quiz-offline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										372
									
								
								src/addons/mod/quiz/services/quiz-offline.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<AddonModQuizAttemptDBRecord[]> { | ||||
|         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<CoreQuestionAnswerDBRecord[]> { | ||||
|         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<AddonModQuizAttemptDBRecord> { | ||||
|         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<AddonModQuizAttemptDBRecord[]> { | ||||
|         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<CoreQuestionQuestionParsed[]> { | ||||
| 
 | ||||
|         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<number, CoreQuestionQuestionParsed>, | ||||
|         data: CoreQuestionsAnswers, | ||||
|         finish?: boolean, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         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<void> { | ||||
|         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<void> { | ||||
|         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<number, CoreQuestionQuestionParsed>, | ||||
|         answers: CoreQuestionsAnswers, | ||||
|         timeMod?: number, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
|         siteId = siteId || CoreSites.instance.getCurrentSiteId(); | ||||
|         timeMod = timeMod || CoreTimeUtils.instance.timestamp(); | ||||
| 
 | ||||
|         const questionsWithAnswers: Record<number, CoreQuestionQuestionWithAnswers> = {}; | ||||
|         const newStates: Record<number, string> = {}; | ||||
| 
 | ||||
|         // 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<void> { | ||||
|         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<number, { | ||||
|     prefix: string; | ||||
|     answers: CoreQuestionsAnswers; | ||||
| }>; | ||||
							
								
								
									
										2402
									
								
								src/addons/mod/quiz/services/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2402
									
								
								src/addons/mod/quiz/services/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -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<T = Record<string, unknown>>( | ||||
|         obj: Record<string, unknown>, | ||||
|         keyName: string, | ||||
|         valueName: string, | ||||
|         sortByKey?: boolean, | ||||
|         sortByValue?: boolean, | ||||
|     ): Record<string, unknown>[] { | ||||
|     ): T[] { | ||||
|         // Get the entries from an object or primitive value.
 | ||||
|         const getEntries = (elKey: string, value: unknown): Record<string, unknown>[] | 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<string, unknown>[]; | ||||
|         const entries = getEntries('', obj) as T[]; | ||||
|         if (sortByKey || sortByValue) { | ||||
|             return entries.sort((a, b) => { | ||||
|                 if (sortByKey) { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user