MOBILE-3651 quiz: Implement base services and access rule delegate
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}}."
|
||||
}
|
|
@ -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) {}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}>;
|
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…
Reference in New Issue