forked from CIT/Vmeda.Online
		
	
						commit
						656ce16249
					
				@ -24,6 +24,8 @@ import { AddonNotificationsModule } from './notifications/notifications.module';
 | 
			
		||||
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
 | 
			
		||||
import { AddonMessagesModule } from './messages/messages.module';
 | 
			
		||||
import { AddonModModule } from './mod/mod.module';
 | 
			
		||||
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
 | 
			
		||||
import { AddonQtypeModule } from './qtype/qtype.module';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
@ -37,6 +39,8 @@ import { AddonModModule } from './mod/mod.module';
 | 
			
		||||
        AddonNotificationsModule,
 | 
			
		||||
        AddonMessageOutputModule,
 | 
			
		||||
        AddonModModule,
 | 
			
		||||
        AddonQbehaviourModule,
 | 
			
		||||
        AddonQtypeModule,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonsModule {}
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@
 | 
			
		||||
        <div *ngFor="let item of items">
 | 
			
		||||
            <ion-card>
 | 
			
		||||
                <ion-item class="core-course-module-handler item-media ion-text-wrap" detail="false" (click)="action($event, item)"
 | 
			
		||||
                    [title]="item.name">
 | 
			
		||||
                    [title]="item.name" button>
 | 
			
		||||
                    <img slot="start" [src]="item.iconUrl" alt="" role="presentation" *ngIf="item.iconUrl" class="core-module-icon">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,6 @@ export class AddonBlockRecentlyAccessedItemsHandlerService extends CoreBlockBase
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the data needed to render the block.
 | 
			
		||||
     *
 | 
			
		||||
     * @param injector Injector.
 | 
			
		||||
     * @param block The block to render.
 | 
			
		||||
     * @param contextLevel The context where the block will be used.
 | 
			
		||||
     * @param instanceId The instance ID associated with the context level.
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
 | 
			
		||||
     * @return Promise resolved if sync is successful, rejected if sync fails.
 | 
			
		||||
     */
 | 
			
		||||
    async syncAllEvents(siteId?: string, force = false): Promise<void> {
 | 
			
		||||
        await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId);
 | 
			
		||||
        await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, force), siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -74,17 +74,17 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider<AddonMessage
 | 
			
		||||
    syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
 | 
			
		||||
        const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : '');
 | 
			
		||||
 | 
			
		||||
        return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId);
 | 
			
		||||
        return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, onlyDeviceOffline), siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all messages pending to be sent in the site.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID to sync. If not defined, sync all sites.
 | 
			
		||||
     * @param onlyDeviceOffline True to only sync discussions that failed because device was offline.
 | 
			
		||||
     * @param siteId Site ID to sync. If not defined, sync all sites.
 | 
			
		||||
     * @param Promise resolved if sync is successful, rejected if sync fails.
 | 
			
		||||
     */
 | 
			
		||||
    protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise<void> {
 | 
			
		||||
    protected async syncAllDiscussionsFunc(onlyDeviceOffline: boolean, siteId: string): Promise<void> {
 | 
			
		||||
        const userIds: number[] = [];
 | 
			
		||||
        const conversationIds: number[] = [];
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
@ -897,10 +897,7 @@ export class AddonMessagesProvider {
 | 
			
		||||
    ): Promise<{members: AddonMessagesConversationMember[]; canLoadMore: boolean}> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
        userId = userId || site.getUserId();
 | 
			
		||||
 | 
			
		||||
        if (typeof limitTo == 'undefined' || limitTo === null) {
 | 
			
		||||
            limitTo = AddonMessagesProvider.LIMIT_MESSAGES;
 | 
			
		||||
        }
 | 
			
		||||
        limitTo = limitTo ?? AddonMessagesProvider.LIMIT_MESSAGES;
 | 
			
		||||
 | 
			
		||||
        const preSets: CoreSiteWSPreSets = {
 | 
			
		||||
            cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId),
 | 
			
		||||
@ -948,11 +945,9 @@ export class AddonMessagesProvider {
 | 
			
		||||
 | 
			
		||||
        options.userId = options.userId || site.getUserId();
 | 
			
		||||
        options.limitFrom = options.limitFrom || 0;
 | 
			
		||||
        options.limitTo = options.limitTo === undefined || options.limitTo === null
 | 
			
		||||
            ? AddonMessagesProvider.LIMIT_MESSAGES
 | 
			
		||||
            : options.limitTo;
 | 
			
		||||
        options.limitTo = options.limitTo ?? AddonMessagesProvider.LIMIT_MESSAGES;
 | 
			
		||||
        options.timeFrom = options.timeFrom || 0;
 | 
			
		||||
        options.newestFirst = options.newestFirst === undefined || options.newestFirst === null ? true : options.newestFirst;
 | 
			
		||||
        options.newestFirst = options.newestFirst ?? true;
 | 
			
		||||
 | 
			
		||||
        const preSets: CoreSiteWSPreSets = {
 | 
			
		||||
            cacheKey: this.getCacheKeyForConversationMessages(options.userId, conversationId),
 | 
			
		||||
 | 
			
		||||
@ -64,7 +64,7 @@
 | 
			
		||||
                        </ion-item>
 | 
			
		||||
                        <ion-button expand="block" type="submit">
 | 
			
		||||
                            {{ 'addon.mod_lesson.continue' | translate }}
 | 
			
		||||
                            <core-icon slot="end" name="fas-chevron-right"></core-icon>
 | 
			
		||||
                            <ion-icon slot="end" name="fas-chevron-right"></ion-icon>
 | 
			
		||||
                        </ion-button>
 | 
			
		||||
                        <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
 | 
			
		||||
                        <input type="submit" class="core-submit-hidden-enter" />
 | 
			
		||||
 | 
			
		||||
@ -531,7 +531,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (formattedData.lessonscored) {
 | 
			
		||||
            if (formattedData.numofattempts) {
 | 
			
		||||
            if (formattedData.numofattempts && formattedData.avescore != null) {
 | 
			
		||||
                formattedData.avescore = CoreTextUtils.instance.roundToDecimals(formattedData.avescore, 2);
 | 
			
		||||
            }
 | 
			
		||||
            if (formattedData.highscore != null) {
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <core-icon slot="icon-only" name="fas-times"></core-icon>
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-times"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,7 @@
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <core-icon slot="icon-only" name="fas-times"></core-icon>
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-times"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
@ -20,7 +20,7 @@
 | 
			
		||||
        </ion-item>
 | 
			
		||||
        <ion-button expand="block" type="submit">
 | 
			
		||||
            {{ 'addon.mod_lesson.continue' | translate }}
 | 
			
		||||
            <core-icon slot="end" name="fas-chevron-right"></core-icon>
 | 
			
		||||
            <ion-icon slot="end" name="fas-chevron-right"></ion-icon>
 | 
			
		||||
        </ion-button>
 | 
			
		||||
        <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
 | 
			
		||||
        <input type="submit" class="core-submit-hidden-enter" />
 | 
			
		||||
 | 
			
		||||
@ -99,7 +99,7 @@
 | 
			
		||||
                        <ng-container *ngSwitchCase="'multichoice'">
 | 
			
		||||
                            <!-- Single choice. -->
 | 
			
		||||
                            <ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName">
 | 
			
		||||
                                <ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
 | 
			
		||||
                                <ion-item class="ion-text-wrap" *ngFor="let option of question.options">
 | 
			
		||||
                                    <ion-label>
 | 
			
		||||
                                        <core-format-text [component]="component" [componentId]="lesson.coursemodule"
 | 
			
		||||
                                            [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
 | 
			
		||||
@ -113,7 +113,7 @@
 | 
			
		||||
 | 
			
		||||
                            <!-- Multiple choice. -->
 | 
			
		||||
                            <ng-container *ngIf="question.multi">
 | 
			
		||||
                                <ion-item class="ion-text-wrap" *ngFor="let option of question.options" lines="none">
 | 
			
		||||
                                <ion-item class="ion-text-wrap" *ngFor="let option of question.options">
 | 
			
		||||
                                    <ion-label>
 | 
			
		||||
                                        <core-format-text [component]="component" [componentId]="lesson?.coursemodule"
 | 
			
		||||
                                            [text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
 | 
			
		||||
 | 
			
		||||
@ -27,7 +27,7 @@ import { CoreUrlUtils } from '@services/utils/url';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { ModalController, Translate } from '@singletons';
 | 
			
		||||
import { CoreEvents } from '@singletons/events';
 | 
			
		||||
import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModLesson,
 | 
			
		||||
@ -409,7 +409,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave {
 | 
			
		||||
        this.messages = this.messages.concat(data.messages);
 | 
			
		||||
        this.processData = undefined;
 | 
			
		||||
 | 
			
		||||
        CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' });
 | 
			
		||||
        CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' });
 | 
			
		||||
 | 
			
		||||
        // Format activity link if present.
 | 
			
		||||
        if (this.eolData.activitylink) {
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -253,7 +253,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Sync finished, set sync time.
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId));
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(this.setSyncTime(lessonId, siteId));
 | 
			
		||||
 | 
			
		||||
        // All done, return the result.
 | 
			
		||||
        return result;
 | 
			
		||||
 | 
			
		||||
@ -2231,14 +2231,14 @@ export class AddonModLessonProvider {
 | 
			
		||||
     * @param data Data containing the user answer.
 | 
			
		||||
     * @return User response.
 | 
			
		||||
     */
 | 
			
		||||
    protected getUserResponseMultichoice(data: Record<string, unknown>): number[] | null {
 | 
			
		||||
    protected getUserResponseMultichoice(data: Record<string, unknown>): number[] | undefined {
 | 
			
		||||
        if (data.answer) {
 | 
			
		||||
            // The data is already stored as expected. If it's valid, parse the values to int.
 | 
			
		||||
            if (Array.isArray(data.answer)) {
 | 
			
		||||
                return data.answer.map((value) => parseInt(value, 10));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return null;
 | 
			
		||||
            return undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Data is stored in properties like 'answer[379]'. Recreate the answer array.
 | 
			
		||||
@ -3979,12 +3979,12 @@ export type AddonModLessonGetAttemptsOverviewWSResponse = {
 | 
			
		||||
export type AddonModLessonAttemptsOverviewWSData = {
 | 
			
		||||
    lessonscored: boolean; // True if the lesson was scored.
 | 
			
		||||
    numofattempts: number; // Number of attempts.
 | 
			
		||||
    avescore: number; // Average score.
 | 
			
		||||
    highscore: number; // High score.
 | 
			
		||||
    lowscore: number; // Low score.
 | 
			
		||||
    avetime: number; // Average time (spent in taking the lesson).
 | 
			
		||||
    hightime: number; // High time.
 | 
			
		||||
    lowtime: number; // Low time.
 | 
			
		||||
    avescore: number | null; // Average score.
 | 
			
		||||
    highscore: number | null; // High score.
 | 
			
		||||
    lowscore: number | null; // Low score.
 | 
			
		||||
    avetime: number | null; // Average time (spent in taking the lesson).
 | 
			
		||||
    hightime: number | null; // High time.
 | 
			
		||||
    lowtime: number | null; // Low time.
 | 
			
		||||
    students?: AddonModLessonAttemptsOverviewsStudentWSData[]; // Students data, including attempts.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -4003,7 +4003,7 @@ export type AddonModLessonAttemptsOverviewsStudentWSData = {
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModLessonAttemptsOverviewsAttemptWSData = {
 | 
			
		||||
    try: number; // Attempt number.
 | 
			
		||||
    grade: number; // Attempt grade.
 | 
			
		||||
    grade: number | null; // Attempt grade.
 | 
			
		||||
    timestart: number; // Attempt time started.
 | 
			
		||||
    timeend: number; // Attempt last time continued.
 | 
			
		||||
    end: number; // Attempt time ended.
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ import { AddonModAssignModule } from './assign/assign.module';
 | 
			
		||||
import { AddonModBookModule } from './book/book.module';
 | 
			
		||||
import { AddonModLessonModule } from './lesson/lesson.module';
 | 
			
		||||
import { AddonModPageModule } from './page/page.module';
 | 
			
		||||
import { AddonModQuizModule } from './quiz/quiz.module';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [],
 | 
			
		||||
@ -26,6 +27,7 @@ import { AddonModPageModule } from './page/page.module';
 | 
			
		||||
        AddonModBookModule,
 | 
			
		||||
        AddonModLessonModule,
 | 
			
		||||
        AddonModPageModule,
 | 
			
		||||
        AddonModQuizModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [],
 | 
			
		||||
    exports: [],
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/mod/quiz/accessrules/accessrules.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/mod/quiz/accessrules/accessrules.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessDelayBetweenAttemptsModule } from './delaybetweenattempts/delaybetweenattempts.module';
 | 
			
		||||
import { AddonModQuizAccessIpAddressModule } from './ipaddress/ipaddress.module';
 | 
			
		||||
import { AddonModQuizAccessNumAttemptsModule } from './numattempts/numattempts.module';
 | 
			
		||||
import { AddonModQuizAccessOfflineAttemptsModule } from './offlineattempts/offlineattempts.module';
 | 
			
		||||
import { AddonModQuizAccessOpenCloseDateModule } from './openclosedate/openclosedate.module';
 | 
			
		||||
import { AddonModQuizAccessPasswordModule } from './password/password.module';
 | 
			
		||||
import { AddonModQuizAccessSafeBrowserModule } from './safebrowser/safebrowser.module';
 | 
			
		||||
import { AddonModQuizAccessSecureWindowModule } from './securewindow/securewindow.module';
 | 
			
		||||
import { AddonModQuizAccessTimeLimitModule } from './timelimit/timelimit.module';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [],
 | 
			
		||||
    imports: [
 | 
			
		||||
        AddonModQuizAccessDelayBetweenAttemptsModule,
 | 
			
		||||
        AddonModQuizAccessIpAddressModule,
 | 
			
		||||
        AddonModQuizAccessNumAttemptsModule,
 | 
			
		||||
        AddonModQuizAccessOfflineAttemptsModule,
 | 
			
		||||
        AddonModQuizAccessOpenCloseDateModule,
 | 
			
		||||
        AddonModQuizAccessPasswordModule,
 | 
			
		||||
        AddonModQuizAccessSafeBrowserModule,
 | 
			
		||||
        AddonModQuizAccessSecureWindowModule,
 | 
			
		||||
        AddonModQuizAccessTimeLimitModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [],
 | 
			
		||||
    exports: [],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessRulesModule { }
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessDelayBetweenAttemptsHandler } from './services/handlers/delaybetweenattempts';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessDelayBetweenAttemptsHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessDelayBetweenAttemptsModule {}
 | 
			
		||||
@ -0,0 +1,54 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support delay between attempts access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessDelayBetweenAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessDelayBetweenAttempts';
 | 
			
		||||
    ruleName = 'quizaccess_delaybetweenattempts';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessDelayBetweenAttemptsHandler
 | 
			
		||||
    extends makeSingleton(AddonModQuizAccessDelayBetweenAttemptsHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessIpAddressHandler } from './services/handlers/ipaddress';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessIpAddressHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessIpAddressModule {}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support IP address access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessIpAddressHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessIpAddress';
 | 
			
		||||
    ruleName = 'quizaccess_ipaddress';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessIpAddressHandler extends makeSingleton(AddonModQuizAccessIpAddressHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessNumAttemptsHandler } from './services/handlers/numattempts';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessNumAttemptsHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessNumAttemptsModule {}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support num attempts access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessNumAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessNumAttempts';
 | 
			
		||||
    ruleName = 'quizaccess_numattempts';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessNumAttemptsHandler extends makeSingleton(AddonModQuizAccessNumAttemptsHandlerService) {}
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
<ion-item class="ion-text-wrap">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h3 class="item-heading">{{ 'core.settings.synchronization' | translate }}</h3>
 | 
			
		||||
        <p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}</p>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
@ -0,0 +1,56 @@
 | 
			
		||||
// (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 { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync';
 | 
			
		||||
import { Component, OnInit, Input } from '@angular/core';
 | 
			
		||||
import { FormGroup, FormBuilder } from '@angular/forms';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render the preflight for offline attempts.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-access-offline-attempts',
 | 
			
		||||
    templateUrl: 'addon-mod-quiz-access-offline-attempts.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessOfflineAttemptsComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() rule?: string; // The name of the rule.
 | 
			
		||||
    @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
 | 
			
		||||
    @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
 | 
			
		||||
    @Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
 | 
			
		||||
    @Input() siteId?: string; // Site ID.
 | 
			
		||||
    @Input() form?: FormGroup; // Form where to add the form control.
 | 
			
		||||
 | 
			
		||||
    syncTimeReadable = '';
 | 
			
		||||
 | 
			
		||||
    constructor(private fb: FormBuilder) { }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        // Always set confirmdatasaved to 1. Sending the data means the user accepted.
 | 
			
		||||
        this.form?.addControl('confirmdatasaved', this.fb.control(1));
 | 
			
		||||
 | 
			
		||||
        if (!this.quiz) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const time = await AddonModQuizSync.instance.getSyncTime(this.quiz.id);
 | 
			
		||||
 | 
			
		||||
        this.syncTimeReadable = AddonModQuizSync.instance.getReadableTimeFromTimestamp(time);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,43 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { AddonModQuizAccessOfflineAttemptsComponent } from './component/offlineattempts';
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessOfflineAttemptsHandler } from './services/handlers/offlineattempts';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizAccessOfflineAttemptsComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOfflineAttemptsHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonModQuizAccessOfflineAttemptsComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessOfflineAttemptsModule {}
 | 
			
		||||
@ -0,0 +1,102 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { AddonModQuizAccessOfflineAttemptsComponent } from '../../component/offlineattempts';
 | 
			
		||||
import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support offline attempts access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessOfflineAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessOfflineAttempts';
 | 
			
		||||
    ruleName = 'quizaccess_offlineattempts';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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>,
 | 
			
		||||
    ): void | Promise<void> {
 | 
			
		||||
        preflightData.confirmdatasaved = '1';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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>> {
 | 
			
		||||
        return AddonModQuizAccessOfflineAttemptsComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.
 | 
			
		||||
     */
 | 
			
		||||
    async isPreflightCheckRequired(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        if (prefetch) {
 | 
			
		||||
            // Don't show the warning if the user is prefetching.
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!attempt) {
 | 
			
		||||
            // User is starting a new attempt, show the warning.
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const syncTime = await AddonModQuizSync.instance.getSyncTime(quiz.id);
 | 
			
		||||
 | 
			
		||||
        // Show warning if last sync was a while ago.
 | 
			
		||||
        return Date.now() - AddonModQuizSync.instance.syncInterval > syncTime;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessOfflineAttemptsHandler extends makeSingleton(AddonModQuizAccessOfflineAttemptsHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessOpenCloseDateHandler } from './services/handlers/openclosedate';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOpenCloseDateHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessOpenCloseDateModule {}
 | 
			
		||||
@ -0,0 +1,76 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizProvider } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support open/close date access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessOpenCloseDateHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessOpenCloseDate';
 | 
			
		||||
    ruleName = 'quizaccess_openclosedate';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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 {
 | 
			
		||||
        // If this is a teacher preview after the close date, do not show the time.
 | 
			
		||||
        if (attempt.preview && timeNow > endTime) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Show the time left only if it's less than QUIZ_SHOW_TIME_BEFORE_DEADLINE.
 | 
			
		||||
        if (timeNow > endTime - AddonModQuizProvider.QUIZ_SHOW_TIME_BEFORE_DEADLINE) {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessOpenCloseDateHandler extends makeSingleton(AddonModQuizAccessOpenCloseDateHandlerService) {}
 | 
			
		||||
@ -0,0 +1,14 @@
 | 
			
		||||
<ion-item class="ion-text-wrap">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h3 class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</h3>
 | 
			
		||||
        <p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
<ion-item [formGroup]="form">
 | 
			
		||||
    <ion-label></ion-label>
 | 
			
		||||
    <core-show-password [name]="'quizpassword'">
 | 
			
		||||
        <ion-input id="addon-mod_quiz-accessrule-password-input" name="quizpassword" type="password"
 | 
			
		||||
            placeholder="{{ 'addon.mod_quiz.quizpassword' | translate }}" [formControlName]="'quizpassword'" [clearOnEdit]="false">
 | 
			
		||||
        </ion-input>
 | 
			
		||||
    </core-show-password>
 | 
			
		||||
</ion-item>
 | 
			
		||||
@ -0,0 +1,46 @@
 | 
			
		||||
// (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 { Component, OnInit, Input } from '@angular/core';
 | 
			
		||||
import { FormGroup, FormBuilder } from '@angular/forms';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render the preflight for password.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-access-password',
 | 
			
		||||
    templateUrl: 'addon-mod-quiz-access-password.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessPasswordComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() rule?: string; // The name of the rule.
 | 
			
		||||
    @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
 | 
			
		||||
    @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
 | 
			
		||||
    @Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
 | 
			
		||||
    @Input() siteId?: string; // Site ID.
 | 
			
		||||
    @Input() form?: FormGroup; // Form where to add the form control.
 | 
			
		||||
 | 
			
		||||
    constructor(private fb: FormBuilder) { }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        // Add the control for the password.
 | 
			
		||||
        this.form?.addControl('quizpassword', this.fb.control(''));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										51
									
								
								src/addons/mod/quiz/accessrules/password/password.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/addons/mod/quiz/accessrules/password/password.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
// (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 { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CORE_SITE_SCHEMAS } from '@services/sites';
 | 
			
		||||
import { AddonModQuizAccessPasswordComponent } from './component/password';
 | 
			
		||||
import { AddonModQuizAccessPasswordHandler } from './services/handlers/password';
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { SITE_SCHEMA } from './services/database/password';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizAccessPasswordComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: CORE_SITE_SCHEMAS,
 | 
			
		||||
            useValue: [SITE_SCHEMA],
 | 
			
		||||
            multi: true,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessPasswordHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonModQuizAccessPasswordComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessPasswordModule {}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
// (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 AddonModQuizAccessPasswordHandlerService.
 | 
			
		||||
 */
 | 
			
		||||
export const PASSWORD_TABLE_NAME = 'addon_mod_quiz_access_password';
 | 
			
		||||
export const SITE_SCHEMA: CoreSiteSchema = {
 | 
			
		||||
    name: 'AddonModQuizAccessPasswordHandler',
 | 
			
		||||
    version: 1,
 | 
			
		||||
    tables: [
 | 
			
		||||
        {
 | 
			
		||||
            name: PASSWORD_TABLE_NAME,
 | 
			
		||||
            columns: [
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'id',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                    primaryKey: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'password',
 | 
			
		||||
                    type: 'TEXT',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'timemodified',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Quiz attempt.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizAccessPasswordDBRecord = {
 | 
			
		||||
    id: number;
 | 
			
		||||
    password: string;
 | 
			
		||||
    timemodified: number;
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,198 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { AddonModQuizAccessPasswordDBRecord, PASSWORD_TABLE_NAME } from '../database/password';
 | 
			
		||||
import { AddonModQuizAccessPasswordComponent } from '../../component/password';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support password access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessPasswordHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessPassword';
 | 
			
		||||
    ruleName = 'quizaccess_password';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.
 | 
			
		||||
     */
 | 
			
		||||
    async getFixedPreflightData(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        if (typeof preflightData.quizpassword != 'undefined') {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Try to get a password stored. If it's found, use it.
 | 
			
		||||
            const entry = await this.getPasswordEntry(quiz.id, siteId);
 | 
			
		||||
 | 
			
		||||
            preflightData.quizpassword = entry.password;
 | 
			
		||||
        } catch {
 | 
			
		||||
            // No password stored.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a password stored in DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quizId Quiz ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with the DB entry on success.
 | 
			
		||||
     */
 | 
			
		||||
    protected async getPasswordEntry(quizId: number, siteId?: string): Promise<AddonModQuizAccessPasswordDBRecord> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        return site.getDb().getRecord(PASSWORD_TABLE_NAME, { id: quizId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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>> {
 | 
			
		||||
        return AddonModQuizAccessPasswordComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.
 | 
			
		||||
     */
 | 
			
		||||
    async isPreflightCheckRequired(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        // If there's a password stored don't require the preflight since we'll use the stored one.
 | 
			
		||||
        const entry = await CoreUtils.instance.ignoreErrors(this.getPasswordEntry(quiz.id, siteId));
 | 
			
		||||
 | 
			
		||||
        return !entry;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.
 | 
			
		||||
     */
 | 
			
		||||
    async notifyPreflightCheckPassed(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData | undefined,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        // The password is right, store it to use it automatically in following executions.
 | 
			
		||||
        if (typeof preflightData.quizpassword != 'undefined') {
 | 
			
		||||
            return this.storePassword(quiz.id, preflightData.quizpassword, siteId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        // The password is wrong, remove it from DB if it's there.
 | 
			
		||||
        return this.removePassword(quiz.id, siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a password from DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quizId Quiz ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async removePassword(quizId: number, siteId?: string): Promise<void> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        await site.getDb().deleteRecords(PASSWORD_TABLE_NAME, { id: quizId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Store a password in DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quizId Quiz ID.
 | 
			
		||||
     * @param password Password.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async storePassword(quizId: number, password: string, siteId?: string): Promise<void> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        const entry: AddonModQuizAccessPasswordDBRecord = {
 | 
			
		||||
            id: quizId,
 | 
			
		||||
            password,
 | 
			
		||||
            timemodified: Date.now(),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await site.getDb().insertRecord(PASSWORD_TABLE_NAME, entry);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessPasswordHandler extends makeSingleton(AddonModQuizAccessPasswordHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessSafeBrowserHandler } from './services/handlers/safebrowser';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSafeBrowserHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessSafeBrowserModule {}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support safe address access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessSafeBrowserHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessSafeBrowser';
 | 
			
		||||
    ruleName = 'quizaccess_safebrowser';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessSafeBrowserHandler extends makeSingleton(AddonModQuizAccessSafeBrowserHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessSecureWindowHandler } from './services/handlers/securewindow';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSecureWindowHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessSecureWindowModule {}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support secure window access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessSecureWindowHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessSecureWindow';
 | 
			
		||||
    ruleName = 'quizaccess_securewindow';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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(): boolean | Promise<boolean> {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessSecureWindowHandler extends makeSingleton(AddonModQuizAccessSecureWindowHandlerService) {}
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
<ion-item class="ion-text-wrap">
 | 
			
		||||
    <ion-label>
 | 
			
		||||
        <h3 class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</h3>
 | 
			
		||||
        <p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}</p>
 | 
			
		||||
    </ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
@ -0,0 +1,47 @@
 | 
			
		||||
// (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 { Component, Input, OnInit } from '@angular/core';
 | 
			
		||||
import { FormGroup } from '@angular/forms';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render the preflight for time limit.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-access-time-limit',
 | 
			
		||||
    templateUrl: 'addon-mod-quiz-access-time-limit.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessTimeLimitComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @Input() rule?: string; // The name of the rule.
 | 
			
		||||
    @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
 | 
			
		||||
    @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
 | 
			
		||||
    @Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
 | 
			
		||||
    @Input() siteId?: string; // Site ID.
 | 
			
		||||
    @Input() form?: FormGroup; // Form where to add the form control.
 | 
			
		||||
 | 
			
		||||
    readableTimeLimit = '';
 | 
			
		||||
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        if (!this.quiz?.timelimit) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz?.timelimit);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -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 { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
 | 
			
		||||
import { AddonModQuizAccessTimeLimitComponent } from '../../component/timelimit';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support time limit access rule.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessTimeLimitHandlerService implements AddonModQuizAccessRuleHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizAccessTimeLimit';
 | 
			
		||||
    ruleName = 'quizaccess_timelimit';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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>> {
 | 
			
		||||
        return AddonModQuizAccessTimeLimitComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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,
 | 
			
		||||
    ): boolean | Promise<boolean> {
 | 
			
		||||
        // Warning only required if the attempt is not already started.
 | 
			
		||||
        return !attempt;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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 {
 | 
			
		||||
        // If this is a teacher preview after the time limit expires, don't show the time left.
 | 
			
		||||
        return !(attempt.preview && timeNow > endTime);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessTimeLimitHandler extends makeSingleton(AddonModQuizAccessTimeLimitHandlerService) {}
 | 
			
		||||
@ -0,0 +1,43 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { AddonModQuizAccessTimeLimitComponent } from './component/timelimit';
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAccessTimeLimitHandler } from './services/handlers/timelimit';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizAccessTimeLimitComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessTimeLimitHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonModQuizAccessTimeLimitComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAccessTimeLimitModule {}
 | 
			
		||||
							
								
								
									
										254
									
								
								src/addons/mod/quiz/classes/auto-save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								src/addons/mod/quiz/classes/auto-save.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,254 @@
 | 
			
		||||
// (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 { BehaviorSubject } from 'rxjs';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { PopoverController } from '@singletons';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { AddonModQuizConnectionErrorComponent } from '../components/connection-error/connection-error';
 | 
			
		||||
import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../services/quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class to support auto-save in quiz. Every certain seconds, it will check if there are changes in the current page answers
 | 
			
		||||
 * and, if so, it will save them automatically.
 | 
			
		||||
 */
 | 
			
		||||
export class AddonModQuizAutoSave {
 | 
			
		||||
 | 
			
		||||
    protected readonly CHECK_CHANGES_INTERVAL = 5000;
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
    protected checkChangesInterval?: number; // Interval to check if there are changes in the answers.
 | 
			
		||||
    protected loadPreviousAnswersTimeout?: number; // Timeout to load previous answers.
 | 
			
		||||
    protected autoSaveTimeout?: number; // Timeout to auto-save the answers.
 | 
			
		||||
    protected popover?: HTMLIonPopoverElement; // Popover to display there's been an error.
 | 
			
		||||
    protected popoverShown = false; // Whether the popover is shown.
 | 
			
		||||
    protected previousAnswers?: CoreQuestionsAnswers; // The previous answers, to check if answers have changed.
 | 
			
		||||
    protected errorObservable: BehaviorSubject<boolean>; // An observable to notify if there's been an error.
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructor.
 | 
			
		||||
     *
 | 
			
		||||
     * @param formName Name of the form where the answers are stored.
 | 
			
		||||
     * @param buttonSelector Selector to find the button to show the connection error.
 | 
			
		||||
     */
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected formName: string,
 | 
			
		||||
        protected buttonSelector: string,
 | 
			
		||||
    ) {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonModQuizAutoSave');
 | 
			
		||||
 | 
			
		||||
        // Create the observable to notify if an error happened.
 | 
			
		||||
        this.errorObservable = new BehaviorSubject<boolean>(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Cancel a pending auto save.
 | 
			
		||||
     */
 | 
			
		||||
    cancelAutoSave(): void {
 | 
			
		||||
        clearTimeout(this.autoSaveTimeout);
 | 
			
		||||
        this.autoSaveTimeout = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the answers have changed in a page.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight data.
 | 
			
		||||
     * @param offline Whether the quiz is being attempted in offline mode.
 | 
			
		||||
     */
 | 
			
		||||
    checkChanges(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        offline?: boolean,
 | 
			
		||||
    ): void {
 | 
			
		||||
        if (this.autoSaveTimeout) {
 | 
			
		||||
            // We already have an auto save pending, no need to check changes.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const answers = this.getAnswers();
 | 
			
		||||
 | 
			
		||||
        if (!this.previousAnswers) {
 | 
			
		||||
            // Previous answers isn't set, set it now.
 | 
			
		||||
            this.previousAnswers = answers;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if answers have changed.
 | 
			
		||||
        let equal = true;
 | 
			
		||||
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            if (this.previousAnswers[name] != answers[name]) {
 | 
			
		||||
                equal = false;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!equal) {
 | 
			
		||||
            this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.previousAnswers = answers;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get answers from a form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Answers.
 | 
			
		||||
     */
 | 
			
		||||
    protected getAnswers(): CoreQuestionsAnswers {
 | 
			
		||||
        return CoreQuestionHelper.instance.getAnswersFromForm(document.forms[this.formName]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Hide the auto save error.
 | 
			
		||||
     */
 | 
			
		||||
    hideAutoSaveError(): void {
 | 
			
		||||
        this.errorObservable.next(false);
 | 
			
		||||
        this.popover?.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns an observable that will notify when an error happens or stops.
 | 
			
		||||
     * It will send true when there's an error, and false when the error has been ammended.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Observable.
 | 
			
		||||
     */
 | 
			
		||||
    onError(): BehaviorSubject<boolean> {
 | 
			
		||||
        return this.errorObservable;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Schedule an auto save process if it's not scheduled already.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight data.
 | 
			
		||||
     * @param offline Whether the quiz is being attempted in offline mode.
 | 
			
		||||
     */
 | 
			
		||||
    setAutoSaveTimer(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        offline?: boolean,
 | 
			
		||||
    ): void {
 | 
			
		||||
        // Don't schedule if already shceduled or quiz is almost closed.
 | 
			
		||||
        if (!quiz.autosaveperiod || this.autoSaveTimeout || AddonModQuiz.instance.isAttemptTimeNearlyOver(quiz, attempt)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Schedule save.
 | 
			
		||||
        this.autoSaveTimeout = window.setTimeout(async () => {
 | 
			
		||||
            const answers = this.getAnswers();
 | 
			
		||||
            this.cancelAutoSave();
 | 
			
		||||
            this.previousAnswers = answers; // Update previous answers to match what we're sending to the server.
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                await AddonModQuiz.instance.saveAttempt(quiz, attempt, answers, preflightData, offline);
 | 
			
		||||
 | 
			
		||||
                // Save successful, we can hide the connection error if it was shown.
 | 
			
		||||
                this.hideAutoSaveError();
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                // Error auto-saving. Show error and set timer again.
 | 
			
		||||
                this.logger.warn('Error auto-saving data.', error);
 | 
			
		||||
 | 
			
		||||
                // If there was no error already, show the error message.
 | 
			
		||||
                if (!this.errorObservable.getValue()) {
 | 
			
		||||
                    this.errorObservable.next(true);
 | 
			
		||||
                    this.showAutoSaveError();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Try again.
 | 
			
		||||
                this.setAutoSaveTimer(quiz, attempt, preflightData, offline);
 | 
			
		||||
            }
 | 
			
		||||
        }, quiz.autosaveperiod * 1000);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show an error popover due to an auto save error.
 | 
			
		||||
     */
 | 
			
		||||
    async showAutoSaveError(ev?: Event): Promise<void> {
 | 
			
		||||
        // Don't show popover if it was already shown.
 | 
			
		||||
        if (this.popoverShown) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const event: unknown = ev || {
 | 
			
		||||
            // Cannot use new Event() because event's target property is readonly
 | 
			
		||||
            target: document.querySelector(this.buttonSelector),
 | 
			
		||||
            stopPropagation: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
 | 
			
		||||
            preventDefault: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
 | 
			
		||||
        };
 | 
			
		||||
        this.popoverShown = true;
 | 
			
		||||
 | 
			
		||||
        this.popover = await PopoverController.instance.create({
 | 
			
		||||
            component: AddonModQuizConnectionErrorComponent,
 | 
			
		||||
            event: <Event> event,
 | 
			
		||||
        });
 | 
			
		||||
        await this.popover.present();
 | 
			
		||||
 | 
			
		||||
        await this.popover.onDidDismiss();
 | 
			
		||||
 | 
			
		||||
        this.popoverShown = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start a process to periodically check changes in answers.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight data.
 | 
			
		||||
     * @param offline Whether the quiz is being attempted in offline mode.
 | 
			
		||||
     */
 | 
			
		||||
    startCheckChangesProcess(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        offline?: boolean,
 | 
			
		||||
    ): void {
 | 
			
		||||
        if (this.checkChangesInterval || !quiz.autosaveperiod) {
 | 
			
		||||
            // We already have the interval in place or the quiz has autosave disabled.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.previousAnswers = undefined;
 | 
			
		||||
 | 
			
		||||
        // Load initial answers in 2.5 seconds so the first check interval finds them already loaded.
 | 
			
		||||
        this.loadPreviousAnswersTimeout = window.setTimeout(() => {
 | 
			
		||||
            this.checkChanges(quiz, attempt, preflightData, offline);
 | 
			
		||||
        }, 2500);
 | 
			
		||||
 | 
			
		||||
        // Check changes every certain time.
 | 
			
		||||
        this.checkChangesInterval = window.setInterval(() => {
 | 
			
		||||
            this.checkChanges(quiz, attempt, preflightData, offline);
 | 
			
		||||
        }, this.CHECK_CHANGES_INTERVAL);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Stops the periodical check for changes.
 | 
			
		||||
     */
 | 
			
		||||
    stopCheckChangesProcess(): void {
 | 
			
		||||
        clearTimeout(this.loadPreviousAnswersTimeout);
 | 
			
		||||
        clearInterval(this.checkChangesInterval);
 | 
			
		||||
 | 
			
		||||
        this.loadPreviousAnswersTimeout = undefined;
 | 
			
		||||
        this.checkChangesInterval = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								src/addons/mod/quiz/components/components.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/addons/mod/quiz/components/components.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
 | 
			
		||||
import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error';
 | 
			
		||||
import { AddonModQuizIndexComponent } from './index/index';
 | 
			
		||||
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
 | 
			
		||||
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizIndexComponent,
 | 
			
		||||
        AddonModQuizConnectionErrorComponent,
 | 
			
		||||
        AddonModQuizNavigationModalComponent,
 | 
			
		||||
        AddonModQuizPreflightModalComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        CoreCourseComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonModQuizIndexComponent,
 | 
			
		||||
        AddonModQuizConnectionErrorComponent,
 | 
			
		||||
        AddonModQuizNavigationModalComponent,
 | 
			
		||||
        AddonModQuizPreflightModalComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizComponentsModule {}
 | 
			
		||||
@ -0,0 +1,3 @@
 | 
			
		||||
<ion-item class="ion-text-wrap">
 | 
			
		||||
    <ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label>
 | 
			
		||||
</ion-item>
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
:host {
 | 
			
		||||
    background-color: var(--red-light);
 | 
			
		||||
 | 
			
		||||
    .item {
 | 
			
		||||
        --background: var(--red-light);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,27 @@
 | 
			
		||||
// (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 { Component } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component that displays a quiz entry page.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-connection-error',
 | 
			
		||||
    templateUrl: 'connection-error.html',
 | 
			
		||||
    styleUrls: ['connection-error.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizConnectionErrorComponent {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										199
									
								
								src/addons/mod/quiz/components/index/addon-mod-quiz-index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								src/addons/mod/quiz/components/index/addon-mod-quiz-index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,199 @@
 | 
			
		||||
<!-- Buttons to add to the header. -->
 | 
			
		||||
<core-navbar-buttons slot="end">
 | 
			
		||||
    <core-context-menu>
 | 
			
		||||
        <core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
 | 
			
		||||
            [href]="externalUrl" iconAction="fas-external-link-alt">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
 | 
			
		||||
            (action)="expandDescription()" iconAction="fas-arrow-right">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
 | 
			
		||||
            [iconAction]="'far-newspaper'" (action)="gotoBlog()">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
 | 
			
		||||
            (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="loaded && hasOffline && isOnline"  [priority]="600" (action)="doRefresh(null, $event, true)"
 | 
			
		||||
            [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
 | 
			
		||||
            [iconAction]="prefetchStatusIcon" [closeOnClick]="false">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
        <core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
 | 
			
		||||
            iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
 | 
			
		||||
        </core-context-menu-item>
 | 
			
		||||
    </core-context-menu>
 | 
			
		||||
</core-navbar-buttons>
 | 
			
		||||
 | 
			
		||||
<!-- Content. -->
 | 
			
		||||
<core-loading [hideUntil]="loaded" class="core-loading-center">
 | 
			
		||||
    <core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
 | 
			
		||||
        contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
 | 
			
		||||
    </core-course-module-description>
 | 
			
		||||
 | 
			
		||||
    <!-- Access rules description messages. -->
 | 
			
		||||
    <ion-card *ngIf="gradeMethodReadable || accessRules.length || syncTime">
 | 
			
		||||
        <ion-list>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngFor="let rule of accessRules">
 | 
			
		||||
                <ion-label><p>{{ rule }}</p></ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="gradeMethodReadable">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h3>{{ 'addon.mod_quiz.grademethod' | translate }}</h3>
 | 
			
		||||
                    <p>{{ gradeMethodReadable }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="syncTime">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h3>{{ 'core.lastsync' | translate }}</h3>
 | 
			
		||||
                    <p>{{ syncTime }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-list>
 | 
			
		||||
    </ion-card>
 | 
			
		||||
 | 
			
		||||
    <!-- List of user attempts. -->
 | 
			
		||||
    <ion-card class="addon-mod_quiz-table" *ngIf="quiz && attempts.length">
 | 
			
		||||
        <ion-card-header class="ion-text-wrap">
 | 
			
		||||
            <ion-card-header>
 | 
			
		||||
                <ion-card-title>{{ 'addon.mod_quiz.summaryofattempts' | translate }}</ion-card-title>
 | 
			
		||||
            </ion-card-header>
 | 
			
		||||
        </ion-card-header>
 | 
			
		||||
        <ion-card-content>
 | 
			
		||||
            <!-- "Header" of the table -->
 | 
			
		||||
            <ion-item class="ion-text-wrap addon-mod_quiz-table-header" detail="true">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <ion-row class="ion-align-items-center">
 | 
			
		||||
                        <ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showAttemptColumn">
 | 
			
		||||
                            <strong>{{ 'addon.mod_quiz.attemptnumber' | translate }}</strong>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center ion-hide-md-up" *ngIf="quiz.showAttemptColumn"><strong>#</strong></ion-col>
 | 
			
		||||
                        <ion-col size="7"><strong>{{ 'addon.mod_quiz.attemptstate' | translate }}</strong></ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn">
 | 
			
		||||
                            <strong>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }}</strong>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn">
 | 
			
		||||
                            <strong>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }}</strong>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                    </ion-row>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <!-- List of attempts. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngFor="let attempt of attempts" button detail="true"
 | 
			
		||||
                [ngClass]='{"addon-mod_quiz-highlighted": attempt.highlightGrade}'
 | 
			
		||||
                [attr.aria-label]="'core.seemoredetail' | translate" (click)="viewAttempt(attempt.id)">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <ion-row class="ion-align-items-center">
 | 
			
		||||
                        <ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && attempt.preview">
 | 
			
		||||
                            {{ 'addon.mod_quiz.preview' | translate }}
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && !attempt.preview">
 | 
			
		||||
                            {{ attempt.attempt }}
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col size="7">
 | 
			
		||||
                            <p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn">
 | 
			
		||||
                            <p>{{ attempt.readableMark }}</p>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn"><p>{{ attempt.readableGrade }}</p></ion-col>
 | 
			
		||||
                    </ion-row>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-card-content>
 | 
			
		||||
    </ion-card>
 | 
			
		||||
 | 
			
		||||
    <!-- Result info. -->
 | 
			
		||||
    <ion-card *ngIf="quiz && showResults &&
 | 
			
		||||
        (gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedbackColumn && overallFeedback))">
 | 
			
		||||
        <ion-list>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="gradeResult">
 | 
			
		||||
                <ion-label>{{ gradeResult }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="gradeOverridden">
 | 
			
		||||
                <ion-label>{{ 'core.course.overriddennotice' | translate }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="gradebookFeedback">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h3 class="item-heading">{{ 'addon.mod_quiz.comment' | translate }}</h3>
 | 
			
		||||
                    <p><core-format-text [component]="component" [componentId]="componentId" [text]="gradebookFeedback"
 | 
			
		||||
                        contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
 | 
			
		||||
                    </core-format-text></p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="quiz.showFeedbackColumn && overallFeedback">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h3 class="item-heading">{{ 'addon.mod_quiz.overallfeedback' | translate }}</h3>
 | 
			
		||||
                    <p><core-format-text [component]="component" [componentId]="componentId" [text]="overallFeedback"
 | 
			
		||||
                        contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
 | 
			
		||||
                    </core-format-text></p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-list>
 | 
			
		||||
    </ion-card>
 | 
			
		||||
 | 
			
		||||
    <!-- More data and button to start/continue. -->
 | 
			
		||||
    <ion-card *ngIf="quiz">
 | 
			
		||||
        <ion-list>
 | 
			
		||||
            <!-- Error messages. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngFor="let message of preventMessages">
 | 
			
		||||
                <ion-label><p>{{ message }}</p></ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngIf="quiz.hasquestions === 0">
 | 
			
		||||
                <ion-label><p>{{ 'addon.mod_quiz.noquestions' | translate }}</p></ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngIf="!hasSupportedQuestions && unsupportedQuestions.length">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.errorquestionsnotsupported' | translate }}</p>
 | 
			
		||||
                    <p *ngFor="let type of unsupportedQuestions">{{ type }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngIf="unsupportedRules.length">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.errorrulesnotsupported' | translate }}</p>
 | 
			
		||||
                    <p *ngFor="let name of unsupportedRules">{{ name }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngIf="behaviourSupported === false">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.errorbehaviournotsupported' | translate }}</p>
 | 
			
		||||
                    <p>{{ quiz.preferredbehaviour }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- Quiz has data to be synchronized -->
 | 
			
		||||
            <ion-card class="core-warning-card" *ngIf="buttonText && hasOffline && !showStatusSpinner">
 | 
			
		||||
                <ion-item class="ion-text-wrap">
 | 
			
		||||
                    <ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
 | 
			
		||||
                    <ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-card>
 | 
			
		||||
 | 
			
		||||
            <!-- Other warnings. -->
 | 
			
		||||
            <ion-item class="core-warning-item ion-text-wrap" *ngIf="hasSupportedQuestions && unsupportedQuestions.length">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.canattemptbutnotsubmit' | translate }}</p>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.warningquestionsnotsupported' | translate }}</p>
 | 
			
		||||
                    <p *ngFor="let type of unsupportedQuestions">{{ type }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- Button to start/continue. -->
 | 
			
		||||
            <ion-button *ngIf="buttonText && !showStatusSpinner" expand="block" (click)="attemptQuiz()" class="ion-margin">
 | 
			
		||||
                {{ buttonText | translate }}
 | 
			
		||||
            </ion-button>
 | 
			
		||||
 | 
			
		||||
            <!-- Button to open in browser if it cannot be attempted in the app. -->
 | 
			
		||||
            <ion-button class="ion-margin" *ngIf="!buttonText && ((!hasSupportedQuestions && unsupportedQuestions.length) ||
 | 
			
		||||
                unsupportedRules.length || behaviourSupported === false)" expand="block" [href]="externalUrl" core-link>
 | 
			
		||||
                {{ 'core.openinbrowser' | translate }}
 | 
			
		||||
                <ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
 | 
			
		||||
            <!-- Spinner shown while downloading or calculating. -->
 | 
			
		||||
            <ion-item class="ion-text-center" *ngIf="showStatusSpinner">
 | 
			
		||||
                <ion-label><ion-spinner></ion-spinner></ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-list>
 | 
			
		||||
    </ion-card>
 | 
			
		||||
</core-loading>
 | 
			
		||||
							
								
								
									
										37
									
								
								src/addons/mod/quiz/components/index/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/addons/mod/quiz/components/index/index.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
:host {
 | 
			
		||||
 | 
			
		||||
    .addon-mod_quiz-table {
 | 
			
		||||
        .addon-mod_quiz-table-header {
 | 
			
		||||
            --detail-icon-opacity: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ion-card-content {
 | 
			
		||||
            padding-left: 0;
 | 
			
		||||
            padding-right: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .item:nth-child(even) {
 | 
			
		||||
            --background: var(--light);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .addon-mod_quiz-highlighted,
 | 
			
		||||
        .item.addon-mod_quiz-highlighted,
 | 
			
		||||
        .addon-mod_quiz-highlighted p,
 | 
			
		||||
        .item.addon-mod_quiz-highlighted p {
 | 
			
		||||
            --background: var(--blue-light);
 | 
			
		||||
            color: var(--blue-dark);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host-context(body.dark) {
 | 
			
		||||
    .addon-mod_quiz-table {
 | 
			
		||||
        .addon-mod_quiz-highlighted,
 | 
			
		||||
        .item.addon-mod_quiz-highlighted,
 | 
			
		||||
        .addon-mod_quiz-highlighted p,
 | 
			
		||||
        .item.addon-mod_quiz-highlighted p {
 | 
			
		||||
            --background: var(--blue-dark);
 | 
			
		||||
            color: var(--blue-light);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										674
									
								
								src/addons/mod/quiz/components/index/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										674
									
								
								src/addons/mod/quiz/components/index/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,674 @@
 | 
			
		||||
// (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 { CoreConstants } from '@/core/constants';
 | 
			
		||||
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
 | 
			
		||||
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
 | 
			
		||||
import { CoreCourse } from '@features/course/services/course';
 | 
			
		||||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { IonContent } from '@ionic/angular';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { Translate } from '@singletons';
 | 
			
		||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { AddonModQuizPrefetchHandler } from '../../services/handlers/prefetch';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuiz,
 | 
			
		||||
    AddonModQuizAttemptFinishedData,
 | 
			
		||||
    AddonModQuizAttemptWSData,
 | 
			
		||||
    AddonModQuizCombinedReviewOptions,
 | 
			
		||||
    AddonModQuizGetAttemptAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizGetUserBestGradeWSResponse,
 | 
			
		||||
    AddonModQuizProvider,
 | 
			
		||||
} from '../../services/quiz';
 | 
			
		||||
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuizAutoSyncData,
 | 
			
		||||
    AddonModQuizSync,
 | 
			
		||||
    AddonModQuizSyncProvider,
 | 
			
		||||
    AddonModQuizSyncResult,
 | 
			
		||||
} from '../../services/quiz-sync';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component that displays a quiz entry page.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-index',
 | 
			
		||||
    templateUrl: 'addon-mod-quiz-index.html',
 | 
			
		||||
    styleUrls: ['index.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    component = AddonModQuizProvider.COMPONENT;
 | 
			
		||||
    moduleName = 'quiz';
 | 
			
		||||
    quiz?: AddonModQuizQuizData; // The quiz.
 | 
			
		||||
    now?: number; // Current time.
 | 
			
		||||
    syncTime?: string; // Last synchronization time.
 | 
			
		||||
    hasOffline = false; // Whether the quiz has offline data.
 | 
			
		||||
    hasSupportedQuestions = false; // Whether the quiz has at least 1 supported question.
 | 
			
		||||
    accessRules: string[] = []; // List of access rules of the quiz.
 | 
			
		||||
    unsupportedRules: string[] = []; // List of unsupported access rules of the quiz.
 | 
			
		||||
    unsupportedQuestions: string[] = []; // List of unsupported question types of the quiz.
 | 
			
		||||
    behaviourSupported = false; // Whether the quiz behaviour is supported.
 | 
			
		||||
    showResults = false; // Whether to show the result of the quiz (grade, etc.).
 | 
			
		||||
    gradeOverridden = false; // Whether grade has been overridden.
 | 
			
		||||
    gradebookFeedback?: string; // The feedback in the gradebook.
 | 
			
		||||
    gradeResult?: string; // Message with the grade.
 | 
			
		||||
    overallFeedback?: string; // The feedback for the grade.
 | 
			
		||||
    buttonText?: string; // Text to display in the start/continue button.
 | 
			
		||||
    preventMessages: string[] = []; // List of messages explaining why the quiz cannot be attempted.
 | 
			
		||||
    showStatusSpinner = true; // Whether to show a spinner due to quiz status.
 | 
			
		||||
    gradeMethodReadable?: string; // Grade method in a readable format.
 | 
			
		||||
    showReviewColumn = false; // Whether to show the review column.
 | 
			
		||||
    attempts: AddonModQuizAttempt[] = []; // List of attempts the user has made.
 | 
			
		||||
 | 
			
		||||
    protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents.
 | 
			
		||||
    protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED;
 | 
			
		||||
 | 
			
		||||
    // protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown
 | 
			
		||||
    protected autoReview?: AddonModQuizAttemptFinishedData; // Data to auto-review an attempt after finishing.
 | 
			
		||||
    protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access info.
 | 
			
		||||
    protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info.
 | 
			
		||||
    protected moreAttempts = false; // Whether user can create/continue attempts.
 | 
			
		||||
    protected options?: AddonModQuizCombinedReviewOptions; // Combined review options.
 | 
			
		||||
    protected bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data.
 | 
			
		||||
    protected gradebookData?: { grade?: number; feedback?: string }; // The gradebook grade and feedback.
 | 
			
		||||
    protected overallStats = false; // Equivalent to overallstats in mod_quiz_view_object in Moodle.
 | 
			
		||||
    protected finishedObserver?: CoreEventObserver; // It will observe attempt finished events.
 | 
			
		||||
    protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted).
 | 
			
		||||
    protected candidateQuiz?: AddonModQuizQuizData;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected content?: IonContent,
 | 
			
		||||
        @Optional() courseContentsPage?: CoreCourseContentsPage,
 | 
			
		||||
    ) {
 | 
			
		||||
        super('AddonModQuizIndexComponent', content, courseContentsPage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        super.ngOnInit();
 | 
			
		||||
 | 
			
		||||
        // Listen for attempt finished events.
 | 
			
		||||
        this.finishedObserver = CoreEvents.on<AddonModQuizAttemptFinishedData>(
 | 
			
		||||
            AddonModQuizProvider.ATTEMPT_FINISHED_EVENT,
 | 
			
		||||
            (data) => {
 | 
			
		||||
                // Go to review attempt if an attempt in this quiz was finished and synced.
 | 
			
		||||
                if (this.quiz && data.quizId == this.quiz.id) {
 | 
			
		||||
                    this.autoReview = data;
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            this.siteId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        await this.loadContent(false, true);
 | 
			
		||||
 | 
			
		||||
        if (!this.quiz) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await AddonModQuiz.instance.logViewQuiz(this.quiz.id, this.quiz.name);
 | 
			
		||||
 | 
			
		||||
            CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attempt the quiz.
 | 
			
		||||
     */
 | 
			
		||||
    async attemptQuiz(): Promise<void> {
 | 
			
		||||
        if (this.showStatusSpinner || !this.quiz) {
 | 
			
		||||
            // Quiz is being downloaded or synchronized, abort.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!AddonModQuiz.instance.isQuizOffline(this.quiz)) {
 | 
			
		||||
            // Quiz isn't offline, just open it.
 | 
			
		||||
            return this.openQuiz();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Quiz supports offline, check if it needs to be downloaded.
 | 
			
		||||
        // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new.
 | 
			
		||||
        const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED;
 | 
			
		||||
 | 
			
		||||
        if (isDownloaded && CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) {
 | 
			
		||||
            // Already downloaded, open it.
 | 
			
		||||
            return this.openQuiz();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Prefetch the quiz.
 | 
			
		||||
        this.showStatusSpinner = true;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await AddonModQuizPrefetchHandler.instance.prefetch(this.module!, this.courseId, true);
 | 
			
		||||
 | 
			
		||||
            // Success downloading, open quiz.
 | 
			
		||||
            this.openQuiz();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (this.hasOffline || (isDownloaded && !CoreCourseModulePrefetchDelegate.instance.canCheckUpdates())) {
 | 
			
		||||
                // Error downloading but there is something offline, allow continuing it.
 | 
			
		||||
                // If the site doesn't support check updates, continue too because we cannot tell if there's something new.
 | 
			
		||||
                this.openQuiz();
 | 
			
		||||
            } else {
 | 
			
		||||
                CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true);
 | 
			
		||||
            }
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.showStatusSpinner = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the quiz data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param refresh If it's refreshing content.
 | 
			
		||||
     * @param sync If it should try to sync.
 | 
			
		||||
     * @param showErrors If show errors to the user of hide them.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            // First get the quiz instance.
 | 
			
		||||
            const quiz = await AddonModQuiz.instance.getQuiz(this.courseId!, this.module!.id);
 | 
			
		||||
 | 
			
		||||
            this.gradeMethodReadable = AddonModQuiz.instance.getQuizGradeMethod(quiz.grademethod);
 | 
			
		||||
            this.now = Date.now();
 | 
			
		||||
            this.dataRetrieved.emit(quiz);
 | 
			
		||||
            this.description = quiz.intro || this.description;
 | 
			
		||||
            this.candidateQuiz = quiz;
 | 
			
		||||
 | 
			
		||||
            // Try to get warnings from automatic sync.
 | 
			
		||||
            const warnings = await AddonModQuizSync.instance.getSyncWarnings(quiz.id);
 | 
			
		||||
 | 
			
		||||
            if (warnings?.length) {
 | 
			
		||||
                // Show warnings and delete them so they aren't shown again.
 | 
			
		||||
                CoreDomUtils.instance.showErrorModal(CoreTextUtils.instance.buildMessage(warnings));
 | 
			
		||||
 | 
			
		||||
                await AddonModQuizSync.instance.setSyncWarnings(quiz.id, []);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (AddonModQuiz.instance.isQuizOffline(quiz) && sync) {
 | 
			
		||||
                // Try to sync the quiz.
 | 
			
		||||
                try {
 | 
			
		||||
                    await this.syncActivity(showErrors);
 | 
			
		||||
                } catch {
 | 
			
		||||
                    // Ignore errors, keep getting data even if sync fails.
 | 
			
		||||
                    this.autoReview = undefined;
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                this.autoReview = undefined;
 | 
			
		||||
                this.showStatusSpinner = false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (AddonModQuiz.instance.isQuizOffline(quiz)) {
 | 
			
		||||
                // Handle status.
 | 
			
		||||
                this.setStatusListener();
 | 
			
		||||
 | 
			
		||||
                // Get last synchronization time and check if sync button should be seen.
 | 
			
		||||
                this.syncTime = await AddonModQuizSync.instance.getReadableSyncTime(quiz.id);
 | 
			
		||||
                this.hasOffline = await AddonModQuizSync.instance.hasDataToSync(quiz.id);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get quiz access info.
 | 
			
		||||
            this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, { cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
            this.showReviewColumn = this.quizAccessInfo.canreviewmyattempts;
 | 
			
		||||
            this.accessRules = this.quizAccessInfo.accessrules;
 | 
			
		||||
            this.unsupportedRules = AddonModQuiz.instance.getUnsupportedRules(this.quizAccessInfo.activerulenames);
 | 
			
		||||
 | 
			
		||||
            if (quiz.preferredbehaviour) {
 | 
			
		||||
                this.behaviourSupported = CoreQuestionBehaviourDelegate.instance.isBehaviourSupported(quiz.preferredbehaviour);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get question types in the quiz.
 | 
			
		||||
            const types = await AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, { cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
            this.unsupportedQuestions = AddonModQuiz.instance.getUnsupportedQuestions(types);
 | 
			
		||||
            this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1);
 | 
			
		||||
 | 
			
		||||
            await this.getAttempts(quiz);
 | 
			
		||||
 | 
			
		||||
            // Quiz is ready to be shown, move it to the variable that is displayed.
 | 
			
		||||
            this.quiz = quiz;
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.fillContextMenu(refresh);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the user attempts in the quiz and the result info.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz instance.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async getAttempts(quiz: AddonModQuizQuizData): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        // Get access information of last attempt (it also works if no attempts made).
 | 
			
		||||
        this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, { cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
        // Get attempts.
 | 
			
		||||
        const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
        this.attempts = await this.treatAttempts(quiz, attempts);
 | 
			
		||||
 | 
			
		||||
        // Check if user can create/continue attempts.
 | 
			
		||||
        if (this.attempts.length) {
 | 
			
		||||
            const last = this.attempts[this.attempts.length - 1];
 | 
			
		||||
            this.moreAttempts = !AddonModQuiz.instance.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.moreAttempts = !this.attemptAccessInfo.isfinished;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.getButtonText(quiz);
 | 
			
		||||
 | 
			
		||||
        await this.getResultInfo(quiz);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the text to show in the button. It also sets restriction messages if needed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     */
 | 
			
		||||
    protected getButtonText(quiz: AddonModQuizQuizData): void {
 | 
			
		||||
        this.buttonText = '';
 | 
			
		||||
 | 
			
		||||
        if (quiz.hasquestions !== 0) {
 | 
			
		||||
            if (this.attempts.length && !AddonModQuiz.instance.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) {
 | 
			
		||||
                // Last attempt is unfinished.
 | 
			
		||||
                if (this.quizAccessInfo?.canattempt) {
 | 
			
		||||
                    this.buttonText = 'addon.mod_quiz.continueattemptquiz';
 | 
			
		||||
                } else if (this.quizAccessInfo?.canpreview) {
 | 
			
		||||
                    this.buttonText = 'addon.mod_quiz.continuepreview';
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            } else {
 | 
			
		||||
                // Last attempt is finished or no attempts.
 | 
			
		||||
                if (this.quizAccessInfo?.canattempt) {
 | 
			
		||||
                    this.preventMessages = this.attemptAccessInfo?.preventnewattemptreasons || [];
 | 
			
		||||
                    if (!this.preventMessages.length) {
 | 
			
		||||
                        if (!this.attempts.length) {
 | 
			
		||||
                            this.buttonText = 'addon.mod_quiz.attemptquiznow';
 | 
			
		||||
                        } else {
 | 
			
		||||
                            this.buttonText = 'addon.mod_quiz.reattemptquiz';
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (this.quizAccessInfo?.canpreview) {
 | 
			
		||||
                    this.buttonText = 'addon.mod_quiz.previewquiznow';
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this.buttonText) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // So far we think a button should be printed, check if they will be allowed to access it.
 | 
			
		||||
        this.preventMessages = this.quizAccessInfo?.preventaccessreasons || [];
 | 
			
		||||
 | 
			
		||||
        if (!this.moreAttempts) {
 | 
			
		||||
            this.buttonText = '';
 | 
			
		||||
        } else if (this.quizAccessInfo?.canattempt && this.preventMessages.length) {
 | 
			
		||||
            this.buttonText = '';
 | 
			
		||||
        } else if (!this.hasSupportedQuestions || this.unsupportedRules.length || !this.behaviourSupported) {
 | 
			
		||||
            this.buttonText = '';
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get result info to show.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async getResultInfo(quiz: AddonModQuizQuizData): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade ||
 | 
			
		||||
            this.gradebookData?.grade === undefined) {
 | 
			
		||||
            this.showResults = false;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const formattedGradebookGrade = AddonModQuiz.instance.formatGrade(this.gradebookData.grade, quiz.decimalpoints);
 | 
			
		||||
        const formattedBestGrade = AddonModQuiz.instance.formatGrade(this.bestGrade.grade, quiz.decimalpoints);
 | 
			
		||||
        let gradeToShow = formattedGradebookGrade; // By default we show the grade in the gradebook.
 | 
			
		||||
 | 
			
		||||
        this.showResults = true;
 | 
			
		||||
        this.gradeOverridden = formattedGradebookGrade != formattedBestGrade;
 | 
			
		||||
        this.gradebookFeedback = this.gradebookData.feedback;
 | 
			
		||||
 | 
			
		||||
        if (this.bestGrade.grade! > this.gradebookData.grade && this.gradebookData.grade == quiz.grade) {
 | 
			
		||||
            // The best grade is higher than the max grade for the quiz.
 | 
			
		||||
            // We'll do like Moodle web and show the best grade instead of the gradebook grade.
 | 
			
		||||
            this.gradeOverridden = false;
 | 
			
		||||
            gradeToShow = formattedBestGrade;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.overallStats) {
 | 
			
		||||
            // Show the quiz grade. The message shown is different if the quiz is finished.
 | 
			
		||||
            if (this.moreAttempts) {
 | 
			
		||||
                this.gradeResult = Translate.instance.instant('addon.mod_quiz.gradesofar', { $a: {
 | 
			
		||||
                    method: this.gradeMethodReadable,
 | 
			
		||||
                    mygrade: gradeToShow,
 | 
			
		||||
                    quizgrade: quiz.gradeFormatted,
 | 
			
		||||
                } });
 | 
			
		||||
            } else {
 | 
			
		||||
                const outOfShort = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: {
 | 
			
		||||
                    grade: gradeToShow,
 | 
			
		||||
                    maxgrade: quiz.gradeFormatted,
 | 
			
		||||
                } });
 | 
			
		||||
 | 
			
		||||
                this.gradeResult = Translate.instance.instant('addon.mod_quiz.yourfinalgradeis', { $a: outOfShort });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (quiz.showFeedbackColumn) {
 | 
			
		||||
            // Get the quiz overall feedback.
 | 
			
		||||
            const response = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, this.gradebookData.grade, {
 | 
			
		||||
                cmId: this.module!.id,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.overallFeedback = response.feedbacktext;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Go to review an attempt that has just been finished.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async goToAutoReview(): Promise<void> {
 | 
			
		||||
        if (!this.autoReview) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If we go to auto review it means an attempt was finished. Check completion status.
 | 
			
		||||
        CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
 | 
			
		||||
 | 
			
		||||
        // Verify that user can see the review.
 | 
			
		||||
        const attemptId = this.autoReview.attemptId;
 | 
			
		||||
 | 
			
		||||
        if (this.quizAccessInfo?.canreviewmyattempts) {
 | 
			
		||||
            try {
 | 
			
		||||
                await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
                await CoreNavigator.instance.navigate(`review/${attemptId}`);
 | 
			
		||||
            } catch {
 | 
			
		||||
                // Ignore errors.
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Checks if sync has succeed from result sync data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param result Data returned on the sync function.
 | 
			
		||||
     * @return If suceed or not.
 | 
			
		||||
     */
 | 
			
		||||
    protected hasSyncSucceed(result: AddonModQuizSyncResult): boolean {
 | 
			
		||||
        if (result.attemptFinished) {
 | 
			
		||||
            // An attempt was finished, check completion status.
 | 
			
		||||
            CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If the sync call isn't rejected it means the sync was successful.
 | 
			
		||||
        return result.updated;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * User entered the page that contains the component.
 | 
			
		||||
     */
 | 
			
		||||
    async ionViewDidEnter(): Promise<void> {
 | 
			
		||||
        super.ionViewDidEnter();
 | 
			
		||||
 | 
			
		||||
        if (!this.hasPlayed) {
 | 
			
		||||
            this.autoReview = undefined;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.hasPlayed = false;
 | 
			
		||||
        let promise = Promise.resolve();
 | 
			
		||||
 | 
			
		||||
        // Update data when we come back from the player since the attempt status could have changed.
 | 
			
		||||
        // Check if we need to go to review an attempt automatically.
 | 
			
		||||
        if (this.autoReview && this.autoReview.synced) {
 | 
			
		||||
            promise = this.goToAutoReview();
 | 
			
		||||
            this.autoReview = undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Refresh data.
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
        this.refreshIcon = CoreConstants.ICON_LOADING;
 | 
			
		||||
        this.syncIcon = CoreConstants.ICON_LOADING;
 | 
			
		||||
        this.content?.scrollToTop();
 | 
			
		||||
 | 
			
		||||
        await promise;
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(this.refreshContent(true));
 | 
			
		||||
 | 
			
		||||
        this.loaded = true;
 | 
			
		||||
        this.refreshIcon = CoreConstants.ICON_REFRESH;
 | 
			
		||||
        this.syncIcon = CoreConstants.ICON_SYNC;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * User left the page that contains the component.
 | 
			
		||||
     */
 | 
			
		||||
    ionViewDidLeave(): void {
 | 
			
		||||
        super.ionViewDidLeave();
 | 
			
		||||
        this.autoReview = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Perform the invalidate content function.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async invalidateContent(): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId!));
 | 
			
		||||
 | 
			
		||||
        if (this.quiz) {
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateQuizRequiredQtypes(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateAttemptAccessInformation(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateUserBestGradeForUser(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateGradeFromGradebook(this.courseId!));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Compares sync event data with current data to check if refresh content is needed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param syncEventData Data receiven on sync observer.
 | 
			
		||||
     * @return True if refresh is needed, false otherwise.
 | 
			
		||||
     */
 | 
			
		||||
    protected isRefreshSyncNeeded(syncEventData: AddonModQuizAutoSyncData): boolean {
 | 
			
		||||
        if (!this.courseId || !this.module) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (syncEventData.attemptFinished) {
 | 
			
		||||
            // An attempt was finished, check completion status.
 | 
			
		||||
            CoreCourse.instance.checkModuleCompletion(this.courseId, this.module.completiondata);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.quiz && syncEventData.quizId == this.quiz.id) {
 | 
			
		||||
            this.content?.scrollToTop();
 | 
			
		||||
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Open a quiz to attempt it.
 | 
			
		||||
     */
 | 
			
		||||
    protected openQuiz(): void {
 | 
			
		||||
        this.hasPlayed = true;
 | 
			
		||||
 | 
			
		||||
        CoreNavigator.instance.navigate('player', {
 | 
			
		||||
            params: {
 | 
			
		||||
                moduleUrl: this.module?.url,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Displays some data based on the current status.
 | 
			
		||||
     *
 | 
			
		||||
     * @param status The current status.
 | 
			
		||||
     * @param previousStatus The previous status. If not defined, there is no previous status.
 | 
			
		||||
     */
 | 
			
		||||
    protected showStatus(status: string, previousStatus?: string): void {
 | 
			
		||||
        this.showStatusSpinner = status == CoreConstants.DOWNLOADING;
 | 
			
		||||
 | 
			
		||||
        if (status == CoreConstants.DOWNLOADED && previousStatus == CoreConstants.DOWNLOADING) {
 | 
			
		||||
            // Quiz downloaded now, maybe a new attempt was created. Load content again.
 | 
			
		||||
            this.loaded = false;
 | 
			
		||||
            this.loadContent();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Performs the sync of the activity.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected sync(): Promise<AddonModQuizSyncResult> {
 | 
			
		||||
        return AddonModQuizSync.instance.syncQuiz(this.candidateQuiz!, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Treat user attempts.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attempts The attempts to treat.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async treatAttempts(
 | 
			
		||||
        quiz: AddonModQuizQuizData,
 | 
			
		||||
        attempts: AddonModQuizAttemptWSData[],
 | 
			
		||||
    ): Promise<AddonModQuizAttempt[]> {
 | 
			
		||||
        if (!attempts || !attempts.length) {
 | 
			
		||||
            // There are no attempts to treat.
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const lastFinished = AddonModQuiz.instance.getLastFinishedAttemptFromList(attempts);
 | 
			
		||||
        const promises: Promise<unknown>[] = [];
 | 
			
		||||
 | 
			
		||||
        if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) {
 | 
			
		||||
            // User just finished an attempt in offline and it seems it's been synced, since it's finished in online.
 | 
			
		||||
            // Go to the review of this attempt if the user hasn't left this view.
 | 
			
		||||
            if (!this.isDestroyed && this.isCurrentView) {
 | 
			
		||||
                promises.push(this.goToAutoReview());
 | 
			
		||||
            }
 | 
			
		||||
            this.autoReview = undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get combined review options.
 | 
			
		||||
        promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, { cmId: this.module!.id }).then((options) => {
 | 
			
		||||
            this.options = options;
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Get best grade.
 | 
			
		||||
        promises.push(this.getQuizGrade(quiz));
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
 | 
			
		||||
        const grade = typeof this.gradebookData?.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade?.grade;
 | 
			
		||||
        const quizGrade = AddonModQuiz.instance.formatGrade(grade, quiz.decimalpoints);
 | 
			
		||||
 | 
			
		||||
        // Calculate data to construct the header of the attempts table.
 | 
			
		||||
        AddonModQuizHelper.instance.setQuizCalculatedData(quiz, this.options!);
 | 
			
		||||
 | 
			
		||||
        this.overallStats = !!lastFinished && this.options!.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX;
 | 
			
		||||
 | 
			
		||||
        // Calculate data to show for each attempt.
 | 
			
		||||
        const formattedAttempts = await Promise.all(attempts.map((attempt, index) => {
 | 
			
		||||
            // Highlight the highest grade if appropriate.
 | 
			
		||||
            const shouldHighlight = this.overallStats && quiz.grademethod == AddonModQuizProvider.GRADEHIGHEST &&
 | 
			
		||||
                attempts.length > 1;
 | 
			
		||||
            const isLast = index == attempts.length - 1;
 | 
			
		||||
 | 
			
		||||
            return AddonModQuizHelper.instance.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast);
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return formattedAttempts;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get quiz grade data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async getQuizGrade(quiz: AddonModQuizQuizData): Promise<void> {
 | 
			
		||||
        this.bestGrade = await AddonModQuiz.instance.getUserBestGrade(quiz.id, { cmId: this.module!.id });
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Get gradebook grade.
 | 
			
		||||
            const data = await AddonModQuiz.instance.getGradeFromGradebook(this.courseId!, this.module!.id);
 | 
			
		||||
 | 
			
		||||
            if (data) {
 | 
			
		||||
                this.gradebookData = {
 | 
			
		||||
                    grade: 'graderaw' in data ? data.graderaw : Number(data.grade),
 | 
			
		||||
                    feedback: data.feedback,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Fallback to quiz best grade if failure or not found.
 | 
			
		||||
            this.gradebookData = {
 | 
			
		||||
                grade: this.bestGrade.grade,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Go to page to view the attempt details.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async viewAttempt(attemptId: number): Promise<void> {
 | 
			
		||||
        CoreNavigator.instance.navigate(`attempt/${attemptId}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        super.ngOnDestroy();
 | 
			
		||||
 | 
			
		||||
        this.finishedObserver?.off();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,67 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title>
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-times"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content class="addon-mod_quiz-navigation-modal">
 | 
			
		||||
    <nav>
 | 
			
		||||
        <ion-list>
 | 
			
		||||
            <!-- In player, show button to finish attempt. -->
 | 
			
		||||
            <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
 | 
			
		||||
                <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- In review we can toggle between all questions in same page or one page at a time. -->
 | 
			
		||||
            <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
 | 
			
		||||
                    <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <ion-item button class="ion-text-wrap {{question.stateClass}}" *ngFor="let question of navigation"
 | 
			
		||||
                [ngClass]='{"core-selected-item": !summaryShown && currentPage == question.page}'
 | 
			
		||||
                (click)="loadPage(question.page, question.slot)" detail="true">
 | 
			
		||||
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span>
 | 
			
		||||
                    <span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
 | 
			
		||||
                <ion-icon *ngIf="!question.number" name="fas-info-circle" slot="end"></ion-icon>
 | 
			
		||||
                <ion-icon *ngIf="question.stateClass == 'core-question-requiresgrading'" name="fas-question-circle"
 | 
			
		||||
                    [attr.aria-label]="question.status" slot="end">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
                <ion-icon *ngIf="question.stateClass == 'core-question-correct'" name="fas-check" color="success"
 | 
			
		||||
                    [attr.aria-label]="question.status" slot="end">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
                <ion-icon *ngIf="question.stateClass == 'core-question-partiallycorrect'" name="fas-check-square"
 | 
			
		||||
                    color="warning" [attr.aria-label]="question.status" slot="end">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
                <ion-icon *ngIf="question.stateClass == 'core-question-incorrect' ||
 | 
			
		||||
                    question.stateClass == 'core-question-notanswered'" name="fas-times" color="danger"
 | 
			
		||||
                    [attr.aria-label]="question.status" slot="end">
 | 
			
		||||
                </ion-icon>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- In player, show button to finish attempt. -->
 | 
			
		||||
            <ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
 | 
			
		||||
                <ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- In review we can toggle between all questions in same page or one page at a time. -->
 | 
			
		||||
            <ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
 | 
			
		||||
                    <span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-list>
 | 
			
		||||
    </nav>
 | 
			
		||||
</ion-content>
 | 
			
		||||
@ -0,0 +1,76 @@
 | 
			
		||||
// (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 { Component, Input } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { ModalController } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Modal that renders the quiz navigation.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-mod-quiz-navigation-modal',
 | 
			
		||||
    templateUrl: 'navigation-modal.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizNavigationModalComponent {
 | 
			
		||||
 | 
			
		||||
    static readonly CHANGE_PAGE = 1;
 | 
			
		||||
    static readonly SWITCH_MODE = 2;
 | 
			
		||||
 | 
			
		||||
    @Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt.
 | 
			
		||||
    @Input() summaryShown?: boolean; // Whether summary is currently being shown.
 | 
			
		||||
    @Input() currentPage?: number; // Current page.
 | 
			
		||||
    @Input() isReview?: boolean; // Whether the user is reviewing the attempt.
 | 
			
		||||
    @Input() numPages = 0; // Num of pages for review mode.
 | 
			
		||||
    @Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode.
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Close modal.
 | 
			
		||||
     */
 | 
			
		||||
    closeModal(): void {
 | 
			
		||||
        ModalController.instance.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load a certain page.
 | 
			
		||||
     *
 | 
			
		||||
     * @param page The page to load.
 | 
			
		||||
     * @param slot Slot of the question to scroll to.
 | 
			
		||||
     */
 | 
			
		||||
    loadPage(page: number, slot?: number): void {
 | 
			
		||||
        ModalController.instance.dismiss({
 | 
			
		||||
            action: AddonModQuizNavigationModalComponent.CHANGE_PAGE,
 | 
			
		||||
            page,
 | 
			
		||||
            slot,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Switch mode in review.
 | 
			
		||||
     */
 | 
			
		||||
    switchMode(): void {
 | 
			
		||||
        ModalController.instance.dismiss({
 | 
			
		||||
            action: AddonModQuizNavigationModalComponent.SWITCH_MODE,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Question for the navigation menu with some calculated data.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & {
 | 
			
		||||
    stateClass?: string;
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1,32 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-title>{{ title | translate }}</ion-title>
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
 | 
			
		||||
                <ion-icon slot="icon-only" name="fas-times"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content class="addon-mod_quiz-preflight-modal">
 | 
			
		||||
    <core-loading [hideUntil]="loaded">
 | 
			
		||||
        <form [formGroup]="preflightForm" (ngSubmit)="sendData($event)" #preflightFormEl>
 | 
			
		||||
            <ion-list>
 | 
			
		||||
                <!-- Access rules. -->
 | 
			
		||||
                <ng-container *ngFor="let data of accessRulesData; let last = last">
 | 
			
		||||
                    <core-dynamic-component [component]="data.component" [data]="data.data">
 | 
			
		||||
                        <p class="ion-padding">Couldn't find the directive to render this access rule.</p>
 | 
			
		||||
                    </core-dynamic-component>
 | 
			
		||||
                    <ion-item-divider *ngIf="!last"><ion-label></ion-label></ion-item-divider>
 | 
			
		||||
                </ng-container>
 | 
			
		||||
 | 
			
		||||
                <ion-button expand="block" type="submit" class="ion-margin">
 | 
			
		||||
                    {{ title | translate }}
 | 
			
		||||
                </ion-button>
 | 
			
		||||
                <!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
 | 
			
		||||
                <input type="submit" class="core-submit-hidden-enter" />
 | 
			
		||||
            </ion-list>
 | 
			
		||||
        </form>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
</ion-content>
 | 
			
		||||
@ -0,0 +1,144 @@
 | 
			
		||||
// (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 { Component, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core';
 | 
			
		||||
import { FormBuilder, FormGroup } from '@angular/forms';
 | 
			
		||||
import { IonContent } from '@ionic/angular';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { ModalController, Translate } from '@singletons';
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Modal that renders the access rules for a quiz.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'page-addon-mod-quiz-preflight-modal',
 | 
			
		||||
    templateUrl: 'preflight-modal.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizPreflightModalComponent implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @ViewChild(IonContent) content?: IonContent;
 | 
			
		||||
    @ViewChild('preflightFormEl') formElement?: ElementRef;
 | 
			
		||||
 | 
			
		||||
    @Input() title!: string;
 | 
			
		||||
    @Input() quiz?: AddonModQuizQuizWSData;
 | 
			
		||||
    @Input() attempt?: AddonModQuizAttemptWSData;
 | 
			
		||||
    @Input() prefetch?: boolean;
 | 
			
		||||
    @Input() siteId!: string;
 | 
			
		||||
    @Input() rules!: string[];
 | 
			
		||||
 | 
			
		||||
    preflightForm: FormGroup;
 | 
			
		||||
    accessRulesData: { component: Type<unknown>; data: Record<string, unknown>}[] = []; // Component and data for each access rule.
 | 
			
		||||
    loaded = false;
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        formBuilder: FormBuilder,
 | 
			
		||||
        protected elementRef: ElementRef,
 | 
			
		||||
    ) {
 | 
			
		||||
        // Create an empty form group. The controls will be added by the access rules components.
 | 
			
		||||
        this.preflightForm = formBuilder.group({});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt');
 | 
			
		||||
        this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
        this.rules = this.rules || [];
 | 
			
		||||
 | 
			
		||||
        if (!this.quiz) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await Promise.all(this.rules.map(async (rule) => {
 | 
			
		||||
                // Check if preflight is required for rule and, if so, get the component to render it.
 | 
			
		||||
                const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule(
 | 
			
		||||
                    rule,
 | 
			
		||||
                    this.quiz!,
 | 
			
		||||
                    this.attempt,
 | 
			
		||||
                    this.prefetch,
 | 
			
		||||
                    this.siteId,
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                if (!required) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule);
 | 
			
		||||
                if (!component) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.accessRulesData.push({
 | 
			
		||||
                    component: component,
 | 
			
		||||
                    data: {
 | 
			
		||||
                        rule: rule,
 | 
			
		||||
                        quiz: this.quiz,
 | 
			
		||||
                        attempt: this.attempt,
 | 
			
		||||
                        prefetch: this.prefetch,
 | 
			
		||||
                        form: this.preflightForm,
 | 
			
		||||
                        siteId: this.siteId,
 | 
			
		||||
                    },
 | 
			
		||||
                });
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules');
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check that the data is valid and send it back.
 | 
			
		||||
     *
 | 
			
		||||
     * @param e Event.
 | 
			
		||||
     */
 | 
			
		||||
    sendData(e: Event): void {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
        if (!this.preflightForm.valid) {
 | 
			
		||||
            // Form not valid. Scroll to the first element with errors.
 | 
			
		||||
            const hasScrolled = CoreDomUtils.instance.scrollToInputError(
 | 
			
		||||
                this.elementRef.nativeElement,
 | 
			
		||||
                this.content,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (!hasScrolled) {
 | 
			
		||||
                // Input not found, show an error modal.
 | 
			
		||||
                CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId);
 | 
			
		||||
 | 
			
		||||
            ModalController.instance.dismiss(this.preflightForm.value);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Close modal.
 | 
			
		||||
     */
 | 
			
		||||
    closeModal(): void {
 | 
			
		||||
        CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId);
 | 
			
		||||
 | 
			
		||||
        ModalController.instance.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										83
									
								
								src/addons/mod/quiz/lang.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/addons/mod/quiz/lang.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,83 @@
 | 
			
		||||
{
 | 
			
		||||
    "answercolon": "Answer:",
 | 
			
		||||
    "attemptfirst": "First attempt",
 | 
			
		||||
    "attemptlast": "Last attempt",
 | 
			
		||||
    "attemptnumber": "Attempt",
 | 
			
		||||
    "attemptquiznow": "Attempt quiz now",
 | 
			
		||||
    "attemptstate": "State",
 | 
			
		||||
    "canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:",
 | 
			
		||||
    "cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:",
 | 
			
		||||
    "clearchoice": "Clear my choice",
 | 
			
		||||
    "comment": "Comment",
 | 
			
		||||
    "completedon": "Completed on",
 | 
			
		||||
    "confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.",
 | 
			
		||||
    "confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.",
 | 
			
		||||
    "confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?",
 | 
			
		||||
    "confirmstart": "Your attempt will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?",
 | 
			
		||||
    "confirmstartheader": "Time limit",
 | 
			
		||||
    "connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.",
 | 
			
		||||
    "continueattemptquiz": "Continue the last attempt",
 | 
			
		||||
    "continuepreview": "Continue the last preview",
 | 
			
		||||
    "errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:",
 | 
			
		||||
    "errordownloading": "Error downloading required data.",
 | 
			
		||||
    "errorgetattempt": "Error getting attempt data.",
 | 
			
		||||
    "errorgetquestions": "Error getting questions.",
 | 
			
		||||
    "errorgetquiz": "Error getting quiz data.",
 | 
			
		||||
    "errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.",
 | 
			
		||||
    "errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:",
 | 
			
		||||
    "errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:",
 | 
			
		||||
    "errorsaveattempt": "An error occurred while saving the attempt data.",
 | 
			
		||||
    "feedback": "Feedback",
 | 
			
		||||
    "finishattemptdots": "Finish attempt...",
 | 
			
		||||
    "finishnotsynced": "Finished but not synchronised",
 | 
			
		||||
    "grade": "Grade",
 | 
			
		||||
    "gradeaverage": "Average grade",
 | 
			
		||||
    "gradehighest": "Highest grade",
 | 
			
		||||
    "grademethod": "Grading method",
 | 
			
		||||
    "gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.",
 | 
			
		||||
    "marks": "Marks",
 | 
			
		||||
    "modulenameplural": "Quizzes",
 | 
			
		||||
    "mustbesubmittedby": "This attempt must be submitted by {{$a}}.",
 | 
			
		||||
    "noquestions": "No questions have been added yet",
 | 
			
		||||
    "noreviewattempt": "You are not allowed to review this attempt.",
 | 
			
		||||
    "notyetgraded": "Not yet graded",
 | 
			
		||||
    "opentoc": "Open navigation popover",
 | 
			
		||||
    "outof": "{{$a.grade}} out of {{$a.maxgrade}}",
 | 
			
		||||
    "outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)",
 | 
			
		||||
    "outofshort": "{{$a.grade}}/{{$a.maxgrade}}",
 | 
			
		||||
    "overallfeedback": "Overall feedback",
 | 
			
		||||
    "overdue": "Overdue",
 | 
			
		||||
    "overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.",
 | 
			
		||||
    "preview": "Preview",
 | 
			
		||||
    "previewquiznow": "Preview quiz now",
 | 
			
		||||
    "question": "Question",
 | 
			
		||||
    "quiznavigation": "Quiz navigation",
 | 
			
		||||
    "quizpassword": "Quiz password",
 | 
			
		||||
    "reattemptquiz": "Re-attempt quiz",
 | 
			
		||||
    "requirepasswordmessage": "To attempt this quiz you need to know the quiz password",
 | 
			
		||||
    "returnattempt": "Return to attempt",
 | 
			
		||||
    "review": "Review",
 | 
			
		||||
    "reviewofattempt": "Review of attempt {{$a}}",
 | 
			
		||||
    "reviewofpreview": "Review of preview",
 | 
			
		||||
    "showall": "Show all questions on one page",
 | 
			
		||||
    "showeachpage": "Show one page at a time",
 | 
			
		||||
    "startattempt": "Start attempt",
 | 
			
		||||
    "startedon": "Started on",
 | 
			
		||||
    "stateabandoned": "Never submitted",
 | 
			
		||||
    "statefinished": "Finished",
 | 
			
		||||
    "statefinisheddetails": "Submitted {{$a}}",
 | 
			
		||||
    "stateinprogress": "In progress",
 | 
			
		||||
    "stateoverdue": "Overdue",
 | 
			
		||||
    "stateoverduedetails": "Must be submitted by {{$a}}",
 | 
			
		||||
    "status": "Status",
 | 
			
		||||
    "submitallandfinish": "Submit all and finish",
 | 
			
		||||
    "summaryofattempt": "Summary of attempt",
 | 
			
		||||
    "summaryofattempts": "Summary of your previous attempts",
 | 
			
		||||
    "timeleft": "Time left",
 | 
			
		||||
    "timetaken": "Time taken",
 | 
			
		||||
    "warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.",
 | 
			
		||||
    "warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.",
 | 
			
		||||
    "warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.",
 | 
			
		||||
    "warningquestionsnotsupported": "This quiz contains questions not supported by the app:",
 | 
			
		||||
    "yourfinalgradeis": "Your final grade for this quiz is {{$a}}."
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								src/addons/mod/quiz/pages/attempt/attempt.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/addons/mod/quiz/pages/attempt/attempt.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,65 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <core-format-text *ngIf="quiz" [text]="quiz.name" contextLevel="module" [contextInstanceId]="quiz.coursemodule"
 | 
			
		||||
                [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
    <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
 | 
			
		||||
        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
    </ion-refresher>
 | 
			
		||||
    <core-loading [hideUntil]="loaded">
 | 
			
		||||
        <ion-list *ngIf="attempt" lines="none">
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h2>{{ 'addon.mod_quiz.attemptnumber' | translate }}</h2>
 | 
			
		||||
                    <p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p>
 | 
			
		||||
                    <p *ngIf="!attempt.preview">{{ attempt.attempt }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2>
 | 
			
		||||
                    <p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== ''">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h2>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</h2>
 | 
			
		||||
                    <p>{{ attempt.readableMark }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h2>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</h2>
 | 
			
		||||
                    <p>{{ attempt.readableGrade }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h2>{{ 'addon.mod_quiz.feedback' | translate }}</h2>
 | 
			
		||||
                    <p>
 | 
			
		||||
                        <core-format-text [component]="component" [componentId]="componentId" [text]="feedback"
 | 
			
		||||
                            contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
 | 
			
		||||
                        </core-format-text>
 | 
			
		||||
                    </p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-button *ngIf="showReviewColumn && attempt.finished" class="ion-margin" expand="block" (click)="reviewAttempt()">
 | 
			
		||||
                <ion-icon name="fas-search" slot="start"></ion-icon>
 | 
			
		||||
                {{ 'addon.mod_quiz.review' | translate }}
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-item class="ion-text-wrap core-danger-item" *ngIf="!showReviewColumn">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <p>{{ 'addon.mod_quiz.noreviewattempt' | translate }}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
        </ion-list>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
</ion-content>
 | 
			
		||||
							
								
								
									
										38
									
								
								src/addons/mod/quiz/pages/attempt/attempt.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/addons/mod/quiz/pages/attempt/attempt.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,38 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
import { RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { AddonModQuizAttemptPage } from './attempt';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: '',
 | 
			
		||||
        component: AddonModQuizAttemptPage,
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        RouterModule.forChild(routes),
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizAttemptPage,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [RouterModule],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAttemptPageModule {}
 | 
			
		||||
							
								
								
									
										200
									
								
								src/addons/mod/quiz/pages/attempt/attempt.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/addons/mod/quiz/pages/attempt/attempt.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,200 @@
 | 
			
		||||
// (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 { Component, OnInit } from '@angular/core';
 | 
			
		||||
import { CoreError } from '@classes/errors/error';
 | 
			
		||||
import { IonRefresher } from '@ionic/angular';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { Translate } from '@singletons';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuiz,
 | 
			
		||||
    AddonModQuizAttemptWSData,
 | 
			
		||||
    AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizProvider,
 | 
			
		||||
} from '../../services/quiz';
 | 
			
		||||
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that displays some summary data about an attempt.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'page-addon-mod-quiz-attempt',
 | 
			
		||||
    templateUrl: 'attempt.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizAttemptPage implements OnInit {
 | 
			
		||||
 | 
			
		||||
    courseId!: number; // The course ID the quiz belongs to.
 | 
			
		||||
    quiz?: AddonModQuizQuizData; // The quiz the attempt belongs to.
 | 
			
		||||
    attempt?: AddonModQuizAttempt; // The attempt to view.
 | 
			
		||||
    component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
 | 
			
		||||
    componentId?: number; // Component ID to use in conjunction with the component.
 | 
			
		||||
    loaded = false; // Whether data has been loaded.
 | 
			
		||||
    feedback?: string; // Attempt feedback.
 | 
			
		||||
    showReviewColumn = false;
 | 
			
		||||
    cmId!: number; // Course module id the attempt belongs to.
 | 
			
		||||
 | 
			
		||||
    protected attemptId!: number; // Attempt to view.
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
 | 
			
		||||
        this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
 | 
			
		||||
        this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!;
 | 
			
		||||
 | 
			
		||||
        this.fetchQuizData().finally(() => {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Refresh the data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param refresher Refresher.
 | 
			
		||||
     */
 | 
			
		||||
    doRefresh(refresher: IonRefresher): void {
 | 
			
		||||
        this.refreshData().finally(() => {
 | 
			
		||||
            refresher.complete();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get quiz data and attempt data.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchQuizData(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId);
 | 
			
		||||
 | 
			
		||||
            this.componentId = this.quiz.coursemodule;
 | 
			
		||||
 | 
			
		||||
            // Load attempt data.
 | 
			
		||||
            const [options, accessInfo, attempt] = await Promise.all([
 | 
			
		||||
                AddonModQuiz.instance.getCombinedReviewOptions(this.quiz.id, { cmId: this.quiz.coursemodule }),
 | 
			
		||||
                this.fetchAccessInfo(),
 | 
			
		||||
                this.fetchAttempt(),
 | 
			
		||||
            ]);
 | 
			
		||||
 | 
			
		||||
            // Set calculated data.
 | 
			
		||||
            this.showReviewColumn = accessInfo.canreviewmyattempts;
 | 
			
		||||
            AddonModQuizHelper.instance.setQuizCalculatedData(this.quiz, options);
 | 
			
		||||
 | 
			
		||||
            this.attempt = await AddonModQuizHelper.instance.setAttemptCalculatedData(this.quiz!, attempt, false, undefined, true);
 | 
			
		||||
 | 
			
		||||
            // Check if the feedback should be displayed.
 | 
			
		||||
            const grade = Number(this.attempt!.rescaledGrade);
 | 
			
		||||
 | 
			
		||||
            if (this.quiz.showFeedbackColumn && AddonModQuiz.instance.isAttemptFinished(this.attempt!.state) &&
 | 
			
		||||
                    options.someoptions.overallfeedback && !isNaN(grade)) {
 | 
			
		||||
 | 
			
		||||
                // Feedback should be displayed, get the feedback for the grade.
 | 
			
		||||
                const response = await AddonModQuiz.instance.getFeedbackForGrade(this.quiz.id, grade, {
 | 
			
		||||
                    cmId: this.quiz.coursemodule,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                this.feedback = response.feedbacktext;
 | 
			
		||||
            } else {
 | 
			
		||||
                delete this.feedback;
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetattempt', true);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchAttempt(): Promise<AddonModQuizAttemptWSData> {
 | 
			
		||||
        // Get all the attempts and search the one we want.
 | 
			
		||||
        const attempts = await AddonModQuiz.instance.getUserAttempts(this.quiz!.id, { cmId: this.cmId });
 | 
			
		||||
 | 
			
		||||
        const attempt = attempts.find(attempt => attempt.id == this.attemptId);
 | 
			
		||||
 | 
			
		||||
        if (!attempt) {
 | 
			
		||||
            // Attempt not found, error.
 | 
			
		||||
            this.attempt = undefined;
 | 
			
		||||
 | 
			
		||||
            throw new CoreError(Translate.instance.instant('addon.mod_quiz.errorgetattempt'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return attempt;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the access info.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchAccessInfo(): Promise<AddonModQuizGetQuizAccessInformationWSResponse> {
 | 
			
		||||
        const accessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quiz!.id, { cmId: this.cmId });
 | 
			
		||||
 | 
			
		||||
        if (!accessInfo.canreviewmyattempts) {
 | 
			
		||||
            return accessInfo;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if the user can review the attempt.
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.invalidateAttemptReviewForPage(this.attemptId, -1));
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz!.coursemodule });
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Error getting the review, assume the user cannot review the attempt.
 | 
			
		||||
            accessInfo.canreviewmyattempts = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return accessInfo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Refresh the data.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async refreshData(): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId));
 | 
			
		||||
        promises.push(AddonModQuiz.instance.invalidateAttemptReview(this.attemptId));
 | 
			
		||||
 | 
			
		||||
        if (this.quiz) {
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id));
 | 
			
		||||
 | 
			
		||||
            if (this.attempt && typeof this.feedback != 'undefined') {
 | 
			
		||||
                promises.push(AddonModQuiz.instance.invalidateFeedback(this.quiz.id));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(Promise.all(promises));
 | 
			
		||||
 | 
			
		||||
        await this.fetchQuizData();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Go to the page to review the attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async reviewAttempt(): Promise<void> {
 | 
			
		||||
        CoreNavigator.instance.navigate(`../../review/${this.attempt!.id}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/addons/mod/quiz/pages/index/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/addons/mod/quiz/pages/index/index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <!-- The buttons defined by the component will be added in here. -->
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
    <ion-refresher slot="fixed" [disabled]="!quizComponent?.loaded" (ionRefresh)="quizComponent?.doRefresh($event)">
 | 
			
		||||
        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
    </ion-refresher>
 | 
			
		||||
 | 
			
		||||
    <addon-mod-quiz-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-quiz-index>
 | 
			
		||||
</ion-content>
 | 
			
		||||
							
								
								
									
										40
									
								
								src/addons/mod/quiz/pages/index/index.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/addons/mod/quiz/pages/index/index.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
import { RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { AddonModQuizComponentsModule } from '../../components/components.module';
 | 
			
		||||
import { AddonModQuizIndexPage } from './index';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: '',
 | 
			
		||||
        component: AddonModQuizIndexPage,
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        RouterModule.forChild(routes),
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        AddonModQuizComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizIndexPage,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [RouterModule],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizIndexPageModule {}
 | 
			
		||||
							
								
								
									
										69
									
								
								src/addons/mod/quiz/pages/index/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/addons/mod/quiz/pages/index/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,69 @@
 | 
			
		||||
// (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 { Component, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreCourseWSModule } from '@features/course/services/course';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { AddonModQuizIndexComponent } from '../../components/index';
 | 
			
		||||
import { AddonModQuizQuizWSData } from '../../services/quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that displays the quiz entry page.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'page-addon-mod-quiz-index',
 | 
			
		||||
    templateUrl: 'index.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizIndexPage implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @ViewChild(AddonModQuizIndexComponent) quizComponent?: AddonModQuizIndexComponent;
 | 
			
		||||
 | 
			
		||||
    title?: string;
 | 
			
		||||
    module?: CoreCourseWSModule;
 | 
			
		||||
    courseId?: number;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.module = CoreNavigator.instance.getRouteParam('module');
 | 
			
		||||
        this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId');
 | 
			
		||||
        this.title = this.module?.name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update some data based on the quiz instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz instance.
 | 
			
		||||
     */
 | 
			
		||||
    updateData(quiz: AddonModQuizQuizWSData): void {
 | 
			
		||||
        this.title = quiz.name || this.title;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * User entered the page.
 | 
			
		||||
     */
 | 
			
		||||
    ionViewDidEnter(): void {
 | 
			
		||||
        this.quizComponent?.ionViewDidEnter();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * User left the page.
 | 
			
		||||
     */
 | 
			
		||||
    ionViewDidLeave(): void {
 | 
			
		||||
        this.quizComponent?.ionViewDidLeave();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										189
									
								
								src/addons/mod/quiz/pages/player/player.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/addons/mod/quiz/pages/player/player.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,189 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <core-format-text *ngIf="quiz" [text]="quiz.name" contextLevel="module" [contextInstanceId]="quiz.coursemodule"
 | 
			
		||||
                [courseId]="courseId">
 | 
			
		||||
            </core-format-text>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button id="addon-mod_quiz-connection-error-button" [hidden]="!autoSaveError" (click)="showConnectionError($event)"
 | 
			
		||||
                [attr.aria-label]="'core.error' | translate">
 | 
			
		||||
                <ion-icon name="fas-exclamation-circle" slot="icon-only"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-button *ngIf="navigation.length" [attr.aria-label]="'addon.mod_quiz.opentoc' | translate"
 | 
			
		||||
                (click)="openNavigation()">
 | 
			
		||||
                <ion-icon name="fas-bookmark" slot="icon-only"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
    <!-- Navigation arrows and time left. -->
 | 
			
		||||
    <ion-toolbar *ngIf="loaded && endTime && questions.length && !quizAborted && !showSummary" color="light" slot="fixed">
 | 
			
		||||
        <ion-title>
 | 
			
		||||
            <core-timer [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_quiz.timeleft' | translate"
 | 
			
		||||
                [align]="'center'">
 | 
			
		||||
            </core-timer>
 | 
			
		||||
        </ion-title>
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
 | 
			
		||||
                <ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
            <ion-button *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
 | 
			
		||||
                <ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
 | 
			
		||||
    <core-loading [hideUntil]="loaded">
 | 
			
		||||
        <!-- Navigation arrows if there's no timer. -->
 | 
			
		||||
        <ion-toolbar *ngIf="!endTime && questions.length && !quizAborted && !showSummary" color="light">
 | 
			
		||||
            <ion-buttons slot="end">
 | 
			
		||||
                <ion-button *ngIf="previousPage >= 0" (click)="changePage(previousPage)" [title]="'core.previous' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
                <ion-button *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </ion-buttons>
 | 
			
		||||
        </ion-toolbar>
 | 
			
		||||
 | 
			
		||||
        <!-- Button to start attempting. -->
 | 
			
		||||
        <ion-button *ngIf="!attempt" expand="block" class="ion-margin" (click)="start()">
 | 
			
		||||
            {{ 'addon.mod_quiz.startattempt' | translate }}
 | 
			
		||||
        </ion-button>
 | 
			
		||||
 | 
			
		||||
        <!-- Questions -->
 | 
			
		||||
        <form name="addon-mod_quiz-player-form" *ngIf="questions.length && !quizAborted && !showSummary" #quizForm>
 | 
			
		||||
            <div *ngFor="let question of questions">
 | 
			
		||||
                <ion-card id="addon-mod_quiz-question-{{question.slot}}">
 | 
			
		||||
                    <!-- "Header" of the question. -->
 | 
			
		||||
                    <ion-item-divider>
 | 
			
		||||
                        <ion-label>
 | 
			
		||||
                            <h2 *ngIf="question.number" class="inline">
 | 
			
		||||
                                {{ 'core.question.questionno' | translate:{$a: question.number} }}
 | 
			
		||||
                            </h2>
 | 
			
		||||
                            <h2 *ngIf="!question.number" class="inline">{{ 'core.question.information' | translate }}</h2>
 | 
			
		||||
                        </ion-label>
 | 
			
		||||
                        <div *ngIf="question.status || question.readableMark" slot="end"
 | 
			
		||||
                            class="ion-text-wrap ion-margin-horizontal addon-mod_quiz-question-note">
 | 
			
		||||
                            <p *ngIf="question.status" class="block">{{question.status}}</p>
 | 
			
		||||
                            <p *ngIf="question.readableMark">{{ question.readableMark }}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </ion-item-divider>
 | 
			
		||||
 | 
			
		||||
                    <!-- Body of the question. -->
 | 
			
		||||
                    <core-question class="ion-text-wrap" [question]="question" [component]="component"
 | 
			
		||||
                        [componentId]="cmId" [attemptId]="attempt!.id" [usageId]="attempt!.uniqueid"
 | 
			
		||||
                        [offlineEnabled]="offline" contextLevel="module" [contextInstanceId]="cmId"
 | 
			
		||||
                        [courseId]="courseId" [preferredBehaviour]="quiz!.preferredbehaviour" [review]="false"
 | 
			
		||||
                        (onAbort)="abortQuiz()" (buttonClicked)="behaviourButtonClicked($event)">
 | 
			
		||||
                    </core-question>
 | 
			
		||||
                </ion-card>
 | 
			
		||||
            </div>
 | 
			
		||||
        </form>
 | 
			
		||||
 | 
			
		||||
        <!-- Go to next or previous page. -->
 | 
			
		||||
        <ion-grid class="ion-text-wrap" *ngIf="questions.length && !quizAborted && !showSummary">
 | 
			
		||||
            <ion-row>
 | 
			
		||||
                <ion-col *ngIf="previousPage >= 0" >
 | 
			
		||||
                    <ion-button expand="block" color="light" (click)="changePage(previousPage)">
 | 
			
		||||
                        <ion-icon name="fas-chevron-left" slot="start"></ion-icon>
 | 
			
		||||
                        {{ 'core.previous' | translate }}
 | 
			
		||||
                    </ion-button>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
                <ion-col *ngIf="nextPage >= -1">
 | 
			
		||||
                    <ion-button expand="block" (click)="changePage(nextPage)">
 | 
			
		||||
                        {{ 'core.next' | translate }}
 | 
			
		||||
                        <ion-icon name="fas-chevron-right" slot="end"></ion-icon>
 | 
			
		||||
                    </ion-button>
 | 
			
		||||
                </ion-col>
 | 
			
		||||
            </ion-row>
 | 
			
		||||
        </ion-grid>
 | 
			
		||||
 | 
			
		||||
        <!-- Summary -->
 | 
			
		||||
        <ion-card *ngIf="!quizAborted && showSummary && summaryQuestions.length" class="addon-mod_quiz-table">
 | 
			
		||||
            <ion-card-header class="ion-text-wrap">
 | 
			
		||||
                <ion-card-title>{{ 'addon.mod_quiz.summaryofattempt' | translate }}</ion-card-title>
 | 
			
		||||
            </ion-card-header>
 | 
			
		||||
 | 
			
		||||
            <!-- "Header" of the summary table. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <ion-row class="ion-align-items-center">
 | 
			
		||||
                        <ion-col size="3" class="ion-text-center ion-hide-md-down">
 | 
			
		||||
                            <strong>{{ 'addon.mod_quiz.question' | translate }}</strong>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                        <ion-col size="3" class="ion-text-center ion-hide-md-up"><strong>#</strong></ion-col>
 | 
			
		||||
                        <ion-col size="9" class="ion-text-center">
 | 
			
		||||
                            <strong>{{ 'addon.mod_quiz.status' | translate }}</strong>
 | 
			
		||||
                        </ion-col>
 | 
			
		||||
                    </ion-row>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- List of questions of the summary table. -->
 | 
			
		||||
            <ng-container *ngFor="let question of summaryQuestions">
 | 
			
		||||
                <ion-item *ngIf="question.number" (click)="changePage(question.page, false, question.slot)"
 | 
			
		||||
                    [attr.aria-label]="'core.question.questionno' | translate:{$a: question.number}"
 | 
			
		||||
                    [detail]="!isSequential && canReturn" [attr.button]="!isSequential && canReturn ? true : null">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <ion-row class="ion-align-items-center">
 | 
			
		||||
                            <ion-col size="3" class="ion-text-center">{{ question.number }}</ion-col>
 | 
			
		||||
                            <ion-col size="9" class="ion-text-center ion-text-wrap">{{ question.status }}</ion-col>
 | 
			
		||||
                        </ion-row>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ng-container>
 | 
			
		||||
 | 
			
		||||
            <!-- Button to return to last page seen. -->
 | 
			
		||||
            <ion-button *ngIf="canReturn" expand="block" class="ion-margin" (click)="changePage(attempt!.currentpage!)">
 | 
			
		||||
                {{ 'addon.mod_quiz.returnattempt' | translate }}
 | 
			
		||||
            </ion-button>
 | 
			
		||||
 | 
			
		||||
            <!-- Due date warning. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="dueDateWarning">
 | 
			
		||||
                <ion-label>{{ dueDateWarning }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <!-- Time left (if quiz is timed). -->
 | 
			
		||||
            <core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()"
 | 
			
		||||
                [timerText]="'addon.mod_quiz.timeleft' | translate">
 | 
			
		||||
            </core-timer>
 | 
			
		||||
 | 
			
		||||
            <!-- List of messages explaining why the quiz cannot be submitted. -->
 | 
			
		||||
            <ion-item class="ion-text-wrap" *ngIf="preventSubmitMessages.length">
 | 
			
		||||
                <ion-label>
 | 
			
		||||
                    <h3 class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</h3>
 | 
			
		||||
                    <p *ngFor="let message of preventSubmitMessages">{{message}}</p>
 | 
			
		||||
                </ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
 | 
			
		||||
            <ion-button *ngIf="preventSubmitMessages.length" expand="block" [href]="moduleUrl" core-link>
 | 
			
		||||
                {{ 'core.openinbrowser' | translate }}
 | 
			
		||||
                <ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
 | 
			
		||||
            <!-- Button to submit the quiz. -->
 | 
			
		||||
            <ion-button *ngIf="!attempt!.finishedOffline && !preventSubmitMessages.length" expand="block"
 | 
			
		||||
                class="ion-margin" (click)="finishAttempt(true)">
 | 
			
		||||
                {{ 'addon.mod_quiz.submitallandfinish' | translate }}
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-card>
 | 
			
		||||
 | 
			
		||||
        <!-- Quiz aborted -->
 | 
			
		||||
        <ion-card *ngIf="attempt && ((!questions.length && !showSummary) || quizAborted)">
 | 
			
		||||
            <ion-item class="ion-text-wrap">
 | 
			
		||||
                <ion-label>{{ 'addon.mod_quiz.errorparsequestions' | translate }}</ion-label>
 | 
			
		||||
            </ion-item>
 | 
			
		||||
            <ion-button expand="block" class="ion-margin" [href]="moduleUrl" core-link>
 | 
			
		||||
                {{ 'core.openinbrowser' | translate }}
 | 
			
		||||
                <ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-card>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
</ion-content>
 | 
			
		||||
							
								
								
									
										42
									
								
								src/addons/mod/quiz/pages/player/player.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/addons/mod/quiz/pages/player/player.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
// (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 { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { NgModule } from '@angular/core';
 | 
			
		||||
import { RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionComponentsModule } from '@features/question/components/components.module';
 | 
			
		||||
import { CanLeaveGuard } from '@guards/can-leave';
 | 
			
		||||
import { AddonModQuizPlayerPage } from './player';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: '',
 | 
			
		||||
        component: AddonModQuizPlayerPage,
 | 
			
		||||
        canDeactivate: [CanLeaveGuard],
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        RouterModule.forChild(routes),
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        CoreQuestionComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizPlayerPage,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [RouterModule],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizPlayerPageModule {}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/addons/mod/quiz/pages/player/player.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/addons/mod/quiz/pages/player/player.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
:host {
 | 
			
		||||
    .addon-mod_quiz-question-note p {
 | 
			
		||||
        margin-top: 2px;
 | 
			
		||||
        margin-bottom: 2px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ion-toolbar {
 | 
			
		||||
        border-bottom: 1px solid var(--gray);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										785
									
								
								src/addons/mod/quiz/pages/player/player.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										785
									
								
								src/addons/mod/quiz/pages/player/player.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,785 @@
 | 
			
		||||
// (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 { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, ViewChildren, QueryList, ElementRef } from '@angular/core';
 | 
			
		||||
import { IonContent } from '@ionic/angular';
 | 
			
		||||
import { Subscription } from 'rxjs';
 | 
			
		||||
 | 
			
		||||
import { CoreIonLoadingElement } from '@classes/ion-loading';
 | 
			
		||||
import { CoreQuestionComponent } from '@features/question/components/question/question';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionBehaviourButton, CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
 | 
			
		||||
import { CoreSync } from '@services/sync';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { ModalController, Translate } from '@singletons';
 | 
			
		||||
import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events';
 | 
			
		||||
import { AddonModQuizAutoSave } from '../../classes/auto-save';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuizNavigationModalComponent,
 | 
			
		||||
    AddonModQuizNavigationQuestion,
 | 
			
		||||
} from '../../components/navigation-modal/navigation-modal';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuiz,
 | 
			
		||||
    AddonModQuizAttemptFinishedData,
 | 
			
		||||
    AddonModQuizAttemptWSData,
 | 
			
		||||
    AddonModQuizGetAttemptAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizProvider,
 | 
			
		||||
    AddonModQuizQuizWSData,
 | 
			
		||||
} from '../../services/quiz';
 | 
			
		||||
import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper';
 | 
			
		||||
import { AddonModQuizSync } from '../../services/quiz-sync';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that allows attempting a quiz.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'page-addon-mod-quiz-player',
 | 
			
		||||
    templateUrl: 'player.html',
 | 
			
		||||
    styleUrls: ['player.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    @ViewChild(IonContent) content?: IonContent;
 | 
			
		||||
    @ViewChildren(CoreQuestionComponent) questionComponents?: QueryList<CoreQuestionComponent>;
 | 
			
		||||
    @ViewChild('quizForm') formElement?: ElementRef;
 | 
			
		||||
 | 
			
		||||
    quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
 | 
			
		||||
    attempt?: AddonModQuizAttempt; // The attempt being attempted.
 | 
			
		||||
    moduleUrl?: string; // URL to the module in the site.
 | 
			
		||||
    component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
 | 
			
		||||
    loaded = false; // Whether data has been loaded.
 | 
			
		||||
    quizAborted = false; // Whether the quiz was aborted due to an error.
 | 
			
		||||
    offline = false; // Whether the quiz is being attempted in offline mode.
 | 
			
		||||
    navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them.
 | 
			
		||||
    questions: QuizQuestion[] = []; // Questions of the current page.
 | 
			
		||||
    nextPage = -2; // Next page.
 | 
			
		||||
    previousPage = -1; // Previous page.
 | 
			
		||||
    showSummary = false; // Whether the attempt summary should be displayed.
 | 
			
		||||
    summaryQuestions: CoreQuestionQuestionParsed[] = []; // The questions to display in the summary.
 | 
			
		||||
    canReturn = false; // Whether the user can return to a page after seeing the summary.
 | 
			
		||||
    preventSubmitMessages: string[] = []; // List of messages explaining why the quiz cannot be submitted.
 | 
			
		||||
    endTime?: number; // The time when the attempt must be finished.
 | 
			
		||||
    autoSaveError = false; // Whether there's been an error in auto-save.
 | 
			
		||||
    isSequential = false; // Whether quiz navigation is sequential.
 | 
			
		||||
    readableTimeLimit?: string; // Time limit in a readable format.
 | 
			
		||||
    dueDateWarning?: string; // Warning about due date.
 | 
			
		||||
    courseId!: number; // The course ID the quiz belongs to.
 | 
			
		||||
    cmId!: number; // Course module ID.
 | 
			
		||||
 | 
			
		||||
    protected preflightData: Record<string, string> = {}; // Preflight data to attempt the quiz.
 | 
			
		||||
    protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information.
 | 
			
		||||
    protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info.
 | 
			
		||||
    protected lastAttempt?: AddonModQuizAttemptWSData; // Last user attempt before a new one is created (if needed).
 | 
			
		||||
    protected newAttempt = false; // Whether the user is starting a new attempt.
 | 
			
		||||
    protected quizDataLoaded = false; // Whether the quiz data has been loaded.
 | 
			
		||||
    protected timeUpCalled = false; // Whether the time up function has been called.
 | 
			
		||||
    protected autoSave!: AddonModQuizAutoSave; // Class to auto-save answers every certain time.
 | 
			
		||||
    protected autoSaveErrorSubscription?: Subscription; // To be notified when an error happens in auto-save.
 | 
			
		||||
    protected forceLeave = false; // If true, don't perform any check when leaving the view.
 | 
			
		||||
    protected reloadNavigation = false; // Whether navigation needs to be reloaded because some data was sent to server.
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected changeDetector: ChangeDetectorRef,
 | 
			
		||||
        protected elementRef: ElementRef,
 | 
			
		||||
    ) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnInit(): void {
 | 
			
		||||
        this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
 | 
			
		||||
        this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
 | 
			
		||||
        this.moduleUrl = CoreNavigator.instance.getRouteParam('moduleUrl');
 | 
			
		||||
 | 
			
		||||
        // Create the auto save instance.
 | 
			
		||||
        this.autoSave = new AddonModQuizAutoSave(
 | 
			
		||||
            'addon-mod_quiz-player-form',
 | 
			
		||||
            '#addon-mod_quiz-connection-error-button',
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Start the player when the page is loaded.
 | 
			
		||||
        this.start();
 | 
			
		||||
 | 
			
		||||
        // Listen for errors on auto-save.
 | 
			
		||||
        this.autoSaveErrorSubscription = this.autoSave.onError().subscribe((error) => {
 | 
			
		||||
            this.autoSaveError = error;
 | 
			
		||||
            this.changeDetector.detectChanges();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being destroyed.
 | 
			
		||||
     */
 | 
			
		||||
    ngOnDestroy(): void {
 | 
			
		||||
        // Stop auto save.
 | 
			
		||||
        this.autoSave.cancelAutoSave();
 | 
			
		||||
        this.autoSave.stopCheckChangesProcess();
 | 
			
		||||
        this.autoSaveErrorSubscription?.unsubscribe();
 | 
			
		||||
 | 
			
		||||
        if (this.quiz) {
 | 
			
		||||
            // Unblock the quiz so it can be synced.
 | 
			
		||||
            CoreSync.instance.unblockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if we can leave the page or not.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Resolved if we can leave it, rejected if not.
 | 
			
		||||
     */
 | 
			
		||||
    async ionViewCanLeave(): Promise<void> {
 | 
			
		||||
        if (this.forceLeave || this.quizAborted || !this.questions.length || this.showSummary) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Save answers.
 | 
			
		||||
        const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.processAttempt(false, false);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // Save attempt failed. Show confirmation.
 | 
			
		||||
            modal.dismiss();
 | 
			
		||||
 | 
			
		||||
            await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmleavequizonerror'));
 | 
			
		||||
 | 
			
		||||
            CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
 | 
			
		||||
        } finally {
 | 
			
		||||
            modal.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Runs when the page is about to leave and no longer be the active page.
 | 
			
		||||
     */
 | 
			
		||||
    async ionViewWillLeave(): Promise<void> {
 | 
			
		||||
        // Close any modal if present.
 | 
			
		||||
        const modal = await ModalController.instance.getTop();
 | 
			
		||||
 | 
			
		||||
        modal?.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Abort the quiz.
 | 
			
		||||
     */
 | 
			
		||||
    abortQuiz(): void {
 | 
			
		||||
        this.quizAborted = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * A behaviour button in a question was clicked (Check, Redo, ...).
 | 
			
		||||
     *
 | 
			
		||||
     * @param button Clicked button.
 | 
			
		||||
     */
 | 
			
		||||
    async behaviourButtonClicked(button: CoreQuestionBehaviourButton): Promise<void> {
 | 
			
		||||
        let modal: CoreIonLoadingElement | undefined;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Confirm that the user really wants to do it.
 | 
			
		||||
            await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure'));
 | 
			
		||||
 | 
			
		||||
            modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
 | 
			
		||||
 | 
			
		||||
            // Get the answers.
 | 
			
		||||
            const answers = await this.prepareAnswers();
 | 
			
		||||
 | 
			
		||||
            // Add the clicked button data.
 | 
			
		||||
            answers[button.name] = button.value;
 | 
			
		||||
 | 
			
		||||
            // Behaviour checks are always in online.
 | 
			
		||||
            await AddonModQuiz.instance.processAttempt(this.quiz!, this.attempt!, answers, this.preflightData);
 | 
			
		||||
 | 
			
		||||
            this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
 | 
			
		||||
 | 
			
		||||
            // Reload the current page.
 | 
			
		||||
            const scrollElement = await this.content?.getScrollElement();
 | 
			
		||||
            const scrollTop = scrollElement?.scrollTop || -1;
 | 
			
		||||
            const scrollLeft = scrollElement?.scrollLeft || -1;
 | 
			
		||||
 | 
			
		||||
            this.loaded = false;
 | 
			
		||||
            this.content?.scrollToTop(); // Scroll top so the spinner is seen.
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                await this.loadPage(this.attempt!.currentpage!);
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.loaded = true;
 | 
			
		||||
                if (scrollTop != -1 && scrollLeft != -1) {
 | 
			
		||||
                    this.content?.scrollToPoint(scrollLeft, scrollTop);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'Error performing action.');
 | 
			
		||||
        } finally {
 | 
			
		||||
            modal?.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the current page. If slot is supplied, try to scroll to that question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param page Page to load. -1 means summary.
 | 
			
		||||
     * @param fromModal Whether the page was selected using the navigation modal.
 | 
			
		||||
     * @param slot Slot of the question to scroll to.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async changePage(page: number, fromModal?: boolean, slot?: number): Promise<void> {
 | 
			
		||||
        if (!this.attempt) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) {
 | 
			
		||||
            // We can't load a page if overdue or the local attempt is finished.
 | 
			
		||||
            return;
 | 
			
		||||
        } else if (page == this.attempt.currentpage && !this.showSummary && typeof slot != 'undefined') {
 | 
			
		||||
            // Navigating to a question in the current page.
 | 
			
		||||
            this.scrollToQuestion(slot);
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        } else if ((page == this.attempt.currentpage && !this.showSummary) || (fromModal && this.isSequential && page != -1)) {
 | 
			
		||||
            // If the user is navigating to the current page we do nothing.
 | 
			
		||||
            // Also, in sequential quizzes we don't allow navigating using the modal except for finishing the quiz (summary).
 | 
			
		||||
            return;
 | 
			
		||||
        } else if (page === -1 && this.showSummary) {
 | 
			
		||||
            // Summary already shown.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.content?.scrollToTop();
 | 
			
		||||
 | 
			
		||||
        // First try to save the attempt data. We only save it if we're not seeing the summary.
 | 
			
		||||
        if (!this.showSummary) {
 | 
			
		||||
            const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                await this.processAttempt(false, false);
 | 
			
		||||
 | 
			
		||||
                modal.dismiss();
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
 | 
			
		||||
                modal.dismiss();
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Attempt data successfully saved, load the page or summary.
 | 
			
		||||
            // Stop checking for changes during page change.
 | 
			
		||||
            this.autoSave.stopCheckChangesProcess();
 | 
			
		||||
 | 
			
		||||
            if (page === -1) {
 | 
			
		||||
                await this.loadSummary();
 | 
			
		||||
            } else {
 | 
			
		||||
                await this.loadPage(page);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // If the user isn't seeing the summary, start the check again.
 | 
			
		||||
            if (!this.showSummary) {
 | 
			
		||||
                this.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
 | 
			
		||||
            if (typeof slot != 'undefined') {
 | 
			
		||||
                // Scroll to the question. Give some time to the questions to render.
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.scrollToQuestion(slot);
 | 
			
		||||
                }, 2000);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convenience function to get the quiz data.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchData(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId);
 | 
			
		||||
 | 
			
		||||
            // Block the quiz so it cannot be synced.
 | 
			
		||||
            CoreSync.instance.blockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
 | 
			
		||||
 | 
			
		||||
            // Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
 | 
			
		||||
            await AddonModQuizSync.instance.waitForSync(this.quiz.id);
 | 
			
		||||
 | 
			
		||||
            this.isSequential = AddonModQuiz.instance.isNavigationSequential(this.quiz);
 | 
			
		||||
 | 
			
		||||
            if (AddonModQuiz.instance.isQuizOffline(this.quiz)) {
 | 
			
		||||
                // Quiz supports offline.
 | 
			
		||||
                this.offline = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                // Quiz doesn't support offline right now, but maybe it did and then the setting was changed.
 | 
			
		||||
                // If we have an unfinished offline attempt then we'll use offline mode.
 | 
			
		||||
                this.offline = await AddonModQuiz.instance.isLastAttemptOfflineUnfinished(this.quiz);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.quiz!.timelimit && this.quiz!.timelimit > 0) {
 | 
			
		||||
                this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz.timelimit);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get access information for the quiz.
 | 
			
		||||
            this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quiz.id, {
 | 
			
		||||
                cmId: this.quiz.coursemodule,
 | 
			
		||||
                readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Get user attempts to determine last attempt.
 | 
			
		||||
            const attempts = await AddonModQuiz.instance.getUserAttempts(this.quiz.id, {
 | 
			
		||||
                cmId: this.quiz.coursemodule,
 | 
			
		||||
                readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (!attempts.length) {
 | 
			
		||||
                // There are no attempts, start a new one.
 | 
			
		||||
                this.newAttempt = true;
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get the last attempt. If it's finished, start a new one.
 | 
			
		||||
            this.lastAttempt = await AddonModQuizHelper.instance.setAttemptCalculatedData(
 | 
			
		||||
                this.quiz,
 | 
			
		||||
                attempts[attempts.length - 1],
 | 
			
		||||
                false,
 | 
			
		||||
                undefined,
 | 
			
		||||
                true,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            this.newAttempt = AddonModQuiz.instance.isAttemptFinished(this.lastAttempt.state);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Finish an attempt, either by timeup or because the user clicked to finish it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param userFinish Whether the user clicked to finish the attempt.
 | 
			
		||||
     * @param timeUp Whether the quiz time is up.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
 | 
			
		||||
        let modal: CoreIonLoadingElement | undefined;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Show confirm if the user clicked the finish button and the quiz is in progress.
 | 
			
		||||
            if (!timeUp && this.attempt!.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
 | 
			
		||||
                await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmclose'));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
 | 
			
		||||
 | 
			
		||||
            await this.processAttempt(userFinish, timeUp);
 | 
			
		||||
 | 
			
		||||
            // Trigger an event to notify the attempt was finished.
 | 
			
		||||
            CoreEvents.trigger<AddonModQuizAttemptFinishedData>(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
 | 
			
		||||
                quizId: this.quiz!.id,
 | 
			
		||||
                attemptId: this.attempt!.id,
 | 
			
		||||
                synced: !this.offline,
 | 
			
		||||
            }, CoreSites.instance.getCurrentSiteId());
 | 
			
		||||
 | 
			
		||||
            CoreEvents.trigger<CoreEventActivityDataSentData>(CoreEvents.ACTIVITY_DATA_SENT, { module: 'quiz' });
 | 
			
		||||
 | 
			
		||||
            // Leave the player.
 | 
			
		||||
            this.forceLeave = true;
 | 
			
		||||
            CoreNavigator.instance.back();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
 | 
			
		||||
        } finally {
 | 
			
		||||
            modal?.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fix sequence checks of current page.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fixSequenceChecks(): Promise<void> {
 | 
			
		||||
        // Get current page data again to get the latest sequencechecks.
 | 
			
		||||
        const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, this.attempt!.currentpage!, this.preflightData, {
 | 
			
		||||
            cmId: this.quiz!.coursemodule,
 | 
			
		||||
            readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const newSequenceChecks: Record<number, { name: string; value: string }> = {};
 | 
			
		||||
 | 
			
		||||
        data.questions.forEach((question) => {
 | 
			
		||||
            const sequenceCheck = CoreQuestionHelper.instance.getQuestionSequenceCheckFromHtml(question.html);
 | 
			
		||||
            if (sequenceCheck) {
 | 
			
		||||
                newSequenceChecks[question.slot] = sequenceCheck;
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Notify the new sequence checks to the components.
 | 
			
		||||
        this.questionComponents?.forEach((component) => {
 | 
			
		||||
            component.updateSequenceCheck(newSequenceChecks);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the input answers.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Object with the answers.
 | 
			
		||||
     */
 | 
			
		||||
    protected getAnswers(): CoreQuestionsAnswers {
 | 
			
		||||
        return CoreQuestionHelper.instance.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the timer if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    protected initTimer(): void {
 | 
			
		||||
        if (!this.attemptAccessInfo?.endtime || this.attemptAccessInfo.endtime < 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Quiz has an end time. Check if time left should be shown.
 | 
			
		||||
        const shouldShowTime = AddonModQuiz.instance.shouldShowTimeLeft(
 | 
			
		||||
            this.quizAccessInfo!.activerulenames,
 | 
			
		||||
            this.attempt!,
 | 
			
		||||
            this.attemptAccessInfo.endtime,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (shouldShowTime) {
 | 
			
		||||
            this.endTime = this.attemptAccessInfo.endtime;
 | 
			
		||||
        } else {
 | 
			
		||||
            delete this.endTime;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load a page questions.
 | 
			
		||||
     *
 | 
			
		||||
     * @param page The page to load.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadPage(page: number): Promise<void> {
 | 
			
		||||
        const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, page, this.preflightData, {
 | 
			
		||||
            cmId: this.quiz!.coursemodule,
 | 
			
		||||
            readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Update attempt, status could change during the execution.
 | 
			
		||||
        this.attempt = data.attempt;
 | 
			
		||||
        this.attempt.currentpage = page;
 | 
			
		||||
 | 
			
		||||
        this.questions = data.questions;
 | 
			
		||||
        this.nextPage = data.nextpage;
 | 
			
		||||
        this.previousPage = this.isSequential ? -1 : page - 1;
 | 
			
		||||
        this.showSummary = false;
 | 
			
		||||
 | 
			
		||||
        this.questions.forEach((question) => {
 | 
			
		||||
            // Get the readable mark for each question.
 | 
			
		||||
            question.readableMark = AddonModQuizHelper.instance.getQuestionMarkFromHtml(question.html);
 | 
			
		||||
 | 
			
		||||
            // Extract the question info box.
 | 
			
		||||
            CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info');
 | 
			
		||||
 | 
			
		||||
            // Check if the question is blocked. If it is, treat it as a description question.
 | 
			
		||||
            if (AddonModQuiz.instance.isQuestionBlocked(question)) {
 | 
			
		||||
                question.type = 'description';
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Mark the page as viewed.
 | 
			
		||||
        CoreUtils.instance.ignoreErrors(
 | 
			
		||||
            AddonModQuiz.instance.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline, this.quiz),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Start looking for changes.
 | 
			
		||||
        this.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load attempt summary.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadSummary(): Promise<void> {
 | 
			
		||||
        this.summaryQuestions = [];
 | 
			
		||||
 | 
			
		||||
        this.summaryQuestions = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, {
 | 
			
		||||
            cmId: this.quiz!.coursemodule,
 | 
			
		||||
            loadLocal: this.offline,
 | 
			
		||||
            readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.showSummary = true;
 | 
			
		||||
        this.canReturn = this.attempt!.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt!.finishedOffline;
 | 
			
		||||
        this.preventSubmitMessages = AddonModQuiz.instance.getPreventSubmitMessages(this.summaryQuestions);
 | 
			
		||||
 | 
			
		||||
        this.dueDateWarning = AddonModQuiz.instance.getAttemptDueDateWarning(this.quiz!, this.attempt!);
 | 
			
		||||
 | 
			
		||||
        // Log summary as viewed.
 | 
			
		||||
        CoreUtils.instance.ignoreErrors(
 | 
			
		||||
            AddonModQuiz.instance.logViewAttemptSummary(this.attempt!.id, this.preflightData, this.quiz!.id, this.quiz!.name),
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load data to navigate the questions using the navigation modal.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadNavigation(): Promise<void> {
 | 
			
		||||
        // We use the attempt summary to build the navigation because it contains all the questions.
 | 
			
		||||
        this.navigation = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, {
 | 
			
		||||
            cmId: this.quiz!.coursemodule,
 | 
			
		||||
            loadLocal: this.offline,
 | 
			
		||||
            readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.navigation.forEach((question) => {
 | 
			
		||||
            question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || '');
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Open the navigation modal.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async openNavigation(): Promise<void> {
 | 
			
		||||
 | 
			
		||||
        if (this.reloadNavigation) {
 | 
			
		||||
            // Some data has changed, reload the navigation.
 | 
			
		||||
            const modal = await CoreDomUtils.instance.showModalLoading();
 | 
			
		||||
 | 
			
		||||
            await CoreUtils.instance.ignoreErrors(this.loadNavigation());
 | 
			
		||||
 | 
			
		||||
            modal.dismiss();
 | 
			
		||||
            this.reloadNavigation = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create the navigation modal.
 | 
			
		||||
        const modal = await ModalController.instance.create({
 | 
			
		||||
            component: AddonModQuizNavigationModalComponent,
 | 
			
		||||
            componentProps: {
 | 
			
		||||
                navigation: this.navigation,
 | 
			
		||||
                summaryShown: this.showSummary,
 | 
			
		||||
                currentPage: this.attempt?.currentpage,
 | 
			
		||||
                isReview: false,
 | 
			
		||||
            },
 | 
			
		||||
            cssClass: 'core-modal-lateral',
 | 
			
		||||
            showBackdrop: true,
 | 
			
		||||
            backdropDismiss: true,
 | 
			
		||||
            // @todo enterAnimation: 'core-modal-lateral-transition',
 | 
			
		||||
            // @todo leaveAnimation: 'core-modal-lateral-transition',
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await modal.present();
 | 
			
		||||
 | 
			
		||||
        const result = await modal.onWillDismiss();
 | 
			
		||||
 | 
			
		||||
        if (result.data && result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) {
 | 
			
		||||
            this.changePage(result.data.page, true, result.data.slot);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepare the answers to be sent for the attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved with the answers.
 | 
			
		||||
     */
 | 
			
		||||
    protected prepareAnswers(): Promise<CoreQuestionsAnswers> {
 | 
			
		||||
        return CoreQuestionHelper.instance.prepareAnswers(
 | 
			
		||||
            this.questions,
 | 
			
		||||
            this.getAnswers(),
 | 
			
		||||
            this.offline,
 | 
			
		||||
            this.component,
 | 
			
		||||
            this.quiz!.coursemodule,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Process attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @param userFinish Whether the user clicked to finish the attempt.
 | 
			
		||||
     * @param timeUp Whether the quiz time is up.
 | 
			
		||||
     * @param retrying Whether we're retrying the change.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async processAttempt(userFinish?: boolean, timeUp?: boolean, retrying?: boolean): Promise<void> {
 | 
			
		||||
        // Get the answers to send.
 | 
			
		||||
        let answers: CoreQuestionsAnswers = {};
 | 
			
		||||
 | 
			
		||||
        if (!this.showSummary) {
 | 
			
		||||
            answers = await this.prepareAnswers();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Send the answers.
 | 
			
		||||
            await AddonModQuiz.instance.processAttempt(
 | 
			
		||||
                this.quiz!,
 | 
			
		||||
                this.attempt!,
 | 
			
		||||
                answers,
 | 
			
		||||
                this.preflightData,
 | 
			
		||||
                userFinish,
 | 
			
		||||
                timeUp,
 | 
			
		||||
                this.offline,
 | 
			
		||||
            );
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (!error || error.errorcode != 'submissionoutofsequencefriendlymessage') {
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                // There was an error with the sequence check. Try to ammend it.
 | 
			
		||||
                await this.fixSequenceChecks();
 | 
			
		||||
            } catch {
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (retrying) {
 | 
			
		||||
                // We're already retrying, don't send the data again because it could cause an infinite loop.
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Sequence checks updated, try to send the data again.
 | 
			
		||||
            return this.processAttempt(userFinish, timeUp, true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Answers saved, cancel auto save.
 | 
			
		||||
        this.autoSave.cancelAutoSave();
 | 
			
		||||
        this.autoSave.hideAutoSaveError();
 | 
			
		||||
 | 
			
		||||
        if (this.formElement) {
 | 
			
		||||
            CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.instance.getCurrentSiteId());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return CoreQuestionHelper.instance.clearTmpData(this.questions, this.component, this.quiz!.coursemodule);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Scroll to a certain question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param slot Slot of the question to scroll to.
 | 
			
		||||
     */
 | 
			
		||||
    protected scrollToQuestion(slot: number): void {
 | 
			
		||||
        CoreDomUtils.instance.scrollToElementBySelector(
 | 
			
		||||
            this.elementRef.nativeElement,
 | 
			
		||||
            this.content,
 | 
			
		||||
            '#addon-mod_quiz-question-' + slot,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show connection error.
 | 
			
		||||
     *
 | 
			
		||||
     * @param ev Click event.
 | 
			
		||||
     */
 | 
			
		||||
    showConnectionError(ev: Event): void {
 | 
			
		||||
        this.autoSave.showAutoSaveError(ev);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convenience function to start the player.
 | 
			
		||||
     */
 | 
			
		||||
    async start(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            this.loaded = false;
 | 
			
		||||
 | 
			
		||||
            if (!this.quizDataLoaded) {
 | 
			
		||||
                // Fetch data.
 | 
			
		||||
                await this.fetchData();
 | 
			
		||||
 | 
			
		||||
                this.quizDataLoaded = true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Quiz data has been loaded, try to start or continue.
 | 
			
		||||
            await this.startOrContinueAttempt();
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start or continue an attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async startOrContinueAttempt(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            let attempt = this.newAttempt ? undefined : this.lastAttempt;
 | 
			
		||||
 | 
			
		||||
            // Get the preflight data and start attempt if needed.
 | 
			
		||||
            attempt = await AddonModQuizHelper.instance.getAndCheckPreflightData(
 | 
			
		||||
                this.quiz!,
 | 
			
		||||
                this.quizAccessInfo!,
 | 
			
		||||
                this.preflightData,
 | 
			
		||||
                attempt,
 | 
			
		||||
                this.offline,
 | 
			
		||||
                false,
 | 
			
		||||
                'addon.mod_quiz.startattempt',
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
 | 
			
		||||
            this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(this.quiz!.id, attempt.id, {
 | 
			
		||||
                cmId: this.quiz!.coursemodule,
 | 
			
		||||
                readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.attempt = attempt;
 | 
			
		||||
 | 
			
		||||
            await this.loadNavigation();
 | 
			
		||||
 | 
			
		||||
            if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
 | 
			
		||||
                // Attempt not overdue and not finished in offline, load page.
 | 
			
		||||
                await this.loadPage(this.attempt.currentpage!);
 | 
			
		||||
 | 
			
		||||
                this.initTimer();
 | 
			
		||||
            } else {
 | 
			
		||||
                // Attempt is overdue or finished in offline, we can only load the summary.
 | 
			
		||||
                await this.loadSummary();
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Quiz time has finished.
 | 
			
		||||
     */
 | 
			
		||||
    timeUp(): void {
 | 
			
		||||
        if (this.timeUpCalled) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.timeUpCalled = true;
 | 
			
		||||
        this.finishAttempt(false, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Question with some calculated data for the view.
 | 
			
		||||
 */
 | 
			
		||||
type QuizQuestion = CoreQuestionQuestionParsed & {
 | 
			
		||||
    readableMark?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										137
									
								
								src/addons/mod/quiz/pages/review/review.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/addons/mod/quiz/pages/review/review.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,137 @@
 | 
			
		||||
<ion-header>
 | 
			
		||||
    <ion-toolbar>
 | 
			
		||||
        <ion-buttons slot="start">
 | 
			
		||||
            <ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
        <ion-title>{{ 'addon.mod_quiz.review' | translate }}</ion-title>
 | 
			
		||||
 | 
			
		||||
        <ion-buttons slot="end">
 | 
			
		||||
            <ion-button *ngIf="navigation.length" [attr.aria-label]="'addon.mod_quiz.opentoc' | translate"
 | 
			
		||||
                (click)="openNavigation()">
 | 
			
		||||
                <ion-icon name="fas-bookmark" slot="icon-only"></ion-icon>
 | 
			
		||||
            </ion-button>
 | 
			
		||||
        </ion-buttons>
 | 
			
		||||
    </ion-toolbar>
 | 
			
		||||
</ion-header>
 | 
			
		||||
<ion-content>
 | 
			
		||||
    <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event.target)">
 | 
			
		||||
        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
 | 
			
		||||
    </ion-refresher>
 | 
			
		||||
    <core-loading [hideUntil]="loaded">
 | 
			
		||||
 | 
			
		||||
        <!-- Review summary -->
 | 
			
		||||
        <ion-card *ngIf="attempt">
 | 
			
		||||
            <ion-card-header class="ion-text-wrap">
 | 
			
		||||
                <ion-card-title>
 | 
			
		||||
                    <span *ngIf="attempt.preview">{{ 'addon.mod_quiz.reviewofpreview' | translate }}</span>
 | 
			
		||||
                    <span *ngIf="!attempt.preview">{{ 'addon.mod_quiz.reviewofattempt' | translate:{$a: attempt.attempt} }}</span>
 | 
			
		||||
                </ion-card-title>
 | 
			
		||||
            </ion-card-header>
 | 
			
		||||
            <ion-list lines="none">
 | 
			
		||||
                <ion-item class="ion-text-wrap">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.startedon' | translate }}</h2>
 | 
			
		||||
                        <p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2>
 | 
			
		||||
                        <p>{{ readableState }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="showCompleted">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.completedon' | translate }}</h2>
 | 
			
		||||
                        <p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="timeTaken">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.timetaken' | translate }}</h2>
 | 
			
		||||
                        <p>{{ timeTaken }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="overTime">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.overdue' | translate }}</h2>
 | 
			
		||||
                        <p>{{ overTime }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="readableMark">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.marks' | translate }}</h2>
 | 
			
		||||
                        <p>{{ readableMark }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngIf="readableGrade">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ 'addon.mod_quiz.grade' | translate }}</h2>
 | 
			
		||||
                        <p>{{ readableGrade }}</p>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
                <ion-item class="ion-text-wrap" *ngFor="let data of additionalData">
 | 
			
		||||
                    <ion-label>
 | 
			
		||||
                        <h2>{{ data.title }}</h2>
 | 
			
		||||
                        <core-format-text [component]="component" [componentId]="cmId" [text]="data.content"
 | 
			
		||||
                            contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
 | 
			
		||||
                        </core-format-text>
 | 
			
		||||
                    </ion-label>
 | 
			
		||||
                </ion-item>
 | 
			
		||||
            </ion-list>
 | 
			
		||||
        </ion-card>
 | 
			
		||||
 | 
			
		||||
        <!-- Questions -->
 | 
			
		||||
        <div *ngIf="attempt && questions.length">
 | 
			
		||||
            <!-- Arrows to go to next/previous. -->
 | 
			
		||||
            <ng-container *ngTemplateOutlet="navArrows"></ng-container>
 | 
			
		||||
 | 
			
		||||
            <!-- Questions. -->
 | 
			
		||||
            <div *ngFor="let question of questions">
 | 
			
		||||
                <ion-card id="addon-mod_quiz-question-{{question.slot}}">
 | 
			
		||||
                    <!-- "Header" of the question. -->
 | 
			
		||||
                    <ion-item-divider>
 | 
			
		||||
                        <ion-label>
 | 
			
		||||
                            <h2 *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</h2>
 | 
			
		||||
                            <h2 *ngIf="!question.number">{{ 'core.question.information' | translate }}</h2>
 | 
			
		||||
                        </ion-label>
 | 
			
		||||
                        <div class="ion-text-wrap ion-margin-horizontal addon-mod_quiz-question-note" slot="end"
 | 
			
		||||
                            *ngIf="question.status || question.readableMark">
 | 
			
		||||
                            <p *ngIf="question.status">{{question.status}}</p>
 | 
			
		||||
                            <p *ngIf="question.readableMark">{{question.readableMark}}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </ion-item-divider>
 | 
			
		||||
 | 
			
		||||
                    <!-- Body of the question. -->
 | 
			
		||||
                    <core-question class="ion-text-wrap" [question]="question" [component]="component" [componentId]="cmId"
 | 
			
		||||
                        [attemptId]="attempt.id" [usageId]="attempt.uniqueid" [offlineEnabled]="false" contextLevel="module"
 | 
			
		||||
                        [contextInstanceId]="cmId" [courseId]="courseId" [review]="true"
 | 
			
		||||
                        [preferredBehaviour]="quiz?.preferredbehaviour">
 | 
			
		||||
                    </core-question>
 | 
			
		||||
                </ion-card>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <!-- Arrows to go to next/previous. -->
 | 
			
		||||
            <ng-container *ngTemplateOutlet="navArrows"></ng-container>
 | 
			
		||||
        </div>
 | 
			
		||||
    </core-loading>
 | 
			
		||||
</ion-content>
 | 
			
		||||
 | 
			
		||||
<!-- Arrows to go to next/previous. -->
 | 
			
		||||
<ng-template #navArrows>
 | 
			
		||||
    <ion-grid>
 | 
			
		||||
        <ion-row class="ion-align-items-center">
 | 
			
		||||
            <ion-col class="ion-text-start">
 | 
			
		||||
                <ion-button color="light" *ngIf="previousPage >= 0" (click)="changePage(previousPage)"
 | 
			
		||||
                    [title]="'core.previous' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-left" slot="icon-only"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </ion-col>
 | 
			
		||||
            <ion-col class="ion-text-end">
 | 
			
		||||
                <ion-button color="light" *ngIf="nextPage >= -1" (click)="changePage(nextPage)" [title]="'core.next' | translate">
 | 
			
		||||
                    <ion-icon name="fas-chevron-right" slot="icon-only"></ion-icon>
 | 
			
		||||
                </ion-button>
 | 
			
		||||
            </ion-col>
 | 
			
		||||
        </ion-row>
 | 
			
		||||
    </ion-grid>
 | 
			
		||||
</ng-template>
 | 
			
		||||
							
								
								
									
										40
									
								
								src/addons/mod/quiz/pages/review/review.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/addons/mod/quiz/pages/review/review.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
import { RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreQuestionComponentsModule } from '@features/question/components/components.module';
 | 
			
		||||
import { AddonModQuizReviewPage } from './review';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: '',
 | 
			
		||||
        component: AddonModQuizReviewPage,
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        RouterModule.forChild(routes),
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
        CoreQuestionComponentsModule,
 | 
			
		||||
    ],
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonModQuizReviewPage,
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [RouterModule],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizReviewPageModule {}
 | 
			
		||||
							
								
								
									
										6
									
								
								src/addons/mod/quiz/pages/review/review.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/addons/mod/quiz/pages/review/review.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
:host {
 | 
			
		||||
    .addon-mod_quiz-question-note p {
 | 
			
		||||
        margin-top: 2px;
 | 
			
		||||
        margin-bottom: 2px;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										367
									
								
								src/addons/mod/quiz/pages/review/review.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										367
									
								
								src/addons/mod/quiz/pages/review/review.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,367 @@
 | 
			
		||||
// (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 { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { IonContent, IonRefresher } from '@ionic/angular';
 | 
			
		||||
import { CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { CoreDomUtils } from '@services/utils/dom';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { ModalController, Translate } from '@singletons';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuizNavigationModalComponent,
 | 
			
		||||
    AddonModQuizNavigationQuestion,
 | 
			
		||||
} from '../../components/navigation-modal/navigation-modal';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuiz,
 | 
			
		||||
    AddonModQuizAttemptWSData,
 | 
			
		||||
    AddonModQuizCombinedReviewOptions,
 | 
			
		||||
    AddonModQuizGetAttemptReviewResponse,
 | 
			
		||||
    AddonModQuizProvider,
 | 
			
		||||
    AddonModQuizQuizWSData,
 | 
			
		||||
    AddonModQuizWSAdditionalData,
 | 
			
		||||
} from '../../services/quiz';
 | 
			
		||||
import { AddonModQuizHelper } from '../../services/quiz-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Page that allows reviewing a quiz attempt.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'page-addon-mod-quiz-review',
 | 
			
		||||
    templateUrl: 'review.html',
 | 
			
		||||
    styleUrls: ['review.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizReviewPage implements OnInit {
 | 
			
		||||
 | 
			
		||||
    @ViewChild(IonContent) content?: IonContent;
 | 
			
		||||
 | 
			
		||||
    attempt?: AddonModQuizAttemptWSData; // The attempt being reviewed.
 | 
			
		||||
    component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
 | 
			
		||||
    showAll = false; // Whether to view all questions in the same page.
 | 
			
		||||
    numPages?: number; // Number of pages.
 | 
			
		||||
    showCompleted = false; // Whether to show completed time.
 | 
			
		||||
    additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt.
 | 
			
		||||
    loaded = false; // Whether data has been loaded.
 | 
			
		||||
    navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them.
 | 
			
		||||
    questions: QuizQuestion[] = []; // Questions of the current page.
 | 
			
		||||
    nextPage = -2; // Next page.
 | 
			
		||||
    previousPage = -2; // Previous page.
 | 
			
		||||
    readableState?: string;
 | 
			
		||||
    readableGrade?: string;
 | 
			
		||||
    readableMark?: string;
 | 
			
		||||
    timeTaken?: string;
 | 
			
		||||
    overTime?: string;
 | 
			
		||||
    quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
 | 
			
		||||
    courseId!: number; // The course ID the quiz belongs to.
 | 
			
		||||
    cmId!: number; // Course module id the attempt belongs to.
 | 
			
		||||
 | 
			
		||||
    protected attemptId!: number; // The attempt being reviewed.
 | 
			
		||||
    protected currentPage!: number; // The current page being reviewed.
 | 
			
		||||
    protected options?: AddonModQuizCombinedReviewOptions; // Review options.
 | 
			
		||||
 | 
			
		||||
    constructor(
 | 
			
		||||
        protected elementRef: ElementRef,
 | 
			
		||||
    ) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Component being initialized.
 | 
			
		||||
     */
 | 
			
		||||
    async ngOnInit(): Promise<void> {
 | 
			
		||||
        this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!;
 | 
			
		||||
        this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!;
 | 
			
		||||
        this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!;
 | 
			
		||||
        this.currentPage = CoreNavigator.instance.getRouteNumberParam('page') || -1;
 | 
			
		||||
        this.showAll = this.currentPage == -1;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchData();
 | 
			
		||||
 | 
			
		||||
            CoreUtils.instance.ignoreErrors(
 | 
			
		||||
                AddonModQuiz.instance.logViewAttemptReview(this.attemptId, this.quiz!.id, this.quiz!.name),
 | 
			
		||||
            );
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the current page. If slot is supplied, try to scroll to that question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param page Page to load. -1 means all questions in same page.
 | 
			
		||||
     * @param fromModal Whether the page was selected using the navigation modal.
 | 
			
		||||
     * @param slot Slot of the question to scroll to.
 | 
			
		||||
     */
 | 
			
		||||
    async changePage(page: number, fromModal?: boolean, slot?: number): Promise<void> {
 | 
			
		||||
        if (typeof slot != 'undefined' && (this.attempt!.currentpage == -1 || page == this.currentPage)) {
 | 
			
		||||
            // Scrol to a certain question in the current page.
 | 
			
		||||
            this.scrollToQuestion(slot);
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        } else if (page == this.currentPage) {
 | 
			
		||||
            // If the user is navigating to the current page and no question specified, we do nothing.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.loaded = false;
 | 
			
		||||
        this.content?.scrollToTop();
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.loadPage(page);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.loaded = true;
 | 
			
		||||
 | 
			
		||||
            if (typeof slot != 'undefined') {
 | 
			
		||||
                // Scroll to the question. Give some time to the questions to render.
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.scrollToQuestion(slot);
 | 
			
		||||
                }, 2000);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convenience function to get the quiz data.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async fetchData(): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId);
 | 
			
		||||
 | 
			
		||||
            this.options = await AddonModQuiz.instance.getCombinedReviewOptions(this.quiz.id, { cmId: this.cmId });
 | 
			
		||||
 | 
			
		||||
            // Load the navigation data.
 | 
			
		||||
            await this.loadNavigation();
 | 
			
		||||
 | 
			
		||||
            // Load questions.
 | 
			
		||||
            await this.loadPage(this.currentPage);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load a page questions.
 | 
			
		||||
     *
 | 
			
		||||
     * @param page The page to load.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadPage(page: number): Promise<void> {
 | 
			
		||||
        const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page, cmId: this.quiz!.coursemodule });
 | 
			
		||||
 | 
			
		||||
        this.attempt = data.attempt;
 | 
			
		||||
        this.attempt.currentpage = page;
 | 
			
		||||
        this.currentPage = page;
 | 
			
		||||
 | 
			
		||||
        // Set the summary data.
 | 
			
		||||
        this.setSummaryCalculatedData(data);
 | 
			
		||||
 | 
			
		||||
        this.questions = data.questions;
 | 
			
		||||
        this.nextPage = page == -1 ? -2 : page + 1;
 | 
			
		||||
        this.previousPage = page - 1;
 | 
			
		||||
 | 
			
		||||
        this.questions.forEach((question) => {
 | 
			
		||||
            // Get the readable mark for each question.
 | 
			
		||||
            question.readableMark = AddonModQuizHelper.instance.getQuestionMarkFromHtml(question.html);
 | 
			
		||||
 | 
			
		||||
            // Extract the question info box.
 | 
			
		||||
            CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info');
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load data to navigate the questions using the navigation modal.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async loadNavigation(): Promise<void> {
 | 
			
		||||
        // Get all questions in single page to retrieve all the questions.
 | 
			
		||||
        const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz!.coursemodule });
 | 
			
		||||
 | 
			
		||||
        this.navigation = data.questions;
 | 
			
		||||
 | 
			
		||||
        this.navigation.forEach((question) => {
 | 
			
		||||
            question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || '');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const lastQuestion = data.questions[data.questions.length - 1];
 | 
			
		||||
        this.numPages = lastQuestion ? lastQuestion.page + 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Refreshes data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param refresher Refresher
 | 
			
		||||
     */
 | 
			
		||||
    async refreshData(refresher: IonRefresher): Promise<void> {
 | 
			
		||||
        const promises: Promise<void>[] = [];
 | 
			
		||||
 | 
			
		||||
        promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId));
 | 
			
		||||
        promises.push(AddonModQuiz.instance.invalidateAttemptReview(this.attemptId));
 | 
			
		||||
        if (this.quiz) {
 | 
			
		||||
            promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(Promise.all(promises));
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await this.fetchData();
 | 
			
		||||
        } finally {
 | 
			
		||||
            refresher.complete();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Scroll to a certain question.
 | 
			
		||||
     *
 | 
			
		||||
     * @param slot Slot of the question to scroll to.
 | 
			
		||||
     */
 | 
			
		||||
    protected scrollToQuestion(slot: number): void {
 | 
			
		||||
        CoreDomUtils.instance.scrollToElementBySelector(
 | 
			
		||||
            this.elementRef.nativeElement,
 | 
			
		||||
            this.content,
 | 
			
		||||
            `#addon-mod_quiz-question-${slot}`,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Calculate review summary data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param data Result of getAttemptReview.
 | 
			
		||||
     */
 | 
			
		||||
    protected setSummaryCalculatedData(data: AddonModQuizGetAttemptReviewResponse): void {
 | 
			
		||||
        if (!this.attempt || !this.quiz) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.readableState = AddonModQuiz.instance.getAttemptReadableStateName(this.attempt!.state || '');
 | 
			
		||||
 | 
			
		||||
        if (this.attempt.state != AddonModQuizProvider.ATTEMPT_FINISHED) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.showCompleted = true;
 | 
			
		||||
        this.additionalData = data.additionaldata;
 | 
			
		||||
 | 
			
		||||
        const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0);
 | 
			
		||||
        if (timeTaken > 0) {
 | 
			
		||||
            // Format time taken.
 | 
			
		||||
            this.timeTaken = CoreTimeUtils.instance.formatTime(timeTaken);
 | 
			
		||||
 | 
			
		||||
            // Calculate overdue time.
 | 
			
		||||
            if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
 | 
			
		||||
                this.overTime = CoreTimeUtils.instance.formatTime(timeTaken - this.quiz.timelimit);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            this.timeTaken = undefined;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Treat grade.
 | 
			
		||||
        if (this.options!.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
 | 
			
		||||
                AddonModQuiz.instance.quizHasGrades(this.quiz)) {
 | 
			
		||||
 | 
			
		||||
            if (data.grade === null || typeof data.grade == 'undefined') {
 | 
			
		||||
                this.readableGrade = AddonModQuiz.instance.formatGrade(data.grade, this.quiz.decimalpoints);
 | 
			
		||||
            } else {
 | 
			
		||||
                // Show raw marks only if they are different from the grade (like on the entry page).
 | 
			
		||||
                if (this.quiz.grade != this.quiz.sumgrades) {
 | 
			
		||||
                    this.readableMark = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: {
 | 
			
		||||
                        grade: AddonModQuiz.instance.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
 | 
			
		||||
                        maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints),
 | 
			
		||||
                    } });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Now the scaled grade.
 | 
			
		||||
                const gradeObject: Record<string, unknown> = {
 | 
			
		||||
                    grade: AddonModQuiz.instance.formatGrade(Number(data.grade), this.quiz.decimalpoints),
 | 
			
		||||
                    maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.grade, this.quiz.decimalpoints),
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                if (this.quiz.grade != 100) {
 | 
			
		||||
                    gradeObject.percent = CoreTextUtils.instance.roundToDecimals(
 | 
			
		||||
                        this.attempt.sumgrades! * 100 / this.quiz.sumgrades!,
 | 
			
		||||
                        0,
 | 
			
		||||
                    );
 | 
			
		||||
                    this.readableGrade = Translate.instance.instant('addon.mod_quiz.outofpercent', { $a: gradeObject });
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.readableGrade = Translate.instance.instant('addon.mod_quiz.outof', { $a: gradeObject });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Treat additional data.
 | 
			
		||||
        this.additionalData.forEach((data) => {
 | 
			
		||||
            // Remove help links from additional data.
 | 
			
		||||
            data.content = CoreDomUtils.instance.removeElementFromHtml(data.content, '.helptooltip');
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Switch mode: all questions in same page OR one page at a time.
 | 
			
		||||
     */
 | 
			
		||||
    switchMode(): void {
 | 
			
		||||
        this.showAll = !this.showAll;
 | 
			
		||||
 | 
			
		||||
        // Load all questions or first page, depending on the mode.
 | 
			
		||||
        this.loadPage(this.showAll ? -1 : 0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async openNavigation(): Promise<void> {
 | 
			
		||||
        // Create the navigation modal.
 | 
			
		||||
        const modal = await ModalController.instance.create({
 | 
			
		||||
            component: AddonModQuizNavigationModalComponent,
 | 
			
		||||
            componentProps: {
 | 
			
		||||
                navigation: this.navigation,
 | 
			
		||||
                summaryShown: false,
 | 
			
		||||
                currentPage: this.attempt?.currentpage,
 | 
			
		||||
                isReview: true,
 | 
			
		||||
                numPages: this.numPages,
 | 
			
		||||
                showAll: this.showAll,
 | 
			
		||||
            },
 | 
			
		||||
            cssClass: 'core-modal-lateral',
 | 
			
		||||
            showBackdrop: true,
 | 
			
		||||
            backdropDismiss: true,
 | 
			
		||||
            // @todo enterAnimation: 'core-modal-lateral-transition',
 | 
			
		||||
            // @todo leaveAnimation: 'core-modal-lateral-transition',
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await modal.present();
 | 
			
		||||
 | 
			
		||||
        const result = await modal.onWillDismiss();
 | 
			
		||||
 | 
			
		||||
        if (!result.data) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) {
 | 
			
		||||
            this.changePage(result.data.page, true, result.data.slot);
 | 
			
		||||
        } else if (result.data.action == AddonModQuizNavigationModalComponent.SWITCH_MODE) {
 | 
			
		||||
            this.switchMode();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Question with some calculated data for the view.
 | 
			
		||||
 */
 | 
			
		||||
type QuizQuestion = CoreQuestionQuestionParsed & {
 | 
			
		||||
    readableMark?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										40
									
								
								src/addons/mod/quiz/quiz-lazy.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/addons/mod/quiz/quiz-lazy.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
// (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 { NgModule } from '@angular/core';
 | 
			
		||||
import { RouterModule, Routes } from '@angular/router';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: ':courseId/:cmId',
 | 
			
		||||
        loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        path: ':courseId/:cmId/player',
 | 
			
		||||
        loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        path: ':courseId/:cmId/attempt/:attemptId',
 | 
			
		||||
        loadChildren: () => import('./pages/attempt/attempt.module').then( m => m.AddonModQuizAttemptPageModule),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        path: ':courseId/:cmId/review/:attemptId',
 | 
			
		||||
        loadChildren: () => import('./pages/review/review.module').then( m => m.AddonModQuizReviewPageModule),
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [RouterModule.forChild(routes)],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizLazyModule {}
 | 
			
		||||
							
								
								
									
										73
									
								
								src/addons/mod/quiz/quiz.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/addons/mod/quiz/quiz.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,73 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
import { Routes } from '@angular/router';
 | 
			
		||||
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
 | 
			
		||||
 | 
			
		||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
 | 
			
		||||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
 | 
			
		||||
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
 | 
			
		||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
 | 
			
		||||
import { CoreCronDelegate } from '@services/cron';
 | 
			
		||||
import { CORE_SITE_SCHEMAS } from '@services/sites';
 | 
			
		||||
import { AddonModQuizAccessRulesModule } from './accessrules/accessrules.module';
 | 
			
		||||
import { AddonModQuizComponentsModule } from './components/components.module';
 | 
			
		||||
import { SITE_SCHEMA } from './services/database/quiz';
 | 
			
		||||
import { AddonModQuizGradeLinkHandler } from './services/handlers/grade-link';
 | 
			
		||||
import { AddonModQuizIndexLinkHandler } from './services/handlers/index-link';
 | 
			
		||||
import { AddonModQuizListLinkHandler } from './services/handlers/list-link';
 | 
			
		||||
import { AddonModQuizModuleHandler, AddonModQuizModuleHandlerService } from './services/handlers/module';
 | 
			
		||||
import { AddonModQuizPrefetchHandler } from './services/handlers/prefetch';
 | 
			
		||||
import { AddonModQuizPushClickHandler } from './services/handlers/push-click';
 | 
			
		||||
import { AddonModQuizReviewLinkHandler } from './services/handlers/review-link';
 | 
			
		||||
import { AddonModQuizSyncCronHandler } from './services/handlers/sync-cron';
 | 
			
		||||
 | 
			
		||||
const routes: Routes = [
 | 
			
		||||
    {
 | 
			
		||||
        path: AddonModQuizModuleHandlerService.PAGE_NAME,
 | 
			
		||||
        loadChildren: () => import('./quiz-lazy.module').then(m => m.AddonModQuizLazyModule),
 | 
			
		||||
    },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreMainMenuTabRoutingModule.forChild(routes),
 | 
			
		||||
        AddonModQuizComponentsModule,
 | 
			
		||||
        AddonModQuizAccessRulesModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: CORE_SITE_SCHEMAS,
 | 
			
		||||
            useValue: [SITE_SCHEMA],
 | 
			
		||||
            multi: true,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreCourseModuleDelegate.instance.registerHandler(AddonModQuizModuleHandler.instance);
 | 
			
		||||
                CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModQuizPrefetchHandler.instance);
 | 
			
		||||
                CoreContentLinksDelegate.instance.registerHandler(AddonModQuizGradeLinkHandler.instance);
 | 
			
		||||
                CoreContentLinksDelegate.instance.registerHandler(AddonModQuizIndexLinkHandler.instance);
 | 
			
		||||
                CoreContentLinksDelegate.instance.registerHandler(AddonModQuizListLinkHandler.instance);
 | 
			
		||||
                CoreContentLinksDelegate.instance.registerHandler(AddonModQuizReviewLinkHandler.instance);
 | 
			
		||||
                CorePushNotificationsDelegate.instance.registerClickHandler(AddonModQuizPushClickHandler.instance);
 | 
			
		||||
                CoreCronDelegate.instance.register(AddonModQuizSyncCronHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonModQuizModule {}
 | 
			
		||||
							
								
								
									
										326
									
								
								src/addons/mod/quiz/services/access-rules-delegate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										326
									
								
								src/addons/mod/quiz/services/access-rules-delegate.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,326 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Interface that all access rules handlers must implement.
 | 
			
		||||
 */
 | 
			
		||||
export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Name of the rule the handler supports. E.g. 'password'.
 | 
			
		||||
     */
 | 
			
		||||
    ruleName: string;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether the rule requires a preflight check when prefetch/start/continue an attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz The quiz the rule belongs to.
 | 
			
		||||
     * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Whether the rule requires a preflight check.
 | 
			
		||||
     */
 | 
			
		||||
    isPreflightCheckRequired(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): boolean | Promise<boolean>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz The quiz the rule belongs to.
 | 
			
		||||
     * @param preflightData Object where to add the preflight data.
 | 
			
		||||
     * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done if async, void if it's synchronous.
 | 
			
		||||
     */
 | 
			
		||||
    getFixedPreflightData?(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): void | Promise<void>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the Component to use to display the access rule preflight.
 | 
			
		||||
     * Implement this if your access rule requires a preflight check with user interaction.
 | 
			
		||||
     * It's recommended to return the class of the component, but you can also return an instance of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @return The component (or promise resolved with component) to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getPreflightComponent?(): Type<unknown> | Promise<Type<unknown>>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function called when the preflight check has passed. This is a chance to record that fact in some way.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz The quiz the rule belongs to.
 | 
			
		||||
     * @param attempt The attempt started/continued.
 | 
			
		||||
     * @param preflightData Preflight data gathered.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done if async, void if it's synchronous.
 | 
			
		||||
     */
 | 
			
		||||
    notifyPreflightCheckPassed?(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData | undefined,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): void | Promise<void>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function called when the preflight check fails. This is a chance to record that fact in some way.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz The quiz the rule belongs to.
 | 
			
		||||
     * @param attempt The attempt started/continued.
 | 
			
		||||
     * @param preflightData Preflight data gathered.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done if async, void if it's synchronous.
 | 
			
		||||
     */
 | 
			
		||||
    notifyPreflightCheckFailed?(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData | undefined,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): void | Promise<void>;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the time left of an attempt should be displayed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attempt The attempt.
 | 
			
		||||
     * @param endTime The attempt end time (in seconds).
 | 
			
		||||
     * @param timeNow The current time in seconds.
 | 
			
		||||
     * @return Whether it should be displayed.
 | 
			
		||||
     */
 | 
			
		||||
    shouldShowTimeLeft?(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Delegate to register access rules for quiz module.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizAccessRuleDelegateService extends CoreDelegate<AddonModQuizAccessRuleHandler> {
 | 
			
		||||
 | 
			
		||||
    protected handlerNameProperty = 'ruleName';
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonModQuizAccessRulesDelegate', true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the handler for a certain rule.
 | 
			
		||||
     *
 | 
			
		||||
     * @param ruleName Name of the access rule.
 | 
			
		||||
     * @return Handler. Undefined if no handler found for the rule.
 | 
			
		||||
     */
 | 
			
		||||
    getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler {
 | 
			
		||||
        return this.getHandler(ruleName, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Given a list of rules, get some fixed preflight data (data that doesn't require user interaction).
 | 
			
		||||
     *
 | 
			
		||||
     * @param rules List of active rules names.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param preflightData Object where to store the preflight data.
 | 
			
		||||
     * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when all the data has been gathered.
 | 
			
		||||
     */
 | 
			
		||||
    async getFixedPreflightData(
 | 
			
		||||
        rules: string[],
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        rules = rules || [];
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
 | 
			
		||||
            await this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]);
 | 
			
		||||
        })));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the Component to use to display the access rule preflight.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rule Rule.
 | 
			
		||||
     * @return Promise resolved with the component to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    getPreflightComponent(rule: string): Promise<Type<unknown> | undefined> {
 | 
			
		||||
        return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', []));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if an access rule is supported.
 | 
			
		||||
     *
 | 
			
		||||
     * @param ruleName Name of the rule.
 | 
			
		||||
     * @return Whether it's supported.
 | 
			
		||||
     */
 | 
			
		||||
    isAccessRuleSupported(ruleName: string): boolean {
 | 
			
		||||
        return this.hasHandler(ruleName, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Given a list of rules, check if preflight check is required.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rules List of active rules names.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with boolean: whether it's required.
 | 
			
		||||
     */
 | 
			
		||||
    async isPreflightCheckRequired(
 | 
			
		||||
        rules: string[],
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        rules = rules || [];
 | 
			
		||||
        let isRequired = false;
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
 | 
			
		||||
            const ruleRequired = await this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId);
 | 
			
		||||
 | 
			
		||||
            isRequired = isRequired || ruleRequired;
 | 
			
		||||
        })));
 | 
			
		||||
 | 
			
		||||
        return isRequired;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if preflight check is required for a certain rule.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rule Rule name.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with boolean: whether it's required.
 | 
			
		||||
     */
 | 
			
		||||
    async isPreflightCheckRequiredForRule(
 | 
			
		||||
        rule: string,
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        const isRequired = await this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]);
 | 
			
		||||
 | 
			
		||||
        return !!isRequired;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Notify all rules that the preflight check has passed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rules List of active rules names.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight data gathered.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async notifyPreflightCheckPassed(
 | 
			
		||||
        rules: string[],
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData | undefined,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        rules = rules || [];
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
 | 
			
		||||
            await this.executeFunctionOnEnabled(
 | 
			
		||||
                rule,
 | 
			
		||||
                'notifyPreflightCheckPassed',
 | 
			
		||||
                [quiz, attempt, preflightData, prefetch, siteId],
 | 
			
		||||
            );
 | 
			
		||||
        })));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Notify all rules that the preflight check has failed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rules List of active rules names.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight data gathered.
 | 
			
		||||
     * @param prefetch Whether the user is prefetching the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async notifyPreflightCheckFailed(
 | 
			
		||||
        rules: string[],
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData | undefined,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        rules = rules || [];
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
 | 
			
		||||
            await this.executeFunctionOnEnabled(
 | 
			
		||||
                rule,
 | 
			
		||||
                'notifyPreflightCheckFailed',
 | 
			
		||||
                [quiz, attempt, preflightData, prefetch, siteId],
 | 
			
		||||
            );
 | 
			
		||||
        })));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the time left of an attempt should be displayed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param rules List of active rules names.
 | 
			
		||||
     * @param attempt The attempt.
 | 
			
		||||
     * @param endTime The attempt end time (in seconds).
 | 
			
		||||
     * @param timeNow The current time in seconds.
 | 
			
		||||
     * @return Whether it should be displayed.
 | 
			
		||||
     */
 | 
			
		||||
    shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean {
 | 
			
		||||
        rules = rules || [];
 | 
			
		||||
 | 
			
		||||
        for (const i in rules) {
 | 
			
		||||
            const rule = rules[i];
 | 
			
		||||
 | 
			
		||||
            if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizAccessRuleDelegate extends makeSingleton(AddonModQuizAccessRuleDelegateService) {}
 | 
			
		||||
							
								
								
									
										83
									
								
								src/addons/mod/quiz/services/database/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/addons/mod/quiz/services/database/quiz.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,83 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { CoreSiteSchema } from '@services/sites';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Database variables for AddonModQuizOfflineProvider.
 | 
			
		||||
 */
 | 
			
		||||
export const ATTEMPTS_TABLE_NAME = 'addon_mod_quiz_attempts';
 | 
			
		||||
export const SITE_SCHEMA: CoreSiteSchema = {
 | 
			
		||||
    name: 'AddonModQuizOfflineProvider',
 | 
			
		||||
    version: 1,
 | 
			
		||||
    tables: [
 | 
			
		||||
        {
 | 
			
		||||
            name: ATTEMPTS_TABLE_NAME,
 | 
			
		||||
            columns: [
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'id', // Attempt ID.
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                    primaryKey: true,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'attempt', // Attempt number.
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'courseid',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'userid',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'quizid',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'currentpage',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'timecreated',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'timemodified',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'finished',
 | 
			
		||||
                    type: 'INTEGER',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Quiz attempt.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizAttemptDBRecord = {
 | 
			
		||||
    id: number;
 | 
			
		||||
    attempt: number;
 | 
			
		||||
    courseid: number;
 | 
			
		||||
    userid: number;
 | 
			
		||||
    quizid: number;
 | 
			
		||||
    currentpage?: number;
 | 
			
		||||
    timecreated: number;
 | 
			
		||||
    timemodified: number;
 | 
			
		||||
    finished: number;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										35
									
								
								src/addons/mod/quiz/services/handlers/grade-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/addons/mod/quiz/services/handlers/grade-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
// (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 { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to treat links to quiz grade.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizGradeLinkHandler';
 | 
			
		||||
    canReview = false;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonModQuiz', 'quiz');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizGradeLinkHandler extends makeSingleton(AddonModQuizGradeLinkHandlerService) {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/mod/quiz/services/handlers/index-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/mod/quiz/services/handlers/index-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to treat links to quiz index.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizIndexLinkHandler';
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonModQuiz', 'quiz', 'q');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizIndexLinkHandler extends makeSingleton(AddonModQuizIndexLinkHandlerService) {}
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/mod/quiz/services/handlers/list-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/mod/quiz/services/handlers/list-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to treat links to quiz list page.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizListLinkHandlerService extends CoreContentLinksModuleListHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizListLinkHandler';
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonModQuiz', 'quiz');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizListLinkHandler extends makeSingleton(AddonModQuizListLinkHandlerService) {}
 | 
			
		||||
							
								
								
									
										98
									
								
								src/addons/mod/quiz/services/handlers/module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/addons/mod/quiz/services/handlers/module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,98 @@
 | 
			
		||||
// (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 { CoreConstants } from '@/core/constants';
 | 
			
		||||
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
 | 
			
		||||
import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
 | 
			
		||||
import { CoreCourseModule } from '@features/course/services/course-helper';
 | 
			
		||||
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
 | 
			
		||||
import { AddonModQuizIndexComponent } from '../../components/index';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support quiz modules.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizModuleHandlerService implements CoreCourseModuleHandler {
 | 
			
		||||
 | 
			
		||||
    static readonly PAGE_NAME = 'mod_quiz';
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuiz';
 | 
			
		||||
    modName = 'quiz';
 | 
			
		||||
 | 
			
		||||
    supportedFeatures = {
 | 
			
		||||
        [CoreConstants.FEATURE_GROUPS]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_GROUPINGS]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_MOD_INTRO]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_CONTROLS_GRADE_VISIBILITY]: true,
 | 
			
		||||
        [CoreConstants.FEATURE_USES_QUESTIONS]: true,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Whether or not the handler is enabled on a site level.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the data required to display the module in the course contents view.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module The module object.
 | 
			
		||||
     * @param courseId The course ID.
 | 
			
		||||
     * @param sectionId The section ID.
 | 
			
		||||
     * @return Data to render the module.
 | 
			
		||||
     */
 | 
			
		||||
    getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
 | 
			
		||||
        return {
 | 
			
		||||
            icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
 | 
			
		||||
            title: module.name,
 | 
			
		||||
            class: 'addon-mod_quiz-handler',
 | 
			
		||||
            showDownloadButton: true,
 | 
			
		||||
            action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => {
 | 
			
		||||
                options = options || {};
 | 
			
		||||
                options.params = options.params || {};
 | 
			
		||||
                Object.assign(options.params, { module });
 | 
			
		||||
                const routeParams = '/' + courseId + '/' + module.id;
 | 
			
		||||
 | 
			
		||||
                CoreNavigator.instance.navigateToSitePath(AddonModQuizModuleHandlerService.PAGE_NAME + routeParams, options);
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the component to render the module. This is needed to support singleactivity course format.
 | 
			
		||||
     * The component returned must implement CoreCourseModuleMainComponent.
 | 
			
		||||
     *
 | 
			
		||||
     * @param course The course object.
 | 
			
		||||
     * @param module The module object.
 | 
			
		||||
     * @return The component to use, undefined if not found.
 | 
			
		||||
     */
 | 
			
		||||
    async getMainComponent(): Promise<Type<unknown>> {
 | 
			
		||||
        return AddonModQuizIndexComponent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizModuleHandler extends makeSingleton(AddonModQuizModuleHandlerService) {}
 | 
			
		||||
							
								
								
									
										659
									
								
								src/addons/mod/quiz/services/handlers/prefetch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										659
									
								
								src/addons/mod/quiz/services/handlers/prefetch.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,659 @@
 | 
			
		||||
// (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 { CoreConstants } from '@/core/constants';
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
import { CoreError } from '@classes/errors/error';
 | 
			
		||||
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
 | 
			
		||||
import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { CoreFilepool } from '@services/filepool';
 | 
			
		||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
 | 
			
		||||
import { CoreTextUtils } from '@services/utils/text';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { CoreWSExternalFile } from '@services/ws';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuizAccessRuleDelegate } from '../access-rules-delegate';
 | 
			
		||||
import {
 | 
			
		||||
    AddonModQuiz,
 | 
			
		||||
    AddonModQuizAttemptWSData,
 | 
			
		||||
    AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
    AddonModQuizProvider,
 | 
			
		||||
    AddonModQuizQuizWSData,
 | 
			
		||||
} from '../quiz';
 | 
			
		||||
import { AddonModQuizHelper } from '../quiz-helper';
 | 
			
		||||
import { AddonModQuizSync, AddonModQuizSyncResult } from '../quiz-sync';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to prefetch quizzes.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuiz';
 | 
			
		||||
    modName = 'quiz';
 | 
			
		||||
    component = AddonModQuizProvider.COMPONENT;
 | 
			
		||||
    updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Download the module.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module The module object returned by WS.
 | 
			
		||||
     * @param courseId Course ID.
 | 
			
		||||
     * @param dirPath Path of the directory where to store all the content files.
 | 
			
		||||
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
 | 
			
		||||
     * @param canStart If true, start a new attempt if needed.
 | 
			
		||||
     * @return Promise resolved when all content is downloaded.
 | 
			
		||||
     */
 | 
			
		||||
    download(
 | 
			
		||||
        module: CoreCourseAnyModuleData,
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        dirPath?: string,
 | 
			
		||||
        single?: boolean,
 | 
			
		||||
        canStart: boolean = true,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        // Same implementation for download and prefetch.
 | 
			
		||||
        return this.prefetch(module, courseId, single, dirPath, canStart);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get list of files. If not defined, we'll assume they're in module.contents.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to.
 | 
			
		||||
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
 | 
			
		||||
     * @return Promise resolved with the list of files.
 | 
			
		||||
     */
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    async getFiles(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreWSExternalFile[]> {
 | 
			
		||||
        try {
 | 
			
		||||
            const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id);
 | 
			
		||||
 | 
			
		||||
            const files = this.getIntroFilesFromInstance(module, quiz);
 | 
			
		||||
 | 
			
		||||
            const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, {
 | 
			
		||||
                cmId: module.id,
 | 
			
		||||
                readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts);
 | 
			
		||||
 | 
			
		||||
            return files.concat(attemptFiles);
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Quiz not found, return empty list.
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the list of downloadable files on feedback attemptss.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempts Quiz user attempts.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return List of Files.
 | 
			
		||||
     */
 | 
			
		||||
    protected async getAttemptsFeedbackFiles(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempts: AddonModQuizAttemptWSData[],
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<CoreWSExternalFile[]> {
 | 
			
		||||
        const getInlineFiles = CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.2');
 | 
			
		||||
        let files: CoreWSExternalFile[] = [];
 | 
			
		||||
 | 
			
		||||
        await Promise.all(attempts.map(async (attempt) => {
 | 
			
		||||
            if (!AddonModQuiz.instance.isAttemptFinished(attempt.state)) {
 | 
			
		||||
                // Attempt not finished, no feedback files.
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false);
 | 
			
		||||
            if (typeof attemptGrade == 'undefined') {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const feedback = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), {
 | 
			
		||||
                cmId: quiz.coursemodule,
 | 
			
		||||
                readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
                siteId,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (getInlineFiles && feedback.feedbackinlinefiles?.length) {
 | 
			
		||||
                files = files.concat(feedback.feedbackinlinefiles);
 | 
			
		||||
            } else if (feedback.feedbacktext && !getInlineFiles) {
 | 
			
		||||
                files = files.concat(
 | 
			
		||||
                    CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(feedback.feedbacktext),
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return files;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Gather some preflight data for an attempt. This function will start a new attempt if needed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation.
 | 
			
		||||
     * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt.
 | 
			
		||||
     * @param askPreflight Whether it should ask for preflight data if needed.
 | 
			
		||||
     * @param modalTitle Lang key of the title to set to preflight modal (e.g. 'addon.mod_quiz.startattempt').
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with the preflight data.
 | 
			
		||||
     */
 | 
			
		||||
    async getPreflightData(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
        attempt?: AddonModQuizAttemptWSData,
 | 
			
		||||
        askPreflight?: boolean,
 | 
			
		||||
        title?: string,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<Record<string, string>> {
 | 
			
		||||
        const preflightData: Record<string, string> = {};
 | 
			
		||||
 | 
			
		||||
        if (askPreflight) {
 | 
			
		||||
            // We can ask preflight, check if it's needed and get the data.
 | 
			
		||||
            await AddonModQuizHelper.instance.getAndCheckPreflightData(
 | 
			
		||||
                quiz,
 | 
			
		||||
                accessInfo,
 | 
			
		||||
                preflightData,
 | 
			
		||||
                attempt,
 | 
			
		||||
                false,
 | 
			
		||||
                true,
 | 
			
		||||
                title,
 | 
			
		||||
                siteId,
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
            // Get some fixed preflight data from access rules (data that doesn't require user interaction).
 | 
			
		||||
            const rules = accessInfo?.activerulenames || [];
 | 
			
		||||
 | 
			
		||||
            await AddonModQuizAccessRuleDelegate.instance.getFixedPreflightData(rules, quiz, preflightData, attempt, true, siteId);
 | 
			
		||||
 | 
			
		||||
            if (!attempt) {
 | 
			
		||||
                // We need to create a new attempt.
 | 
			
		||||
                await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return preflightData;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Invalidate the prefetched content.
 | 
			
		||||
     *
 | 
			
		||||
     * @param moduleId The module ID.
 | 
			
		||||
     * @param courseId The course ID the module belongs to.
 | 
			
		||||
     * @return Promise resolved when the data is invalidated.
 | 
			
		||||
     */
 | 
			
		||||
    invalidateContent(moduleId: number, courseId: number): Promise<void> {
 | 
			
		||||
        return AddonModQuiz.instance.invalidateContent(moduleId, courseId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Invalidate WS calls needed to determine module status.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to.
 | 
			
		||||
     * @return Promise resolved when invalidated.
 | 
			
		||||
     */
 | 
			
		||||
    async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
 | 
			
		||||
        // Invalidate the calls required to check if a quiz is downloadable.
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            AddonModQuiz.instance.invalidateQuizData(courseId),
 | 
			
		||||
            AddonModQuiz.instance.invalidateUserAttemptsForUser(module.instance!),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to.
 | 
			
		||||
     * @return Whether the module can be downloaded. The promise should never be rejected.
 | 
			
		||||
     */
 | 
			
		||||
    async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
 | 
			
		||||
        if (CoreSites.instance.getCurrentSite()?.isOfflineDisabled()) {
 | 
			
		||||
            // Don't allow downloading the quiz if offline is disabled to prevent wasting a lot of data when opening it.
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const siteId = CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId });
 | 
			
		||||
 | 
			
		||||
        if (quiz.allowofflineattempts !== 1 || quiz.hasquestions === 0) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Not downloadable if we reached max attempts or the quiz has an unfinished attempt.
 | 
			
		||||
        const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, {
 | 
			
		||||
            cmId: module.id,
 | 
			
		||||
            siteId,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const isLastFinished = !attempts.length || AddonModQuiz.instance.isAttemptFinished(attempts[attempts.length - 1].state);
 | 
			
		||||
 | 
			
		||||
        return quiz.attempts === 0 || quiz.attempts! > attempts.length || !isLastFinished;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch a module.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to.
 | 
			
		||||
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
 | 
			
		||||
     * @param dirPath Path of the directory where to store all the content files.
 | 
			
		||||
     * @param canStart If true, start a new attempt if needed.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetch(
 | 
			
		||||
        module: SyncedModule,
 | 
			
		||||
        courseId?: number,
 | 
			
		||||
        single?: boolean,
 | 
			
		||||
        dirPath?: string,
 | 
			
		||||
        canStart: boolean = true,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        if (module.attemptFinished) {
 | 
			
		||||
            // Delete the value so it does not block anything if true.
 | 
			
		||||
            delete module.attemptFinished;
 | 
			
		||||
 | 
			
		||||
            // Quiz got synced recently and an attempt has finished. Do not prefetch.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const siteId = CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        return this.prefetchPackage(module, courseId, this.prefetchQuiz.bind(this, module, courseId, single, siteId, canStart));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch a quiz.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to.
 | 
			
		||||
     * @param single True if we're downloading a single module, false if we're downloading a whole section.
 | 
			
		||||
     * @param siteId Site ID.
 | 
			
		||||
     * @param canStart If true, start a new attempt if needed.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async prefetchQuiz(
 | 
			
		||||
        module: CoreCourseAnyModuleData,
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        single: boolean,
 | 
			
		||||
        siteId: string,
 | 
			
		||||
        canStart: boolean,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        const commonOptions = {
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        };
 | 
			
		||||
        const modOptions = {
 | 
			
		||||
            cmId: module.id,
 | 
			
		||||
            ...commonOptions, // Include all common options.
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Get quiz.
 | 
			
		||||
        const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, commonOptions);
 | 
			
		||||
 | 
			
		||||
        const introFiles = this.getIntroFilesFromInstance(module, quiz);
 | 
			
		||||
 | 
			
		||||
        // Prefetch some quiz data.
 | 
			
		||||
        // eslint-disable-next-line prefer-const
 | 
			
		||||
        let [quizAccessInfo, attempts, attemptAccessInfo] = await Promise.all([
 | 
			
		||||
            AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions),
 | 
			
		||||
            CoreFilepool.instance.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Check if we need to start a new attempt.
 | 
			
		||||
        let attempt: AddonModQuizAttemptWSData | undefined = attempts[attempts.length - 1];
 | 
			
		||||
        let preflightData: Record<string, string> = {};
 | 
			
		||||
        let startAttempt = false;
 | 
			
		||||
 | 
			
		||||
        if (canStart || attempt) {
 | 
			
		||||
            if (canStart && (!attempt || AddonModQuiz.instance.isAttemptFinished(attempt.state))) {
 | 
			
		||||
                // Check if the user can attempt the quiz.
 | 
			
		||||
                if (attemptAccessInfo.preventnewattemptreasons.length) {
 | 
			
		||||
                    throw new CoreError(CoreTextUtils.instance.buildMessage(attemptAccessInfo.preventnewattemptreasons));
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                startAttempt = true;
 | 
			
		||||
                attempt = undefined;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get the preflight data. This function will also start a new attempt if needed.
 | 
			
		||||
            preflightData = await this.getPreflightData(quiz, quizAccessInfo, attempt, single, 'core.download', siteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const promises: Promise<unknown>[] = [];
 | 
			
		||||
 | 
			
		||||
        if (startAttempt) {
 | 
			
		||||
            // Re-fetch user attempts since we created a new one.
 | 
			
		||||
            promises.push(AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions).then(async (atts) => {
 | 
			
		||||
                attempts = atts;
 | 
			
		||||
 | 
			
		||||
                const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts, siteId);
 | 
			
		||||
 | 
			
		||||
                return CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id);
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
            // Update the download time to prevent detecting the new attempt as an update.
 | 
			
		||||
            promises.push(CoreUtils.instance.ignoreErrors(
 | 
			
		||||
                CoreFilepool.instance.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id),
 | 
			
		||||
            ));
 | 
			
		||||
        } else {
 | 
			
		||||
            // Use the already fetched attempts.
 | 
			
		||||
            promises.push(this.getAttemptsFeedbackFiles(quiz, attempts, siteId).then((attemptFiles) =>
 | 
			
		||||
                CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id)));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Fetch attempt related data.
 | 
			
		||||
        promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions));
 | 
			
		||||
        promises.push(AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions));
 | 
			
		||||
        promises.push(this.prefetchGradeAndFeedback(quiz, modOptions, siteId));
 | 
			
		||||
        promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions)); // Last attempt.
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
 | 
			
		||||
        // We have quiz data, now we'll get specific data for each attempt.
 | 
			
		||||
        await Promise.all(attempts.map(async (attempt) => {
 | 
			
		||||
            await this.prefetchAttempt(quiz, attempt, preflightData, siteId);
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        if (!canStart) {
 | 
			
		||||
            // Nothing else to do.
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If there's nothing to send, mark the quiz as synchronized.
 | 
			
		||||
        const hasData = await AddonModQuizSync.instance.hasDataToSync(quiz.id, siteId);
 | 
			
		||||
 | 
			
		||||
        if (!hasData) {
 | 
			
		||||
            AddonModQuizSync.instance.setSyncTime(quiz.id, siteId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch all WS data for an attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param preflightData Preflight required data (like password).
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when the prefetch is finished. Data returned is not reliable.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetchAttempt(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        const pages = AddonModQuiz.instance.getPagesFromLayout(attempt.layout);
 | 
			
		||||
        const isSequential = AddonModQuiz.instance.isNavigationSequential(quiz);
 | 
			
		||||
        let promises: Promise<unknown>[] = [];
 | 
			
		||||
 | 
			
		||||
        const modOptions: CoreCourseCommonModWSOptions = {
 | 
			
		||||
            cmId: quiz.coursemodule,
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (AddonModQuiz.instance.isAttemptFinished(attempt.state)) {
 | 
			
		||||
            // Attempt is finished, get feedback and review data.
 | 
			
		||||
            const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false);
 | 
			
		||||
            if (typeof attemptGrade != 'undefined') {
 | 
			
		||||
                promises.push(AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), modOptions));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Get the review for each page.
 | 
			
		||||
            pages.forEach((page) => {
 | 
			
		||||
                promises.push(CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, {
 | 
			
		||||
                    page,
 | 
			
		||||
                    ...modOptions, // Include all options.
 | 
			
		||||
                })));
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Get the review for all questions in same page.
 | 
			
		||||
            promises.push(this.prefetchAttemptReviewFiles(quiz, attempt, modOptions, siteId));
 | 
			
		||||
        } else {
 | 
			
		||||
 | 
			
		||||
            // Attempt not finished, get data needed to continue the attempt.
 | 
			
		||||
            promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, attempt.id, modOptions));
 | 
			
		||||
            promises.push(AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions));
 | 
			
		||||
 | 
			
		||||
            if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
 | 
			
		||||
                // Get data for each page.
 | 
			
		||||
                promises = promises.concat(pages.map(async (page) => {
 | 
			
		||||
                    if (isSequential && page < attempt.currentpage!) {
 | 
			
		||||
                        // Sequential quiz, cannot get pages before the current one.
 | 
			
		||||
                        return;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const data = await AddonModQuiz.instance.getAttemptData(attempt.id, page, preflightData, modOptions);
 | 
			
		||||
 | 
			
		||||
                    // Download the files inside the questions.
 | 
			
		||||
                    await Promise.all(data.questions.map(async (question) => {
 | 
			
		||||
                        await CoreQuestionHelper.instance.prefetchQuestionFiles(
 | 
			
		||||
                            question,
 | 
			
		||||
                            this.component,
 | 
			
		||||
                            quiz.coursemodule,
 | 
			
		||||
                            siteId,
 | 
			
		||||
                            attempt.uniqueid,
 | 
			
		||||
                        );
 | 
			
		||||
                    }));
 | 
			
		||||
 | 
			
		||||
                }));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Promise.all(promises);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch attempt review and its files.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param options Other options.
 | 
			
		||||
     * @param siteId Site ID.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async prefetchAttemptReviewFiles(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        modOptions: CoreCourseCommonModWSOptions,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        // Get the review for all questions in same page.
 | 
			
		||||
        const data = await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, {
 | 
			
		||||
            page: -1,
 | 
			
		||||
            ...modOptions, // Include all options.
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        if (!data) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        // Download the files inside the questions.
 | 
			
		||||
        await Promise.all(data.questions.map((question) => {
 | 
			
		||||
            CoreQuestionHelper.instance.prefetchQuestionFiles(
 | 
			
		||||
                question,
 | 
			
		||||
                this.component,
 | 
			
		||||
                quiz.coursemodule,
 | 
			
		||||
                siteId,
 | 
			
		||||
                attempt.uniqueid,
 | 
			
		||||
            );
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetch quiz grade and its feedback.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param modOptions Other options.
 | 
			
		||||
     * @param siteId Site ID.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    protected async prefetchGradeAndFeedback(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        modOptions: CoreCourseCommonModWSOptions,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        try {
 | 
			
		||||
            const gradebookData = await AddonModQuiz.instance.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId);
 | 
			
		||||
 | 
			
		||||
            if (gradebookData && 'graderaw' in gradebookData && gradebookData.graderaw !== undefined) {
 | 
			
		||||
                await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, gradebookData.graderaw, modOptions);
 | 
			
		||||
            }
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prefetches some data for a quiz and its last attempt.
 | 
			
		||||
     * This function will NOT start a new attempt, it only reads data for the quiz and the last attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param askPreflight Whether it should ask for preflight data if needed.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetchQuizAndLastAttempt(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        const modOptions = {
 | 
			
		||||
            cmId: quiz.coursemodule,
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Get quiz data.
 | 
			
		||||
        const [quizAccessInfo, attempts] = await Promise.all([
 | 
			
		||||
            AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions),
 | 
			
		||||
            AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions),
 | 
			
		||||
            this.prefetchGradeAndFeedback(quiz, modOptions, siteId),
 | 
			
		||||
            AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions), // Last attempt.
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        const lastAttempt = attempts[attempts.length - 1];
 | 
			
		||||
        let preflightData: Record<string, string> = {};
 | 
			
		||||
        if (lastAttempt) {
 | 
			
		||||
            // Get the preflight data.
 | 
			
		||||
            preflightData = await this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId);
 | 
			
		||||
 | 
			
		||||
            // Get data for last attempt.
 | 
			
		||||
            await this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Prefetch finished, set the right status.
 | 
			
		||||
        await this.setStatusAfterPrefetch(quiz, {
 | 
			
		||||
            cmId: quiz.coursemodule,
 | 
			
		||||
            attempts,
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.PreferCache,
 | 
			
		||||
            siteId,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the right status to a quiz after prefetching.
 | 
			
		||||
     * If the last attempt is finished or there isn't one, set it as not downloaded to show download icon.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param options Other options.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async setStatusAfterPrefetch(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        options: AddonModQuizSetStatusAfterPrefetchOptions = {},
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        let attempts = options.attempts;
 | 
			
		||||
 | 
			
		||||
        if (!attempts) {
 | 
			
		||||
            // Get the attempts.
 | 
			
		||||
            attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, options);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check the current status of the quiz.
 | 
			
		||||
        const status = await CoreFilepool.instance.getPackageStatus(options.siteId, this.component, quiz.coursemodule);
 | 
			
		||||
 | 
			
		||||
        if (status === CoreConstants.NOT_DOWNLOADED) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Quiz was downloaded, set the new status.
 | 
			
		||||
        // If no attempts or last is finished we'll mark it as not downloaded to show download icon.
 | 
			
		||||
        const lastAttempt = attempts[attempts.length - 1];
 | 
			
		||||
        const isLastFinished = !lastAttempt || AddonModQuiz.instance.isAttemptFinished(lastAttempt.state);
 | 
			
		||||
        const newStatus = isLastFinished ? CoreConstants.NOT_DOWNLOADED : CoreConstants.DOWNLOADED;
 | 
			
		||||
 | 
			
		||||
        await CoreFilepool.instance.storePackageStatus(options.siteId, newStatus, this.component, quiz.coursemodule);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync a module.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param courseId Course ID the module belongs to
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async sync(module: SyncedModule, courseId: number, siteId?: string): Promise<AddonModQuizSyncResult | undefined> {
 | 
			
		||||
        const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId });
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const result = await AddonModQuizSync.instance.syncQuiz(quiz, false, siteId);
 | 
			
		||||
 | 
			
		||||
            module.attemptFinished = result.attemptFinished || false;
 | 
			
		||||
 | 
			
		||||
            return result;
 | 
			
		||||
        } catch {
 | 
			
		||||
            // Ignore errors.
 | 
			
		||||
            module.attemptFinished = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizPrefetchHandler extends makeSingleton(AddonModQuizPrefetchHandlerService) {}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options to pass to setStatusAfterPrefetch.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizSetStatusAfterPrefetchOptions = CoreCourseCommonModWSOptions & {
 | 
			
		||||
    attempts?: AddonModQuizAttemptWSData[]; // List of attempts. If not provided, they will be calculated.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Module data with some calculated data.
 | 
			
		||||
 */
 | 
			
		||||
type SyncedModule = CoreCourseAnyModuleData & {
 | 
			
		||||
    attemptFinished?: boolean;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										86
									
								
								src/addons/mod/quiz/services/handlers/push-click.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/addons/mod/quiz/services/handlers/push-click.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,86 @@
 | 
			
		||||
// (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 { CoreCourseHelper } from '@features/course/services/course-helper';
 | 
			
		||||
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
 | 
			
		||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
 | 
			
		||||
import { CoreUrlUtils } from '@services/utils/url';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuiz } from '../quiz';
 | 
			
		||||
import { AddonModQuizHelper } from '../quiz-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler for quiz push notifications clicks.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizPushClickHandlerService implements CorePushNotificationsClickHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizPushClickHandler';
 | 
			
		||||
    priority = 200;
 | 
			
		||||
    featureName = 'CoreCourseModuleDelegate_AddonModQuiz';
 | 
			
		||||
 | 
			
		||||
    protected readonly SUPPORTED_NAMES = ['submission', 'confirmation', 'attempt_overdue'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a notification click is handled by this handler.
 | 
			
		||||
     *
 | 
			
		||||
     * @param notification The notification to check.
 | 
			
		||||
     * @return Whether the notification click is handled by this handler
 | 
			
		||||
     */
 | 
			
		||||
    async handles(notification: AddonModQuizPushNotificationData): Promise<boolean> {
 | 
			
		||||
        return CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_quiz' &&
 | 
			
		||||
                this.SUPPORTED_NAMES.indexOf(notification.name!) != -1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the notification click.
 | 
			
		||||
     *
 | 
			
		||||
     * @param notification The notification to check.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async handleClick(notification: AddonModQuizPushNotificationData): Promise<void> {
 | 
			
		||||
        const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl || '');
 | 
			
		||||
        const data = notification.customdata || {};
 | 
			
		||||
        const courseId = Number(notification.courseid);
 | 
			
		||||
 | 
			
		||||
        if (notification.name == 'submission') {
 | 
			
		||||
            // A student made a submission, go to view the attempt.
 | 
			
		||||
            return AddonModQuizHelper.instance.handleReviewLink(
 | 
			
		||||
                Number(contextUrlParams.attempt),
 | 
			
		||||
                Number(contextUrlParams.page),
 | 
			
		||||
                courseId,
 | 
			
		||||
                Number(data.instance),
 | 
			
		||||
                notification.site,
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Open the activity.
 | 
			
		||||
        const moduleId = Number(contextUrlParams.id);
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.invalidateContent(moduleId, courseId, notification.site));
 | 
			
		||||
 | 
			
		||||
        return CoreCourseHelper.instance.navigateToModule(moduleId, notification.site, courseId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizPushClickHandler extends makeSingleton(AddonModQuizPushClickHandlerService) {}
 | 
			
		||||
 | 
			
		||||
type AddonModQuizPushNotificationData = CorePushNotificationsNotificationBasicData & {
 | 
			
		||||
    contexturl?: string;
 | 
			
		||||
    courseid?: number | string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										66
									
								
								src/addons/mod/quiz/services/handlers/review-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/addons/mod/quiz/services/handlers/review-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,66 @@
 | 
			
		||||
// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
 | 
			
		||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuizHelper } from '../quiz-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to treat links to quiz review.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizReviewLinkHandlerService extends CoreContentLinksHandlerBase {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizReviewLinkHandler';
 | 
			
		||||
    featureName = 'CoreCourseModuleDelegate_AddonModQuiz';
 | 
			
		||||
    pattern = /\/mod\/quiz\/review\.php.*([&?]attempt=\d+)/;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the list of actions for a link (url).
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteIds List of sites the URL belongs to.
 | 
			
		||||
     * @param url The URL to treat.
 | 
			
		||||
     * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
 | 
			
		||||
     * @param courseId Course ID related to the URL. Optional but recommended.
 | 
			
		||||
     * @param data Extra data to handle the URL.
 | 
			
		||||
     * @return List of (or promise resolved with list of) actions.
 | 
			
		||||
     */
 | 
			
		||||
 | 
			
		||||
    getActions(
 | 
			
		||||
        siteIds: string[],
 | 
			
		||||
        url: string,
 | 
			
		||||
        params: Record<string, string>,
 | 
			
		||||
        courseId?: number,
 | 
			
		||||
        data?: Record<string, unknown>,
 | 
			
		||||
    ): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
 | 
			
		||||
 | 
			
		||||
        courseId = Number(courseId || params.courseid || params.cid);
 | 
			
		||||
        data = data || {};
 | 
			
		||||
 | 
			
		||||
        return [{
 | 
			
		||||
            action: (siteId): void => {
 | 
			
		||||
                const attemptId = parseInt(params.attempt, 10);
 | 
			
		||||
                const page = parseInt(params.page, 10);
 | 
			
		||||
                const quizId = data!.instance ? Number(data!.instance) : undefined;
 | 
			
		||||
 | 
			
		||||
                AddonModQuizHelper.instance.handleReviewLink(attemptId, page, courseId, quizId, siteId);
 | 
			
		||||
            },
 | 
			
		||||
        }];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizReviewLinkHandler extends makeSingleton(AddonModQuizReviewLinkHandlerService) {}
 | 
			
		||||
							
								
								
									
										52
									
								
								src/addons/mod/quiz/services/handlers/sync-cron.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/addons/mod/quiz/services/handlers/sync-cron.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
// (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 { CoreCronHandler } from '@services/cron';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { AddonModQuizSync } from '../quiz-sync';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Synchronization cron handler.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizSyncCronHandlerService implements CoreCronHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonModQuizSyncCronHandler';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Execute the process.
 | 
			
		||||
     * Receives the ID of the site affected, undefined for all sites.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId ID of the site affected, undefined for all sites.
 | 
			
		||||
     * @param force Wether the execution is forced (manual sync).
 | 
			
		||||
     * @return Promise resolved when done, rejected if failure.
 | 
			
		||||
     */
 | 
			
		||||
    execute(siteId?: string, force?: boolean): Promise<void> {
 | 
			
		||||
        return AddonModQuizSync.instance.syncAllQuizzes(siteId, force);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the time between consecutive executions.
 | 
			
		||||
     *
 | 
			
		||||
     * @return Time between consecutive executions (in ms).
 | 
			
		||||
     */
 | 
			
		||||
    getInterval(): number {
 | 
			
		||||
        return AddonModQuizSync.instance.syncInterval;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizSyncCronHandler extends makeSingleton(AddonModQuizSyncCronHandlerService) {}
 | 
			
		||||
							
								
								
									
										435
									
								
								src/addons/mod/quiz/services/quiz-helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										435
									
								
								src/addons/mod/quiz/services/quiz-helper.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,435 @@
 | 
			
		||||
// (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 { CoreCourse } from '@features/course/services/course';
 | 
			
		||||
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 });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const module = await CoreCourse.instance.getModuleBasicInfoByInstance(quizId, 'quiz', siteId);
 | 
			
		||||
 | 
			
		||||
            courseId = courseId || module.course;
 | 
			
		||||
 | 
			
		||||
            // Go to the review page.
 | 
			
		||||
            await CoreNavigator.instance.navigateToSitePath(`mod_quiz/${courseId}/${module.id}/review/${attemptId}`, {
 | 
			
		||||
                params: {
 | 
			
		||||
                    page: page == undefined || isNaN(page) ? -1 : page,
 | 
			
		||||
                },
 | 
			
		||||
                siteId,
 | 
			
		||||
            });
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            CoreDomUtils.instance.showErrorModalDefault(error, 'An error occurred while loading the required data.');
 | 
			
		||||
        } finally {
 | 
			
		||||
            modal.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add some calculated data to the attempt.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param highlight Whether we should check if attempt should be highlighted.
 | 
			
		||||
     * @param bestGrade Quiz's best grade (formatted). Required if highlight=true.
 | 
			
		||||
     * @param isLastAttempt Whether the attempt is the last one.
 | 
			
		||||
     * @param siteId Site ID.
 | 
			
		||||
     */
 | 
			
		||||
    async setAttemptCalculatedData(
 | 
			
		||||
        quiz: AddonModQuizQuizData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        highlight?: boolean,
 | 
			
		||||
        bestGrade?: string,
 | 
			
		||||
        isLastAttempt?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<AddonModQuizAttempt> {
 | 
			
		||||
        const formattedAttempt = <AddonModQuizAttempt> attempt;
 | 
			
		||||
 | 
			
		||||
        formattedAttempt.rescaledGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false);
 | 
			
		||||
        formattedAttempt.finished = AddonModQuiz.instance.isAttemptFinished(attempt.state);
 | 
			
		||||
        formattedAttempt.readableState = AddonModQuiz.instance.getAttemptReadableState(quiz, attempt);
 | 
			
		||||
 | 
			
		||||
        if (quiz.showMarkColumn && formattedAttempt.finished) {
 | 
			
		||||
            formattedAttempt.readableMark = AddonModQuiz.instance.formatGrade(attempt.sumgrades, quiz.decimalpoints);
 | 
			
		||||
        } else {
 | 
			
		||||
            formattedAttempt.readableMark = '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (quiz.showGradeColumn && formattedAttempt.finished) {
 | 
			
		||||
            formattedAttempt.readableGrade = AddonModQuiz.instance.formatGrade(
 | 
			
		||||
                Number(formattedAttempt.rescaledGrade),
 | 
			
		||||
                quiz.decimalpoints,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // Highlight the highest grade if appropriate.
 | 
			
		||||
            formattedAttempt.highlightGrade = !!(highlight && !attempt.preview &&
 | 
			
		||||
                attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED && formattedAttempt.readableGrade == bestGrade);
 | 
			
		||||
        } else {
 | 
			
		||||
            formattedAttempt.readableGrade = '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isLastAttempt || isLastAttempt === undefined) {
 | 
			
		||||
            formattedAttempt.finishedOffline = await AddonModQuiz.instance.isAttemptFinishedOffline(attempt.id, siteId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return formattedAttempt;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add some calculated data to the quiz.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param options Review options.
 | 
			
		||||
     */
 | 
			
		||||
    setQuizCalculatedData(quiz: AddonModQuizQuizWSData, options: AddonModQuizCombinedReviewOptions): AddonModQuizQuizData {
 | 
			
		||||
        const formattedQuiz = <AddonModQuizQuizData> quiz;
 | 
			
		||||
 | 
			
		||||
        formattedQuiz.sumGradesFormatted = AddonModQuiz.instance.formatGrade(quiz.sumgrades, quiz.decimalpoints);
 | 
			
		||||
        formattedQuiz.gradeFormatted = AddonModQuiz.instance.formatGrade(quiz.grade, quiz.decimalpoints);
 | 
			
		||||
 | 
			
		||||
        formattedQuiz.showAttemptColumn = quiz.attempts != 1;
 | 
			
		||||
        formattedQuiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
 | 
			
		||||
            AddonModQuiz.instance.quizHasGrades(quiz);
 | 
			
		||||
        formattedQuiz.showMarkColumn = formattedQuiz.showGradeColumn && quiz.grade != quiz.sumgrades;
 | 
			
		||||
        formattedQuiz.showFeedbackColumn = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;
 | 
			
		||||
 | 
			
		||||
        return formattedQuiz;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param accessInfo Quiz access info.
 | 
			
		||||
     * @param preflightData Object where to store the preflight data.
 | 
			
		||||
     * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt.
 | 
			
		||||
     * @param offline Whether the attempt is offline.
 | 
			
		||||
     * @param sent Whether preflight data has been entered by the user.
 | 
			
		||||
     * @param prefetch Whether user is prefetching.
 | 
			
		||||
     * @param title The title to display in the modal and in the submit button.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when the preflight data is validated.
 | 
			
		||||
     */
 | 
			
		||||
    async validatePreflightData(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
 | 
			
		||||
        preflightData: Record<string, string>,
 | 
			
		||||
        attempt?: AddonModQuizAttempt,
 | 
			
		||||
        offline?: boolean,
 | 
			
		||||
        prefetch?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<AddonModQuizAttempt> {
 | 
			
		||||
 | 
			
		||||
        const rules = accessInfo.activerulenames;
 | 
			
		||||
        const modOptions = {
 | 
			
		||||
            cmId: quiz.coursemodule,
 | 
			
		||||
            readingStrategy: offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            if (attempt) {
 | 
			
		||||
                if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) {
 | 
			
		||||
                    // We're continuing an attempt. Call getAttemptData to validate the preflight data.
 | 
			
		||||
                    await AddonModQuiz.instance.getAttemptData(attempt.id, attempt.currentpage!, preflightData, modOptions);
 | 
			
		||||
 | 
			
		||||
                    if (offline) {
 | 
			
		||||
                        // Get current page stored in local.
 | 
			
		||||
                        const storedAttempt = await CoreUtils.instance.ignoreErrors(
 | 
			
		||||
                            AddonModQuizOffline.instance.getAttemptById(attempt.id),
 | 
			
		||||
                        );
 | 
			
		||||
 | 
			
		||||
                        attempt.currentpage = storedAttempt?.currentpage ?? attempt.currentpage;
 | 
			
		||||
                    }
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Attempt is overdue or finished in offline, we can only see the summary.
 | 
			
		||||
                    // Call getAttemptSummary to validate the preflight data.
 | 
			
		||||
                    await AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                // We're starting a new attempt, call startAttempt.
 | 
			
		||||
                attempt = await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Preflight data validated.
 | 
			
		||||
            AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckPassed(
 | 
			
		||||
                rules,
 | 
			
		||||
                quiz,
 | 
			
		||||
                attempt,
 | 
			
		||||
                preflightData,
 | 
			
		||||
                prefetch,
 | 
			
		||||
                siteId,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            return attempt;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            if (CoreUtils.instance.isWebServiceError(error)) {
 | 
			
		||||
                // The WebService returned an error, assume the preflight failed.
 | 
			
		||||
                AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckFailed(
 | 
			
		||||
                    rules,
 | 
			
		||||
                    quiz,
 | 
			
		||||
                    attempt,
 | 
			
		||||
                    preflightData,
 | 
			
		||||
                    prefetch,
 | 
			
		||||
                    siteId,
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw error;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizHelper extends makeSingleton(AddonModQuizHelperProvider) {}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Quiz data with calculated data.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
 | 
			
		||||
    sumGradesFormatted?: string;
 | 
			
		||||
    gradeFormatted?: string;
 | 
			
		||||
    showAttemptColumn?: boolean;
 | 
			
		||||
    showGradeColumn?: boolean;
 | 
			
		||||
    showMarkColumn?: boolean;
 | 
			
		||||
    showFeedbackColumn?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Attempt data with calculated data.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizAttempt = AddonModQuizAttemptWSData & {
 | 
			
		||||
    finishedOffline?: boolean;
 | 
			
		||||
    rescaledGrade?: string;
 | 
			
		||||
    finished?: boolean;
 | 
			
		||||
    readableState?: string[];
 | 
			
		||||
    readableMark?: string;
 | 
			
		||||
    readableGrade?: string;
 | 
			
		||||
    highlightGrade?: boolean;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										372
									
								
								src/addons/mod/quiz/services/quiz-offline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										372
									
								
								src/addons/mod/quiz/services/quiz-offline.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,372 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourDelegate, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionAnswerDBRecord } from '@features/question/services/database/question';
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
 | 
			
		||||
import { CoreSites } from '@services/sites';
 | 
			
		||||
import { CoreTimeUtils } from '@services/utils/time';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton, Translate } from '@singletons';
 | 
			
		||||
import { CoreLogger } from '@singletons/logger';
 | 
			
		||||
import { AddonModQuizAttemptDBRecord, ATTEMPTS_TABLE_NAME } from './database/quiz';
 | 
			
		||||
import { AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Service to handle offline quiz.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizOfflineProvider {
 | 
			
		||||
 | 
			
		||||
    protected logger: CoreLogger;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.logger = CoreLogger.getInstance('AddonModQuizOfflineProvider');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Classify the answers in questions.
 | 
			
		||||
     *
 | 
			
		||||
     * @param answers List of answers.
 | 
			
		||||
     * @return Object with the questions, the keys are the slot. Each question contains its answers.
 | 
			
		||||
     */
 | 
			
		||||
    classifyAnswersInQuestions(answers: CoreQuestionsAnswers): AddonModQuizQuestionsWithAnswers {
 | 
			
		||||
        const questionsWithAnswers: AddonModQuizQuestionsWithAnswers = {};
 | 
			
		||||
 | 
			
		||||
        // Classify the answers in each question.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const slot = CoreQuestion.instance.getQuestionSlotFromName(name);
 | 
			
		||||
            const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name);
 | 
			
		||||
 | 
			
		||||
            if (!questionsWithAnswers[slot]) {
 | 
			
		||||
                questionsWithAnswers[slot] = {
 | 
			
		||||
                    answers: {},
 | 
			
		||||
                    prefix: name.substr(0, name.indexOf(nameWithoutPrefix)),
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
            questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return questionsWithAnswers;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Given a list of questions with answers classified in it, returns a list of answers (including prefix in the name).
 | 
			
		||||
     *
 | 
			
		||||
     * @param questions Questions.
 | 
			
		||||
     * @return Answers.
 | 
			
		||||
     */
 | 
			
		||||
    extractAnswersFromQuestions(questions: AddonModQuizQuestionsWithAnswers): CoreQuestionsAnswers {
 | 
			
		||||
        const answers: CoreQuestionsAnswers = {};
 | 
			
		||||
 | 
			
		||||
        for (const slot in questions) {
 | 
			
		||||
            const question = questions[slot];
 | 
			
		||||
 | 
			
		||||
            for (const name in question.answers) {
 | 
			
		||||
                answers[question.prefix + name] = question.answers[name];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return answers;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all the offline attempts in a certain site.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with the offline attempts.
 | 
			
		||||
     */
 | 
			
		||||
    async getAllAttempts(siteId?: string): Promise<AddonModQuizAttemptDBRecord[]> {
 | 
			
		||||
        const db = await CoreSites.instance.getSiteDb(siteId);
 | 
			
		||||
 | 
			
		||||
        return db.getAllRecords(ATTEMPTS_TABLE_NAME);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Retrieve an attempt answers from site DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with the answers.
 | 
			
		||||
     */
 | 
			
		||||
    getAttemptAnswers(attemptId: number, siteId?: string): Promise<CoreQuestionAnswerDBRecord[]> {
 | 
			
		||||
        return CoreQuestion.instance.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Retrieve an attempt from site DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with the attempt.
 | 
			
		||||
     */
 | 
			
		||||
    async getAttemptById(attemptId: number, siteId?: string): Promise<AddonModQuizAttemptDBRecord> {
 | 
			
		||||
        const db = await CoreSites.instance.getSiteDb(siteId);
 | 
			
		||||
 | 
			
		||||
        return db.getRecord(ATTEMPTS_TABLE_NAME, { id: attemptId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Retrieve an attempt from site DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @param userId User ID. If not defined, user current site's user.
 | 
			
		||||
     * @return Promise resolved with the attempts.
 | 
			
		||||
     */
 | 
			
		||||
    async getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise<AddonModQuizAttemptDBRecord[]> {
 | 
			
		||||
        const site = await CoreSites.instance.getSite(siteId);
 | 
			
		||||
 | 
			
		||||
        return site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { quizid: quizId, userid: userId || site.getUserId() });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load local state in the questions.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param questions List of questions.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async loadQuestionsLocalStates(
 | 
			
		||||
        attemptId: number,
 | 
			
		||||
        questions: CoreQuestionQuestionParsed[],
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<CoreQuestionQuestionParsed[]> {
 | 
			
		||||
 | 
			
		||||
        await Promise.all(questions.map(async (question) => {
 | 
			
		||||
            const dbQuestion = await CoreUtils.instance.ignoreErrors(
 | 
			
		||||
                CoreQuestion.instance.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId),
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (!dbQuestion) {
 | 
			
		||||
                // Question not found.
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const state = CoreQuestion.instance.getState(dbQuestion.state);
 | 
			
		||||
            question.state = dbQuestion.state;
 | 
			
		||||
            question.status = Translate.instance.instant('core.question.' + state.status);
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return questions;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Process an attempt, saving its data.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param questions Object with the questions of the quiz. The keys should be the question slot.
 | 
			
		||||
     * @param data Data to save.
 | 
			
		||||
     * @param finish Whether to finish the quiz.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved in success, rejected otherwise.
 | 
			
		||||
     */
 | 
			
		||||
    async processAttempt(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        questions: Record<number, CoreQuestionQuestionParsed>,
 | 
			
		||||
        data: CoreQuestionsAnswers,
 | 
			
		||||
        finish?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
        const now = CoreTimeUtils.instance.timestamp();
 | 
			
		||||
 | 
			
		||||
        const db = await CoreSites.instance.getSiteDb(siteId);
 | 
			
		||||
 | 
			
		||||
        // Check if an attempt already exists. Return a new one if it doesn't.
 | 
			
		||||
        let entry = await CoreUtils.instance.ignoreErrors(this.getAttemptById(attempt.id, siteId));
 | 
			
		||||
 | 
			
		||||
        if (entry) {
 | 
			
		||||
            entry.timemodified = now;
 | 
			
		||||
            entry.finished = finish ? 1 : 0;
 | 
			
		||||
        } else {
 | 
			
		||||
            entry = {
 | 
			
		||||
                quizid: quiz.id,
 | 
			
		||||
                userid: attempt.userid!,
 | 
			
		||||
                id: attempt.id,
 | 
			
		||||
                courseid: quiz.course,
 | 
			
		||||
                timecreated: now,
 | 
			
		||||
                attempt: attempt.attempt!,
 | 
			
		||||
                currentpage: attempt.currentpage,
 | 
			
		||||
                timemodified: now,
 | 
			
		||||
                finished: finish ? 1 : 0,
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Save attempt in DB.
 | 
			
		||||
        await db.insertRecord(ATTEMPTS_TABLE_NAME, entry);
 | 
			
		||||
 | 
			
		||||
        // Attempt has been saved, now we need to save the answers.
 | 
			
		||||
        await this.saveAnswers(quiz, attempt, questions, data, now, siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove an attempt and its answers from local DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        const db = await CoreSites.instance.getSiteDb(siteId);
 | 
			
		||||
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            CoreQuestion.instance.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId),
 | 
			
		||||
            CoreQuestion.instance.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId),
 | 
			
		||||
            db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a question and its answers from local DB.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param slot Question slot.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when finished.
 | 
			
		||||
     */
 | 
			
		||||
    async removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            CoreQuestion.instance.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
 | 
			
		||||
            CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save an attempt's answers and calculate state for questions modified.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param attempt Attempt.
 | 
			
		||||
     * @param questions Object with the questions of the quiz. The keys should be the question slot.
 | 
			
		||||
     * @param answers Answers to save.
 | 
			
		||||
     * @param timeMod Time modified to set in the answers. If not defined, current time.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async saveAnswers(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        attempt: AddonModQuizAttemptWSData,
 | 
			
		||||
        questions: Record<number, CoreQuestionQuestionParsed>,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        timeMod?: number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
        timeMod = timeMod || CoreTimeUtils.instance.timestamp();
 | 
			
		||||
 | 
			
		||||
        const questionsWithAnswers: Record<number, CoreQuestionQuestionWithAnswers> = {};
 | 
			
		||||
        const newStates: Record<number, string> = {};
 | 
			
		||||
 | 
			
		||||
        // Classify the answers in each question.
 | 
			
		||||
        for (const name in answers) {
 | 
			
		||||
            const slot = CoreQuestion.instance.getQuestionSlotFromName(name);
 | 
			
		||||
            const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name);
 | 
			
		||||
 | 
			
		||||
            if (questions[slot]) {
 | 
			
		||||
                if (!questionsWithAnswers[slot]) {
 | 
			
		||||
                    questionsWithAnswers[slot] = questions[slot];
 | 
			
		||||
                    questionsWithAnswers[slot].answers = {};
 | 
			
		||||
                }
 | 
			
		||||
                questionsWithAnswers[slot].answers![nameWithoutPrefix] = answers[name];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // First determine the new state of each question. We won't save the new state yet.
 | 
			
		||||
        await Promise.all(Object.values(questionsWithAnswers).map(async (question) => {
 | 
			
		||||
 | 
			
		||||
            const state = await CoreQuestionBehaviourDelegate.instance.determineNewState(
 | 
			
		||||
                quiz.preferredbehaviour!,
 | 
			
		||||
                AddonModQuizProvider.COMPONENT,
 | 
			
		||||
                attempt.id,
 | 
			
		||||
                question,
 | 
			
		||||
                quiz.coursemodule,
 | 
			
		||||
                siteId,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            // Check if state has changed.
 | 
			
		||||
            if (state && state.name != question.state) {
 | 
			
		||||
                newStates[question.slot] = state.name;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Delete previously stored answers for this question.
 | 
			
		||||
            await CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId);
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Now save the answers.
 | 
			
		||||
        await CoreQuestion.instance.saveAnswers(
 | 
			
		||||
            AddonModQuizProvider.COMPONENT,
 | 
			
		||||
            quiz.id,
 | 
			
		||||
            attempt.id,
 | 
			
		||||
            attempt.userid!,
 | 
			
		||||
            answers,
 | 
			
		||||
            timeMod,
 | 
			
		||||
            siteId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Answers have been saved, now we can save the questions with the states.
 | 
			
		||||
            await CoreUtils.instance.allPromises(Object.keys(newStates).map(async (slot) => {
 | 
			
		||||
                const question = questionsWithAnswers[Number(slot)];
 | 
			
		||||
 | 
			
		||||
                await CoreQuestion.instance.saveQuestion(
 | 
			
		||||
                    AddonModQuizProvider.COMPONENT,
 | 
			
		||||
                    quiz.id,
 | 
			
		||||
                    attempt.id,
 | 
			
		||||
                    attempt.userid!,
 | 
			
		||||
                    question,
 | 
			
		||||
                    newStates[slot],
 | 
			
		||||
                    siteId,
 | 
			
		||||
                );
 | 
			
		||||
            }));
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // Ignore errors when saving question state.
 | 
			
		||||
            this.logger.error('Error saving question state', error);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set attempt's current page.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param page Page to set.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved in success, rejected otherwise.
 | 
			
		||||
     */
 | 
			
		||||
    async setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise<void> {
 | 
			
		||||
        const db = await CoreSites.instance.getSiteDb(siteId);
 | 
			
		||||
 | 
			
		||||
        await db.updateRecords(ATTEMPTS_TABLE_NAME, { currentpage: page }, { id: attemptId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizOffline extends makeSingleton(AddonModQuizOfflineProvider) {}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Answers classified by question slot.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizQuestionsWithAnswers = Record<number, {
 | 
			
		||||
    prefix: string;
 | 
			
		||||
    answers: CoreQuestionsAnswers;
 | 
			
		||||
}>;
 | 
			
		||||
							
								
								
									
										513
									
								
								src/addons/mod/quiz/services/quiz-sync.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										513
									
								
								src/addons/mod/quiz/services/quiz-sync.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,513 @@
 | 
			
		||||
// (C) Copyright 2015 Moodle Pty Ltd.
 | 
			
		||||
//
 | 
			
		||||
// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
// you may not use this file except in compliance with the License.
 | 
			
		||||
// You may obtain a copy of the License at
 | 
			
		||||
//
 | 
			
		||||
//     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
//
 | 
			
		||||
// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
// See the License for the specific language governing permissions and
 | 
			
		||||
// limitations under the License.
 | 
			
		||||
 | 
			
		||||
import { Injectable } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreError } from '@classes/errors/error';
 | 
			
		||||
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
 | 
			
		||||
import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
 | 
			
		||||
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
 | 
			
		||||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
 | 
			
		||||
import { CoreQuestion, CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { CoreApp } from '@services/app';
 | 
			
		||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
 | 
			
		||||
import { CoreSync } from '@services/sync';
 | 
			
		||||
import { CoreUtils } from '@services/utils/utils';
 | 
			
		||||
import { makeSingleton, Translate } from '@singletons';
 | 
			
		||||
import { CoreEvents, CoreEventSiteData } from '@singletons/events';
 | 
			
		||||
import { AddonModQuizAttemptDBRecord } from './database/quiz';
 | 
			
		||||
import { AddonModQuizPrefetchHandler } from './handlers/prefetch';
 | 
			
		||||
import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz';
 | 
			
		||||
import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Service to sync quizzes.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModQuizSyncResult> {
 | 
			
		||||
 | 
			
		||||
    static readonly AUTO_SYNCED = 'addon_mod_quiz_autom_synced';
 | 
			
		||||
 | 
			
		||||
    protected componentTranslate?: string;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super('AddonModQuizSyncProvider');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param courseId Course ID.
 | 
			
		||||
     * @param warnings List of warnings generated by the sync.
 | 
			
		||||
     * @param options Other options.
 | 
			
		||||
     * @return Promise resolved on success.
 | 
			
		||||
     */
 | 
			
		||||
    protected async finishSync(
 | 
			
		||||
        siteId: string,
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        warnings: string[],
 | 
			
		||||
        options?: FinishSyncOptions,
 | 
			
		||||
    ): Promise<AddonModQuizSyncResult> {
 | 
			
		||||
        options = options || {};
 | 
			
		||||
 | 
			
		||||
        // Invalidate the data for the quiz and attempt.
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(
 | 
			
		||||
            AddonModQuiz.instance.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (options.removeAttempt && options.attemptId) {
 | 
			
		||||
            const promises: Promise<unknown>[] = [];
 | 
			
		||||
 | 
			
		||||
            promises.push(AddonModQuizOffline.instance.removeAttemptAndAnswers(options.attemptId, siteId));
 | 
			
		||||
 | 
			
		||||
            if (options.onlineQuestions) {
 | 
			
		||||
                for (const slot in options.onlineQuestions) {
 | 
			
		||||
                    promises.push(CoreQuestionDelegate.instance.deleteOfflineData(
 | 
			
		||||
                        options.onlineQuestions[slot],
 | 
			
		||||
                        AddonModQuizProvider.COMPONENT,
 | 
			
		||||
                        quiz.coursemodule,
 | 
			
		||||
                        siteId,
 | 
			
		||||
                    ));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await Promise.all(promises);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.updated) {
 | 
			
		||||
            try {
 | 
			
		||||
                // Data has been sent. Update prefetched data.
 | 
			
		||||
                const module = await CoreCourse.instance.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId);
 | 
			
		||||
 | 
			
		||||
                await this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
 | 
			
		||||
            } catch {
 | 
			
		||||
                // Ignore errors.
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(this.setSyncTime(quiz.id, siteId));
 | 
			
		||||
 | 
			
		||||
        // Check if online attempt was finished because of the sync.
 | 
			
		||||
        let attemptFinished = false;
 | 
			
		||||
        if (options.onlineAttempt && !AddonModQuiz.instance.isAttemptFinished(options.onlineAttempt.state)) {
 | 
			
		||||
            // Attempt wasn't finished at start. Check if it's finished now.
 | 
			
		||||
            const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: quiz.coursemodule, siteId });
 | 
			
		||||
 | 
			
		||||
            const attempt = attempts.find(attempt => attempt.id == options?.onlineAttempt?.id);
 | 
			
		||||
 | 
			
		||||
            attemptFinished = attempt ? AddonModQuiz.instance.isAttemptFinished(attempt.state) : false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return { warnings, attemptFinished, updated: !!options.updated || !!options.removeAttempt };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a quiz has data to synchronize.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quizId Quiz ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with boolean: whether it has data to sync.
 | 
			
		||||
     */
 | 
			
		||||
    async hasDataToSync(quizId: number, siteId?: string): Promise<boolean> {
 | 
			
		||||
        try {
 | 
			
		||||
            const attempts = await AddonModQuizOffline.instance.getQuizAttempts(quizId, siteId);
 | 
			
		||||
 | 
			
		||||
            return !!attempts.length;
 | 
			
		||||
        } catch {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Conveniece function to prefetch data after an update.
 | 
			
		||||
     *
 | 
			
		||||
     * @param module Module.
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param courseId Course ID.
 | 
			
		||||
     * @param regex If regex matches, don't download the data. Defaults to check files.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when done.
 | 
			
		||||
     */
 | 
			
		||||
    async prefetchAfterUpdateQuiz(
 | 
			
		||||
        module: CoreCourseAnyModuleData,
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        courseId: number,
 | 
			
		||||
        regex?: RegExp,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        regex = regex || /^.*files$/;
 | 
			
		||||
 | 
			
		||||
        let shouldDownload = false;
 | 
			
		||||
 | 
			
		||||
        // Get the module updates to check if the data was updated or not.
 | 
			
		||||
        const result = await CoreCourseModulePrefetchDelegate.instance.getModuleUpdates(module, courseId, true, siteId);
 | 
			
		||||
 | 
			
		||||
        if (result?.updates?.length) {
 | 
			
		||||
            // Only prefetch if files haven't changed.
 | 
			
		||||
            shouldDownload = !result.updates.find((entry) => entry.name.match(regex!));
 | 
			
		||||
 | 
			
		||||
            if (shouldDownload) {
 | 
			
		||||
                await AddonModQuizPrefetchHandler.instance.download(module, courseId, undefined, false, false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Prefetch finished or not needed, set the right status.
 | 
			
		||||
        await AddonModQuizPrefetchHandler.instance.setStatusAfterPrefetch(quiz, {
 | 
			
		||||
            cmId: module.id,
 | 
			
		||||
            readingStrategy: shouldDownload ? CoreSitesReadingStrategy.PreferCache : undefined,
 | 
			
		||||
            siteId,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Try to synchronize all the quizzes in a certain site or in all sites.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID to sync. If not defined, sync all sites.
 | 
			
		||||
     * @param force Wether to force sync not depending on last execution.
 | 
			
		||||
     * @return Promise resolved if sync is successful, rejected if sync fails.
 | 
			
		||||
     */
 | 
			
		||||
    syncAllQuizzes(siteId?: string, force?: boolean): Promise<void> {
 | 
			
		||||
        return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this, !!force), siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync all quizzes on a site.
 | 
			
		||||
     *
 | 
			
		||||
     * @param siteId Site ID to sync.
 | 
			
		||||
     * @param force Wether to force sync not depending on last execution.
 | 
			
		||||
     * @param Promise resolved if sync is successful, rejected if sync fails.
 | 
			
		||||
     */
 | 
			
		||||
    protected async syncAllQuizzesFunc(siteId: string, force: boolean): Promise<void> {
 | 
			
		||||
        // Get all offline attempts.
 | 
			
		||||
        const attempts = await AddonModQuizOffline.instance.getAllAttempts(siteId);
 | 
			
		||||
 | 
			
		||||
        const quizIds: Record<number, boolean> = {}; // To prevent duplicates.
 | 
			
		||||
 | 
			
		||||
        // Sync all quizzes that haven't been synced for a while and that aren't attempted right now.
 | 
			
		||||
        await Promise.all(attempts.map(async (attempt) => {
 | 
			
		||||
            if (quizIds[attempt.quizid]) {
 | 
			
		||||
                // Quiz already treated.
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            quizIds[attempt.quizid] = true;
 | 
			
		||||
 | 
			
		||||
            if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, attempt.quizid, siteId)) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Quiz not blocked, try to synchronize it.
 | 
			
		||||
            const quiz = await AddonModQuiz.instance.getQuizById(attempt.courseid, attempt.quizid, { siteId });
 | 
			
		||||
 | 
			
		||||
            const data = await (force ? this.syncQuiz(quiz, false, siteId) : this.syncQuizIfNeeded(quiz, false, siteId));
 | 
			
		||||
 | 
			
		||||
            if (data?.warnings?.length) {
 | 
			
		||||
                // Store the warnings to show them when the user opens the quiz.
 | 
			
		||||
                await this.setSyncWarnings(quiz.id, data.warnings, siteId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (data) {
 | 
			
		||||
                // Sync successful. Send event.
 | 
			
		||||
                CoreEvents.trigger<AddonModQuizAutoSyncData>(AddonModQuizSyncProvider.AUTO_SYNCED, {
 | 
			
		||||
                    quizId: quiz.id,
 | 
			
		||||
                    attemptFinished: data.attemptFinished,
 | 
			
		||||
                    warnings: data.warnings,
 | 
			
		||||
                }, siteId);
 | 
			
		||||
            }
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync a quiz only if a certain time has passed since the last time.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param askPreflight Whether we should ask for preflight data if needed.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved when the quiz is synced or if it doesn't need to be synced.
 | 
			
		||||
     */
 | 
			
		||||
    async syncQuizIfNeeded(
 | 
			
		||||
        quiz: AddonModQuizQuizWSData,
 | 
			
		||||
        askPreflight?: boolean,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<AddonModQuizSyncResult | undefined> {
 | 
			
		||||
        const needed = await this.isSyncNeeded(quiz.id, siteId);
 | 
			
		||||
 | 
			
		||||
        if (needed) {
 | 
			
		||||
            return this.syncQuiz(quiz, askPreflight, siteId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Try to synchronize a quiz.
 | 
			
		||||
     * The promise returned will be resolved with an array with warnings if the synchronization is successful.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param askPreflight Whether we should ask for preflight data if needed.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved in success.
 | 
			
		||||
     */
 | 
			
		||||
    syncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        if (this.isSyncing(quiz.id, siteId)) {
 | 
			
		||||
            // There's already a sync ongoing for this quiz, return the promise.
 | 
			
		||||
            return this.getOngoingSync(quiz.id, siteId)!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Verify that quiz isn't blocked.
 | 
			
		||||
        if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) {
 | 
			
		||||
            this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.');
 | 
			
		||||
            this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('quiz');
 | 
			
		||||
 | 
			
		||||
            throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Perform the quiz sync.
 | 
			
		||||
     *
 | 
			
		||||
     * @param quiz Quiz.
 | 
			
		||||
     * @param askPreflight Whether we should ask for preflight data if needed.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved in success.
 | 
			
		||||
     */
 | 
			
		||||
    async performSyncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
 | 
			
		||||
        siteId = siteId || CoreSites.instance.getCurrentSiteId();
 | 
			
		||||
 | 
			
		||||
        const warnings: string[] = [];
 | 
			
		||||
        const courseId = quiz.course;
 | 
			
		||||
        const modOptions = {
 | 
			
		||||
            cmId: quiz.coursemodule,
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId);
 | 
			
		||||
 | 
			
		||||
        // Sync offline logs.
 | 
			
		||||
        await CoreUtils.instance.ignoreErrors(
 | 
			
		||||
            CoreCourseLogHelper.instance.syncActivity(AddonModQuizProvider.COMPONENT, quiz.id, siteId),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt
 | 
			
		||||
        const offlineAttempts = await AddonModQuizOffline.instance.getQuizAttempts(quiz.id, siteId);
 | 
			
		||||
 | 
			
		||||
        if (!offlineAttempts.length) {
 | 
			
		||||
            // Nothing to sync, finish.
 | 
			
		||||
            return this.finishSync(siteId, quiz, courseId, warnings);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!CoreApp.instance.isOnline()) {
 | 
			
		||||
            // Cannot sync in offline.
 | 
			
		||||
            throw new CoreError(Translate.instance.instant('core.cannotconnect'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const offlineAttempt = offlineAttempts.pop()!;
 | 
			
		||||
 | 
			
		||||
        // Now get the list of online attempts to make sure this attempt exists and isn't finished.
 | 
			
		||||
        const onlineAttempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions);
 | 
			
		||||
 | 
			
		||||
        const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined;
 | 
			
		||||
        const onlineAttempt = onlineAttempts.find((attempt) => attempt.id == offlineAttempt.id);
 | 
			
		||||
 | 
			
		||||
        if (!onlineAttempt || AddonModQuiz.instance.isAttemptFinished(onlineAttempt.state)) {
 | 
			
		||||
            // Attempt not found or it's finished in online. Discard it.
 | 
			
		||||
            warnings.push(Translate.instance.instant('addon.mod_quiz.warningattemptfinished'));
 | 
			
		||||
 | 
			
		||||
            return this.finishSync(siteId, quiz, courseId, warnings, {
 | 
			
		||||
                attemptId: offlineAttempt.id,
 | 
			
		||||
                offlineAttempt,
 | 
			
		||||
                onlineAttempt,
 | 
			
		||||
                removeAttempt: true,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Get the data stored in offline.
 | 
			
		||||
        const answersList = await AddonModQuizOffline.instance.getAttemptAnswers(offlineAttempt.id, siteId);
 | 
			
		||||
 | 
			
		||||
        if (!answersList.length) {
 | 
			
		||||
            // No answers stored, finish.
 | 
			
		||||
            return this.finishSync(siteId, quiz, courseId, warnings, {
 | 
			
		||||
                attemptId: lastAttemptId,
 | 
			
		||||
                offlineAttempt,
 | 
			
		||||
                onlineAttempt,
 | 
			
		||||
                removeAttempt: true,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const offlineAnswers = CoreQuestion.instance.convertAnswersArrayToObject(answersList);
 | 
			
		||||
        const offlineQuestions = AddonModQuizOffline.instance.classifyAnswersInQuestions(offlineAnswers);
 | 
			
		||||
 | 
			
		||||
        // We're going to need preflightData, get it.
 | 
			
		||||
        const info = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions);
 | 
			
		||||
 | 
			
		||||
        const preflightData = await AddonModQuizPrefetchHandler.instance.getPreflightData(
 | 
			
		||||
            quiz,
 | 
			
		||||
            info,
 | 
			
		||||
            onlineAttempt,
 | 
			
		||||
            askPreflight,
 | 
			
		||||
            'core.settings.synchronization',
 | 
			
		||||
            siteId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Now get the online questions data.
 | 
			
		||||
        const onlineQuestions = await AddonModQuiz.instance.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
 | 
			
		||||
            pages: AddonModQuiz.instance.getPagesFromLayoutAndQuestions(onlineAttempt.layout || '', offlineQuestions),
 | 
			
		||||
            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
 | 
			
		||||
            siteId,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Validate questions, discarding the offline answers that can't be synchronized.
 | 
			
		||||
        const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
 | 
			
		||||
 | 
			
		||||
        // Let questions prepare the data to send.
 | 
			
		||||
        await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => {
 | 
			
		||||
            const slot = Number(slotString);
 | 
			
		||||
            const onlineQuestion = onlineQuestions[slot];
 | 
			
		||||
 | 
			
		||||
            await CoreQuestionDelegate.instance.prepareSyncData(
 | 
			
		||||
                onlineQuestion,
 | 
			
		||||
                offlineQuestions[slot].answers,
 | 
			
		||||
                AddonModQuizProvider.COMPONENT,
 | 
			
		||||
                quiz.coursemodule,
 | 
			
		||||
                siteId,
 | 
			
		||||
            );
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        // Get the answers to send.
 | 
			
		||||
        const answers = AddonModQuizOffline.instance.extractAnswersFromQuestions(offlineQuestions);
 | 
			
		||||
        const finish = !!offlineAttempt.finished && !discardedData;
 | 
			
		||||
 | 
			
		||||
        if (discardedData) {
 | 
			
		||||
            if (offlineAttempt.finished) {
 | 
			
		||||
                warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscardedfromfinished'));
 | 
			
		||||
            } else {
 | 
			
		||||
                warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscarded'));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Send the answers.
 | 
			
		||||
        await AddonModQuiz.instance.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId);
 | 
			
		||||
 | 
			
		||||
        if (!finish) {
 | 
			
		||||
            // Answers sent, now set the current page.
 | 
			
		||||
            // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case.
 | 
			
		||||
            await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.logViewAttempt(
 | 
			
		||||
                onlineAttempt.id,
 | 
			
		||||
                offlineAttempt.currentpage,
 | 
			
		||||
                preflightData,
 | 
			
		||||
                false,
 | 
			
		||||
                undefined,
 | 
			
		||||
                siteId,
 | 
			
		||||
            ));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Data sent. Finish the sync.
 | 
			
		||||
        return this.finishSync(siteId, quiz, courseId, warnings, {
 | 
			
		||||
            attemptId: lastAttemptId,
 | 
			
		||||
            offlineAttempt,
 | 
			
		||||
            onlineAttempt,
 | 
			
		||||
            removeAttempt: true,
 | 
			
		||||
            updated: true,
 | 
			
		||||
            onlineQuestions,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate questions, discarding the offline answers that can't be synchronized.
 | 
			
		||||
     *
 | 
			
		||||
     * @param attemptId Attempt ID.
 | 
			
		||||
     * @param onlineQuestions Online questions
 | 
			
		||||
     * @param offlineQuestions Offline questions.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return Promise resolved with boolean: true if some offline data was discarded, false otherwise.
 | 
			
		||||
     */
 | 
			
		||||
    async validateQuestions(
 | 
			
		||||
        attemptId: number,
 | 
			
		||||
        onlineQuestions: Record<number, CoreQuestionQuestionParsed>,
 | 
			
		||||
        offlineQuestions: AddonModQuizQuestionsWithAnswers,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): Promise<boolean> {
 | 
			
		||||
        let discardedData = false;
 | 
			
		||||
 | 
			
		||||
        await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => {
 | 
			
		||||
            const slot = Number(slotString);
 | 
			
		||||
            const offlineQuestion = offlineQuestions[slot];
 | 
			
		||||
            const onlineQuestion = onlineQuestions[slot];
 | 
			
		||||
            const offlineSequenceCheck = <string> offlineQuestion.answers[':sequencecheck'];
 | 
			
		||||
 | 
			
		||||
            if (onlineQuestion) {
 | 
			
		||||
                // We found the online data for the question, validate that the sequence check is ok.
 | 
			
		||||
                if (!CoreQuestionDelegate.instance.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) {
 | 
			
		||||
                    // Sequence check is not valid, remove the offline data.
 | 
			
		||||
                    await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId);
 | 
			
		||||
 | 
			
		||||
                    discardedData = true;
 | 
			
		||||
                    delete offlineQuestions[slot];
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Sequence check is valid. Use the online one to prevent synchronization errors.
 | 
			
		||||
                    offlineQuestion.answers[':sequencecheck'] = String(onlineQuestion.sequencecheck);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                // Online question not found, it can happen for 2 reasons:
 | 
			
		||||
                // 1- It's a sequential quiz and the question is in a page already passed.
 | 
			
		||||
                // 2- Quiz layout has changed (shouldn't happen since it's blocked if there are attempts).
 | 
			
		||||
                await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId);
 | 
			
		||||
 | 
			
		||||
                discardedData = true;
 | 
			
		||||
                delete offlineQuestions[slot];
 | 
			
		||||
            }
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return discardedData;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonModQuizSync extends makeSingleton(AddonModQuizSyncProvider) {}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data returned by a quiz sync.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizSyncResult = {
 | 
			
		||||
    warnings: string[]; // List of warnings.
 | 
			
		||||
    attemptFinished: boolean; // Whether an attempt was finished in the site due to the sync.
 | 
			
		||||
    updated: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options to pass to finish sync.
 | 
			
		||||
 */
 | 
			
		||||
type FinishSyncOptions = {
 | 
			
		||||
    attemptId?: number; // Last attempt ID.
 | 
			
		||||
    offlineAttempt?: AddonModQuizAttemptDBRecord; // Offline attempt synchronized, if any.
 | 
			
		||||
    onlineAttempt?: AddonModQuizAttemptWSData; // Online data for the offline attempt.
 | 
			
		||||
    removeAttempt?: boolean; // Whether the offline data should be removed.
 | 
			
		||||
    updated?: boolean; // Whether the offline data should be removed.
 | 
			
		||||
    onlineQuestions?: Record<number, CoreQuestionQuestionParsed>; // Online questions indexed by slot.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Data passed to AUTO_SYNCED event.
 | 
			
		||||
 */
 | 
			
		||||
export type AddonModQuizAutoSyncData = CoreEventSiteData & {
 | 
			
		||||
    quizId: number;
 | 
			
		||||
    attemptFinished: boolean;
 | 
			
		||||
    warnings: string[];
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										2392
									
								
								src/addons/mod/quiz/services/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2392
									
								
								src/addons/mod/quiz/services/quiz.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										33
									
								
								src/addons/qbehaviour/adaptive/adaptive.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/addons/qbehaviour/adaptive/adaptive.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourAdaptiveHandler } from './services/handlers/adaptive';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourAdaptiveModule {}
 | 
			
		||||
							
								
								
									
										56
									
								
								src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support adaptive question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourAdaptiveHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourAdaptive';
 | 
			
		||||
    type = 'adaptive';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a question behaviour.
 | 
			
		||||
     * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
 | 
			
		||||
     * If the behaviour requires to show some extra data, it should return the components to render it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Components (or promise resolved with components) to render some extra data in the question
 | 
			
		||||
     *         (e.g. certainty options). Don't return anything if no extra data is required.
 | 
			
		||||
     */
 | 
			
		||||
    handleQuestion(question: CoreQuestionQuestionParsed): void {
 | 
			
		||||
        // Just extract the button, it doesn't need any specific component.
 | 
			
		||||
        CoreQuestionHelper.instance.extractQbehaviourButtons(question);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourAdaptiveHandler extends makeSingleton(AddonQbehaviourAdaptiveHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourAdaptiveNoPenaltyHandler } from './services/handlers/adaptivenopenalty';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveNoPenaltyHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourAdaptiveNoPenaltyModule {}
 | 
			
		||||
@ -0,0 +1,56 @@
 | 
			
		||||
// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support adaptive no penalty question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourAdaptiveNoPenaltyHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourAdaptiveNoPenalty';
 | 
			
		||||
    type = 'adaptivenopenalty';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a question behaviour.
 | 
			
		||||
     * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
 | 
			
		||||
     * If the behaviour requires to show some extra data, it should return the components to render it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Components (or promise resolved with components) to render some extra data in the question
 | 
			
		||||
     *         (e.g. certainty options). Don't return anything if no extra data is required.
 | 
			
		||||
     */
 | 
			
		||||
    handleQuestion(question: CoreQuestionQuestionParsed): void {
 | 
			
		||||
        // Just extract the button, it doesn't need any specific component.
 | 
			
		||||
        CoreQuestionHelper.instance.extractQbehaviourButtons(question);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourAdaptiveNoPenaltyHandler extends makeSingleton(AddonQbehaviourAdaptiveNoPenaltyHandlerService) {}
 | 
			
		||||
@ -0,0 +1,15 @@
 | 
			
		||||
<div *ngIf="question && question.behaviourCertaintyOptions && question.behaviourCertaintyOptions.length">
 | 
			
		||||
    <ion-item class="ion-text-wrap addon-qbehaviour-deferredcbm-certainty-title" >
 | 
			
		||||
        <ion-label><p>{{ 'core.question.certainty' | translate }}</p></ion-label>
 | 
			
		||||
    </ion-item>
 | 
			
		||||
 | 
			
		||||
    <ion-radio-group [(ngModel)]="question.behaviourCertaintySelected" [name]="question.behaviourCertaintyOptions[0].name">
 | 
			
		||||
        <ion-item class="ion-text-wrap" *ngFor="let option of question.behaviourCertaintyOptions">
 | 
			
		||||
            <ion-label>{{ option.text }}</ion-label>
 | 
			
		||||
            <ion-radio slot="end" id="{{option.id}}" [value]="option.value" [disabled]="option.disabled"></ion-radio>
 | 
			
		||||
        </ion-item>
 | 
			
		||||
    </ion-radio-group>
 | 
			
		||||
 | 
			
		||||
    <!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
 | 
			
		||||
    <input type="hidden" [ngModel]="question.behaviourCertaintySelected" [attr.name]="question.behaviourCertaintyOptions[0].name">
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										38
									
								
								src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,38 @@
 | 
			
		||||
// (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 { Component, Input, Output, EventEmitter } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourButton, CoreQuestionQuestion } from '@features/question/services/question-helper';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component to render the deferred CBM in a question.
 | 
			
		||||
 */
 | 
			
		||||
@Component({
 | 
			
		||||
    selector: 'addon-qbehaviour-deferredcbm',
 | 
			
		||||
    templateUrl: 'addon-qbehaviour-deferredcbm.html',
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourDeferredCBMComponent {
 | 
			
		||||
 | 
			
		||||
    @Input() question?: CoreQuestionQuestion; // The question.
 | 
			
		||||
    @Input() component?: string; // The component the question belongs to.
 | 
			
		||||
    @Input() componentId?: number; // ID of the component the question belongs to.
 | 
			
		||||
    @Input() attemptId?: number; // Attempt ID.
 | 
			
		||||
    @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline.
 | 
			
		||||
    @Input() contextLevel?: string; // The context level.
 | 
			
		||||
    @Input() contextInstanceId?: number; // The instance ID related to the context.
 | 
			
		||||
    @Output() buttonClicked = new EventEmitter<CoreQuestionBehaviourButton>(); // Will emit when a behaviour button is clicked.
 | 
			
		||||
    @Output() onAbort = new EventEmitter<void>(); // Should emit an event if the question should be aborted.
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreSharedModule } from '@/core/shared.module';
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourDeferredCBMComponent } from './component/deferredcbm';
 | 
			
		||||
import { AddonQbehaviourDeferredCBMHandler } from './services/handlers/deferredcbm';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
        AddonQbehaviourDeferredCBMComponent,
 | 
			
		||||
    ],
 | 
			
		||||
    imports: [
 | 
			
		||||
        CoreSharedModule,
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredCBMHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
    exports: [
 | 
			
		||||
        AddonQbehaviourDeferredCBMComponent,
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourDeferredCBMModule {}
 | 
			
		||||
@ -0,0 +1,152 @@
 | 
			
		||||
// (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 { AddonQbehaviourDeferredFeedbackHandler } from '@addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback';
 | 
			
		||||
import { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers, CoreQuestionState } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { AddonQbehaviourDeferredCBMComponent } from '../../component/deferredcbm';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support deferred CBM question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourDeferredCBMHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourDeferredCBM';
 | 
			
		||||
    type = 'deferredcbm';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine a question new state based on its answer(s).
 | 
			
		||||
     *
 | 
			
		||||
     * @param component Component the question belongs to.
 | 
			
		||||
     * @param attemptId Attempt ID the question belongs to.
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return New state (or promise resolved with state).
 | 
			
		||||
     */
 | 
			
		||||
    determineNewState(
 | 
			
		||||
        component: string,
 | 
			
		||||
        attemptId: number,
 | 
			
		||||
        question: CoreQuestionQuestionWithAnswers,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): CoreQuestionState | Promise<CoreQuestionState> {
 | 
			
		||||
        // Depends on deferredfeedback.
 | 
			
		||||
        return AddonQbehaviourDeferredFeedbackHandler.instance.determineNewStateDeferred(
 | 
			
		||||
            component,
 | 
			
		||||
            attemptId,
 | 
			
		||||
            question,
 | 
			
		||||
            componentId,
 | 
			
		||||
            siteId,
 | 
			
		||||
            this.isCompleteResponse.bind(this),
 | 
			
		||||
            this.isSameResponse.bind(this),
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a question behaviour.
 | 
			
		||||
     * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
 | 
			
		||||
     * If the behaviour requires to show some extra data, it should return the components to render it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Components (or promise resolved with components) to render some extra data in the question
 | 
			
		||||
     *         (e.g. certainty options). Don't return anything if no extra data is required.
 | 
			
		||||
     */
 | 
			
		||||
    handleQuestion(question: CoreQuestionQuestionParsed): void | Type<unknown>[] {
 | 
			
		||||
        if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) {
 | 
			
		||||
            return [AddonQbehaviourDeferredCBMComponent];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a response is complete.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param answers Object with the question answers (without prefix).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
     */
 | 
			
		||||
    protected isCompleteResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        answers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): number {
 | 
			
		||||
        // First check if the question answer is complete.
 | 
			
		||||
        const complete = CoreQuestionDelegate.instance.isCompleteResponse(question, answers, component, componentId);
 | 
			
		||||
        if (complete > 0) {
 | 
			
		||||
            // Answer is complete, check the user answered CBM too.
 | 
			
		||||
            return answers['-certainty'] ? 1 : 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return complete;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if two responses are the same.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question Question.
 | 
			
		||||
     * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
     * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
 | 
			
		||||
     * @param newAnswers Object with the new question answers.
 | 
			
		||||
     * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
 | 
			
		||||
     * @param component The component the question is related to.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @return Whether they're the same.
 | 
			
		||||
     */
 | 
			
		||||
    protected isSameResponse(
 | 
			
		||||
        question: CoreQuestionQuestionParsed,
 | 
			
		||||
        prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        prevBasicAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        newBasicAnswers: CoreQuestionsAnswers,
 | 
			
		||||
        component: string,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
    ): boolean {
 | 
			
		||||
        // First check if the question answer is the same.
 | 
			
		||||
        const sameResponse = CoreQuestionDelegate.instance.isSameResponse(
 | 
			
		||||
            question,
 | 
			
		||||
            prevBasicAnswers,
 | 
			
		||||
            newBasicAnswers,
 | 
			
		||||
            component,
 | 
			
		||||
            componentId,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (sameResponse) {
 | 
			
		||||
            // Same response, check the CBM is the same too.
 | 
			
		||||
            return prevAnswers['-certainty'] == newAnswers['-certainty'];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return sameResponse;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourDeferredCBMHandler extends makeSingleton(AddonQbehaviourDeferredCBMHandlerService) {}
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourDeferredFeedbackHandler } from './services/handlers/deferredfeedback';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredFeedbackHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourDeferredFeedbackModule {}
 | 
			
		||||
@ -0,0 +1,216 @@
 | 
			
		||||
// (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 { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionDBRecord } from '@features/question/services/database/question';
 | 
			
		||||
import {
 | 
			
		||||
    CoreQuestion,
 | 
			
		||||
    CoreQuestionQuestionParsed,
 | 
			
		||||
    CoreQuestionsAnswers,
 | 
			
		||||
    CoreQuestionState,
 | 
			
		||||
} from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support deferred feedback question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourDeferredFeedbackHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourDeferredFeedback';
 | 
			
		||||
    type = 'deferredfeedback';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine a question new state based on its answer(s).
 | 
			
		||||
     *
 | 
			
		||||
     * @param component Component the question belongs to.
 | 
			
		||||
     * @param attemptId Attempt ID the question belongs to.
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @return New state (or promise resolved with state).
 | 
			
		||||
     */
 | 
			
		||||
    determineNewState(
 | 
			
		||||
        component: string,
 | 
			
		||||
        attemptId: number,
 | 
			
		||||
        question: CoreQuestionQuestionWithAnswers,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
    ): CoreQuestionState | Promise<CoreQuestionState> {
 | 
			
		||||
        return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine a question new state based on its answer(s) for deferred question behaviour.
 | 
			
		||||
     *
 | 
			
		||||
     * @param component Component the question belongs to.
 | 
			
		||||
     * @param attemptId Attempt ID the question belongs to.
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @param componentId Component ID.
 | 
			
		||||
     * @param siteId Site ID. If not defined, current site.
 | 
			
		||||
     * @param isCompleteFn Function to override the default isCompleteResponse check.
 | 
			
		||||
     * @param isSameFn Function to override the default isSameResponse check.
 | 
			
		||||
     * @return Promise resolved with state.
 | 
			
		||||
     */
 | 
			
		||||
    async determineNewStateDeferred(
 | 
			
		||||
        component: string,
 | 
			
		||||
        attemptId: number,
 | 
			
		||||
        question: CoreQuestionQuestionWithAnswers,
 | 
			
		||||
        componentId: string | number,
 | 
			
		||||
        siteId?: string,
 | 
			
		||||
        isCompleteFn?: isCompleteResponseFunction,
 | 
			
		||||
        isSameFn?: isSameResponseFunction,
 | 
			
		||||
    ): Promise<CoreQuestionState> {
 | 
			
		||||
 | 
			
		||||
        // Check if we have local data for the question.
 | 
			
		||||
        let dbQuestion: CoreQuestionDBRecord | CoreQuestionQuestionWithAnswers = question;
 | 
			
		||||
        try {
 | 
			
		||||
            dbQuestion = await CoreQuestion.instance.getQuestion(component, attemptId, question.slot, siteId);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            // No entry found, use the original data.
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const state = CoreQuestion.instance.getState(dbQuestion.state);
 | 
			
		||||
 | 
			
		||||
        if (state.finished || !state.active) {
 | 
			
		||||
            // Question is finished, it cannot change.
 | 
			
		||||
            return state;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const newBasicAnswers = CoreQuestion.instance.getBasicAnswers(question.answers || {});
 | 
			
		||||
 | 
			
		||||
        if (dbQuestion.state) {
 | 
			
		||||
            // Question already has a state stored. Check if answer has changed.
 | 
			
		||||
            const prevAnswersList = await CoreQuestion.instance.getQuestionAnswers(
 | 
			
		||||
                component,
 | 
			
		||||
                attemptId,
 | 
			
		||||
                question.slot,
 | 
			
		||||
                false,
 | 
			
		||||
                siteId,
 | 
			
		||||
            );
 | 
			
		||||
            const prevAnswers = CoreQuestion.instance.convertAnswersArrayToObject(prevAnswersList, true);
 | 
			
		||||
            const prevBasicAnswers = CoreQuestion.instance.getBasicAnswers(prevAnswers);
 | 
			
		||||
 | 
			
		||||
            // If answers haven't changed the state is the same.
 | 
			
		||||
            let sameResponse = false;
 | 
			
		||||
 | 
			
		||||
            if (isSameFn) {
 | 
			
		||||
                sameResponse = isSameFn(
 | 
			
		||||
                    question,
 | 
			
		||||
                    prevAnswers,
 | 
			
		||||
                    prevBasicAnswers,
 | 
			
		||||
                    question.answers || {},
 | 
			
		||||
                    newBasicAnswers,
 | 
			
		||||
                    component,
 | 
			
		||||
                    componentId,
 | 
			
		||||
                );
 | 
			
		||||
            } else {
 | 
			
		||||
                sameResponse = CoreQuestionDelegate.instance.isSameResponse(
 | 
			
		||||
                    question,
 | 
			
		||||
                    prevBasicAnswers,
 | 
			
		||||
                    newBasicAnswers,
 | 
			
		||||
                    component,
 | 
			
		||||
                    componentId,
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (sameResponse) {
 | 
			
		||||
                return state;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Answers have changed. Now check if the response is complete and calculate the new state.
 | 
			
		||||
        let complete: number;
 | 
			
		||||
        let newState: string;
 | 
			
		||||
 | 
			
		||||
        if (isCompleteFn) {
 | 
			
		||||
            // Pass all the answers since some behaviours might need the extra data.
 | 
			
		||||
            complete = isCompleteFn(question, question.answers || {}, component, componentId);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Only pass the basic answers since questions should be independent of extra data.
 | 
			
		||||
            complete = CoreQuestionDelegate.instance.isCompleteResponse(question, newBasicAnswers, component, componentId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (complete < 0) {
 | 
			
		||||
            newState = 'cannotdeterminestatus';
 | 
			
		||||
        } else if (complete > 0) {
 | 
			
		||||
            newState = 'complete';
 | 
			
		||||
        } else {
 | 
			
		||||
            const gradable = CoreQuestionDelegate.instance.isGradableResponse(question, newBasicAnswers, component, componentId);
 | 
			
		||||
            if (gradable < 0) {
 | 
			
		||||
                newState = 'cannotdeterminestatus';
 | 
			
		||||
            } else if (gradable > 0) {
 | 
			
		||||
                newState = 'invalid';
 | 
			
		||||
            } else {
 | 
			
		||||
                newState = 'todo';
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return CoreQuestion.instance.getState(newState);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourDeferredFeedbackHandler extends makeSingleton(AddonQbehaviourDeferredFeedbackHandlerService) {}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a response is complete.
 | 
			
		||||
 *
 | 
			
		||||
 * @param question The question.
 | 
			
		||||
 * @param answers Object with the question answers (without prefix).
 | 
			
		||||
 * @param component The component the question is related to.
 | 
			
		||||
 * @param componentId Component ID.
 | 
			
		||||
 * @return 1 if complete, 0 if not complete, -1 if cannot determine.
 | 
			
		||||
 */
 | 
			
		||||
export type isCompleteResponseFunction = (
 | 
			
		||||
    question: CoreQuestionQuestionParsed,
 | 
			
		||||
    answers: CoreQuestionsAnswers,
 | 
			
		||||
    component: string,
 | 
			
		||||
    componentId: string | number,
 | 
			
		||||
) => number;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if two responses are the same.
 | 
			
		||||
 *
 | 
			
		||||
 * @param question Question.
 | 
			
		||||
 * @param prevAnswers Object with the previous question answers.
 | 
			
		||||
 * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
 | 
			
		||||
 * @param newAnswers Object with the new question answers.
 | 
			
		||||
 * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
 | 
			
		||||
 * @param component The component the question is related to.
 | 
			
		||||
 * @param componentId Component ID.
 | 
			
		||||
 * @return Whether they're the same.
 | 
			
		||||
 */
 | 
			
		||||
export type isSameResponseFunction = (
 | 
			
		||||
    question: CoreQuestionQuestionParsed,
 | 
			
		||||
    prevAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    prevBasicAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    newAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    newBasicAnswers: CoreQuestionsAnswers,
 | 
			
		||||
    component: string,
 | 
			
		||||
    componentId: string | number,
 | 
			
		||||
) => boolean;
 | 
			
		||||
							
								
								
									
										34
									
								
								src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourImmediateCBMHandler } from './services/handlers/immediatecbm';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateCBMHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourImmediateCBMModule {}
 | 
			
		||||
@ -0,0 +1,61 @@
 | 
			
		||||
// (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 { AddonQbehaviourDeferredCBMComponent } from '@addons/qbehaviour/deferredcbm/component/deferredcbm';
 | 
			
		||||
import { Injectable, Type } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support immediate CBM question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourImmediateCBMHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourImmediateCBM';
 | 
			
		||||
    type = 'immediatecbm';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a question behaviour.
 | 
			
		||||
     * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
 | 
			
		||||
     * If the behaviour requires to show some extra data, it should return the components to render it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Components (or promise resolved with components) to render some extra data in the question
 | 
			
		||||
     *         (e.g. certainty options). Don't return anything if no extra data is required.
 | 
			
		||||
     */
 | 
			
		||||
    handleQuestion(question: CoreQuestionQuestionParsed): void | Type<unknown>[] {
 | 
			
		||||
        CoreQuestionHelper.instance.extractQbehaviourButtons(question);
 | 
			
		||||
 | 
			
		||||
        if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) {
 | 
			
		||||
            // Depends on deferredcbm.
 | 
			
		||||
            return [AddonQbehaviourDeferredCBMComponent];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourImmediateCBMHandler extends makeSingleton(AddonQbehaviourImmediateCBMHandlerService) {}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
 | 
			
		||||
 | 
			
		||||
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { AddonQbehaviourImmediateFeedbackHandler } from './services/handlers/immediatefeedback';
 | 
			
		||||
 | 
			
		||||
@NgModule({
 | 
			
		||||
    declarations: [
 | 
			
		||||
    ],
 | 
			
		||||
    providers: [
 | 
			
		||||
        {
 | 
			
		||||
            provide: APP_INITIALIZER,
 | 
			
		||||
            multi: true,
 | 
			
		||||
            deps: [],
 | 
			
		||||
            useFactory: () => () => {
 | 
			
		||||
                CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateFeedbackHandler.instance);
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    ],
 | 
			
		||||
})
 | 
			
		||||
export class AddonQbehaviourImmediateFeedbackModule {}
 | 
			
		||||
@ -0,0 +1,58 @@
 | 
			
		||||
// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
 | 
			
		||||
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
 | 
			
		||||
import { CoreQuestionHelper } from '@features/question/services/question-helper';
 | 
			
		||||
import { makeSingleton } from '@singletons';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handler to support immediate feedback question behaviour.
 | 
			
		||||
 */
 | 
			
		||||
@Injectable({ providedIn: 'root' })
 | 
			
		||||
export class AddonQbehaviourImmediateFeedbackHandlerService implements CoreQuestionBehaviourHandler {
 | 
			
		||||
 | 
			
		||||
    name = 'AddonQbehaviourImmediateFeedback';
 | 
			
		||||
    type = 'immediatefeedback';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a question behaviour.
 | 
			
		||||
     * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
 | 
			
		||||
     * If the behaviour requires to show some extra data, it should return the components to render it.
 | 
			
		||||
     *
 | 
			
		||||
     * @param question The question.
 | 
			
		||||
     * @return Components (or promise resolved with components) to render some extra data in the question
 | 
			
		||||
     *         (e.g. certainty options). Don't return anything if no extra data is required.
 | 
			
		||||
     */
 | 
			
		||||
    handleQuestion(question: CoreQuestionQuestionParsed): void {
 | 
			
		||||
        // Just extract the button, it doesn't need any specific component.
 | 
			
		||||
        CoreQuestionHelper.instance.extractQbehaviourButtons(question);
 | 
			
		||||
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Whether or not the handler is enabled on a site level.
 | 
			
		||||
     *
 | 
			
		||||
     * @return True or promise resolved with true if enabled.
 | 
			
		||||
     */
 | 
			
		||||
    async isEnabled(): Promise<boolean> {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddonQbehaviourImmediateFeedbackHandler extends makeSingleton(AddonQbehaviourImmediateFeedbackHandlerService) {}
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
<input *ngIf="question && question.behaviourSeenInput" type="hidden" [name]="question.behaviourSeenInput.name"
 | 
			
		||||
    [value]="question.behaviourSeenInput.value" >
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user