MOBILE-3651 quiz: Implement base services and access rule delegate

main
Dani Palou 2021-02-09 08:21:00 +01:00
parent dd060d8168
commit 596ef954ba
8 changed files with 3708 additions and 6 deletions

View File

@ -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;
}
/**

View 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}}."
}

View 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) {}

View 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;
};

View 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;
};

View 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;
}>;

File diff suppressed because it is too large Load Diff

View File

@ -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) {