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(); |         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(); |             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. |      * @param sortByValue True to sort values alphabetically, false otherwise. | ||||||
|      * @return Array of objects with the name & value of each property. |      * @return Array of objects with the name & value of each property. | ||||||
|      */ |      */ | ||||||
|     objectToArrayOfObjects( |     objectToArrayOfObjects<T = Record<string, unknown>>( | ||||||
|         obj: Record<string, unknown>, |         obj: Record<string, unknown>, | ||||||
|         keyName: string, |         keyName: string, | ||||||
|         valueName: string, |         valueName: string, | ||||||
|         sortByKey?: boolean, |         sortByKey?: boolean, | ||||||
|         sortByValue?: boolean, |         sortByValue?: boolean, | ||||||
|     ): Record<string, unknown>[] { |     ): T[] { | ||||||
|         // Get the entries from an object or primitive value.
 |         // Get the entries from an object or primitive value.
 | ||||||
|         const getEntries = (elKey: string, value: unknown): Record<string, unknown>[] | unknown => { |         const getEntries = (elKey: string, value: unknown): Record<string, unknown>[] | unknown => { | ||||||
|             if (typeof value == 'undefined' || value == null) { |             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.
 |         // "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) { |         if (sortByKey || sortByValue) { | ||||||
|             return entries.sort((a, b) => { |             return entries.sort((a, b) => { | ||||||
|                 if (sortByKey) { |                 if (sortByKey) { | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user