Merge pull request #2681 from dpalou/MOBILE-3651

Mobile 3651
main
Dani Palou 2021-02-24 12:01:55 +01:00 committed by GitHub
commit 656ce16249
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
238 changed files with 23183 additions and 204 deletions

View File

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

View File

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

View File

@ -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.

View File

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

View File

@ -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>[] = [];

View File

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

View File

@ -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" />

View File

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

View File

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

View File

@ -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" />

View File

@ -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"

View File

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

View File

@ -60,13 +60,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref
await modal.present();
const password = <string | undefined> await modal.onWillDismiss();
const result = await modal.onWillDismiss();
if (typeof password != 'string') {
if (typeof result.data != 'string') {
throw new CoreCanceledError();
}
return password;
return result.data;
}
/**

View File

@ -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;

View File

@ -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.

View File

@ -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: [],

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

@ -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(''));
}
}

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -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);
}
}

View File

@ -0,0 +1,83 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { 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) {}

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

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

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

View File

@ -0,0 +1,3 @@
<ion-item class="ion-text-wrap">
<ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label>
</ion-item>

View File

@ -0,0 +1,7 @@
:host {
background-color: var(--red-light);
.item {
--background: var(--red-light);
}
}

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -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();
}
}

View File

@ -0,0 +1,83 @@
{
"answercolon": "Answer:",
"attemptfirst": "First attempt",
"attemptlast": "Last attempt",
"attemptnumber": "Attempt",
"attemptquiznow": "Attempt quiz now",
"attemptstate": "State",
"canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:",
"cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:",
"clearchoice": "Clear my choice",
"comment": "Comment",
"completedon": "Completed on",
"confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.",
"confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.",
"confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?",
"confirmstart": "Your attempt will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?",
"confirmstartheader": "Time limit",
"connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.",
"continueattemptquiz": "Continue the last attempt",
"continuepreview": "Continue the last preview",
"errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:",
"errordownloading": "Error downloading required data.",
"errorgetattempt": "Error getting attempt data.",
"errorgetquestions": "Error getting questions.",
"errorgetquiz": "Error getting quiz data.",
"errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.",
"errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:",
"errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:",
"errorsaveattempt": "An error occurred while saving the attempt data.",
"feedback": "Feedback",
"finishattemptdots": "Finish attempt...",
"finishnotsynced": "Finished but not synchronised",
"grade": "Grade",
"gradeaverage": "Average grade",
"gradehighest": "Highest grade",
"grademethod": "Grading method",
"gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.",
"marks": "Marks",
"modulenameplural": "Quizzes",
"mustbesubmittedby": "This attempt must be submitted by {{$a}}.",
"noquestions": "No questions have been added yet",
"noreviewattempt": "You are not allowed to review this attempt.",
"notyetgraded": "Not yet graded",
"opentoc": "Open navigation popover",
"outof": "{{$a.grade}} out of {{$a.maxgrade}}",
"outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)",
"outofshort": "{{$a.grade}}/{{$a.maxgrade}}",
"overallfeedback": "Overall feedback",
"overdue": "Overdue",
"overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.",
"preview": "Preview",
"previewquiznow": "Preview quiz now",
"question": "Question",
"quiznavigation": "Quiz navigation",
"quizpassword": "Quiz password",
"reattemptquiz": "Re-attempt quiz",
"requirepasswordmessage": "To attempt this quiz you need to know the quiz password",
"returnattempt": "Return to attempt",
"review": "Review",
"reviewofattempt": "Review of attempt {{$a}}",
"reviewofpreview": "Review of preview",
"showall": "Show all questions on one page",
"showeachpage": "Show one page at a time",
"startattempt": "Start attempt",
"startedon": "Started on",
"stateabandoned": "Never submitted",
"statefinished": "Finished",
"statefinisheddetails": "Submitted {{$a}}",
"stateinprogress": "In progress",
"stateoverdue": "Overdue",
"stateoverduedetails": "Must be submitted by {{$a}}",
"status": "Status",
"submitallandfinish": "Submit all and finish",
"summaryofattempt": "Summary of attempt",
"summaryofattempts": "Summary of your previous attempts",
"timeleft": "Time left",
"timetaken": "Time taken",
"warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.",
"warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.",
"warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.",
"warningquestionsnotsupported": "This quiz contains questions not supported by the app:",
"yourfinalgradeis": "Your final grade for this quiz is {{$a}}."
}

View File

@ -0,0 +1,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>

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,6 @@
:host {
.addon-mod_quiz-question-note p {
margin-top: 2px;
margin-bottom: 2px;
}
}

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

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

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

View File

@ -0,0 +1,326 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Type } from '@angular/core';
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz';
/**
* Interface that all access rules handlers must implement.
*/
export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler {
/**
* Name of the rule the handler supports. E.g. 'password'.
*/
ruleName: string;
/**
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
*
* @param quiz The quiz the rule belongs to.
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Whether the rule requires a preflight check.
*/
isPreflightCheckRequired(
quiz: AddonModQuizQuizWSData,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
siteId?: string,
): boolean | Promise<boolean>;
/**
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
*
* @param quiz The quiz the rule belongs to.
* @param preflightData Object where to add the preflight data.
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done if async, void if it's synchronous.
*/
getFixedPreflightData?(
quiz: AddonModQuizQuizWSData,
preflightData: Record<string, string>,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
siteId?: string,
): void | Promise<void>;
/**
* Return the Component to use to display the access rule preflight.
* Implement this if your access rule requires a preflight check with user interaction.
* It's recommended to return the class of the component, but you can also return an instance of the component.
*
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getPreflightComponent?(): Type<unknown> | Promise<Type<unknown>>;
/**
* Function called when the preflight check has passed. This is a chance to record that fact in some way.
*
* @param quiz The quiz the rule belongs to.
* @param attempt The attempt started/continued.
* @param preflightData Preflight data gathered.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckPassed?(
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData | undefined,
preflightData: Record<string, string>,
prefetch?: boolean,
siteId?: string,
): void | Promise<void>;
/**
* Function called when the preflight check fails. This is a chance to record that fact in some way.
*
* @param quiz The quiz the rule belongs to.
* @param attempt The attempt started/continued.
* @param preflightData Preflight data gathered.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done if async, void if it's synchronous.
*/
notifyPreflightCheckFailed?(
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData | undefined,
preflightData: Record<string, string>,
prefetch?: boolean,
siteId?: string,
): void | Promise<void>;
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param attempt The attempt.
* @param endTime The attempt end time (in seconds).
* @param timeNow The current time in seconds.
* @return Whether it should be displayed.
*/
shouldShowTimeLeft?(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean;
}
/**
* Delegate to register access rules for quiz module.
*/
@Injectable({ providedIn: 'root' })
export class AddonModQuizAccessRuleDelegateService extends CoreDelegate<AddonModQuizAccessRuleHandler> {
protected handlerNameProperty = 'ruleName';
constructor() {
super('AddonModQuizAccessRulesDelegate', true);
}
/**
* Get the handler for a certain rule.
*
* @param ruleName Name of the access rule.
* @return Handler. Undefined if no handler found for the rule.
*/
getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler {
return this.getHandler(ruleName, true);
}
/**
* Given a list of rules, get some fixed preflight data (data that doesn't require user interaction).
*
* @param rules List of active rules names.
* @param quiz Quiz.
* @param preflightData Object where to store the preflight data.
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when all the data has been gathered.
*/
async getFixedPreflightData(
rules: string[],
quiz: AddonModQuizQuizWSData,
preflightData: Record<string, string>,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
siteId?: string,
): Promise<void> {
rules = rules || [];
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
await this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]);
})));
}
/**
* Get the Component to use to display the access rule preflight.
*
* @param rule Rule.
* @return Promise resolved with the component to use, undefined if not found.
*/
getPreflightComponent(rule: string): Promise<Type<unknown> | undefined> {
return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', []));
}
/**
* Check if an access rule is supported.
*
* @param ruleName Name of the rule.
* @return Whether it's supported.
*/
isAccessRuleSupported(ruleName: string): boolean {
return this.hasHandler(ruleName, true);
}
/**
* Given a list of rules, check if preflight check is required.
*
* @param rules List of active rules names.
* @param quiz Quiz.
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it's required.
*/
async isPreflightCheckRequired(
rules: string[],
quiz: AddonModQuizQuizWSData,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
siteId?: string,
): Promise<boolean> {
rules = rules || [];
let isRequired = false;
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
const ruleRequired = await this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId);
isRequired = isRequired || ruleRequired;
})));
return isRequired;
}
/**
* Check if preflight check is required for a certain rule.
*
* @param rule Rule name.
* @param quiz Quiz.
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it's required.
*/
async isPreflightCheckRequiredForRule(
rule: string,
quiz: AddonModQuizQuizWSData,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
siteId?: string,
): Promise<boolean> {
const isRequired = await this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]);
return !!isRequired;
}
/**
* Notify all rules that the preflight check has passed.
*
* @param rules List of active rules names.
* @param quiz Quiz.
* @param attempt Attempt.
* @param preflightData Preflight data gathered.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async notifyPreflightCheckPassed(
rules: string[],
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData | undefined,
preflightData: Record<string, string>,
prefetch?: boolean,
siteId?: string,
): Promise<void> {
rules = rules || [];
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
await this.executeFunctionOnEnabled(
rule,
'notifyPreflightCheckPassed',
[quiz, attempt, preflightData, prefetch, siteId],
);
})));
}
/**
* Notify all rules that the preflight check has failed.
*
* @param rules List of active rules names.
* @param quiz Quiz.
* @param attempt Attempt.
* @param preflightData Preflight data gathered.
* @param prefetch Whether the user is prefetching the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async notifyPreflightCheckFailed(
rules: string[],
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData | undefined,
preflightData: Record<string, string>,
prefetch?: boolean,
siteId?: string,
): Promise<void> {
rules = rules || [];
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
await this.executeFunctionOnEnabled(
rule,
'notifyPreflightCheckFailed',
[quiz, attempt, preflightData, prefetch, siteId],
);
})));
}
/**
* Whether or not the time left of an attempt should be displayed.
*
* @param rules List of active rules names.
* @param attempt The attempt.
* @param endTime The attempt end time (in seconds).
* @param timeNow The current time in seconds.
* @return Whether it should be displayed.
*/
shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean {
rules = rules || [];
for (const i in rules) {
const rule = rules[i];
if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) {
return true;
}
}
return false;
}
}
export class AddonModQuizAccessRuleDelegate extends makeSingleton(AddonModQuizAccessRuleDelegateService) {}

View File

@ -0,0 +1,83 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSiteSchema } from '@services/sites';
/**
* Database variables for AddonModQuizOfflineProvider.
*/
export const ATTEMPTS_TABLE_NAME = 'addon_mod_quiz_attempts';
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModQuizOfflineProvider',
version: 1,
tables: [
{
name: ATTEMPTS_TABLE_NAME,
columns: [
{
name: 'id', // Attempt ID.
type: 'INTEGER',
primaryKey: true,
},
{
name: 'attempt', // Attempt number.
type: 'INTEGER',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'userid',
type: 'INTEGER',
},
{
name: 'quizid',
type: 'INTEGER',
},
{
name: 'currentpage',
type: 'INTEGER',
},
{
name: 'timecreated',
type: 'INTEGER',
},
{
name: 'timemodified',
type: 'INTEGER',
},
{
name: 'finished',
type: 'INTEGER',
},
],
},
],
};
/**
* Quiz attempt.
*/
export type AddonModQuizAttemptDBRecord = {
id: number;
attempt: number;
courseid: number;
userid: number;
quizid: number;
currentpage?: number;
timecreated: number;
timemodified: number;
finished: number;
};

View File

@ -0,0 +1,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) {}

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,372 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreQuestionBehaviourDelegate, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
import { CoreQuestionAnswerDBRecord } from '@features/question/services/database/question';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreSites } from '@services/sites';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import { AddonModQuizAttemptDBRecord, ATTEMPTS_TABLE_NAME } from './database/quiz';
import { AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz';
/**
* Service to handle offline quiz.
*/
@Injectable({ providedIn: 'root' })
export class AddonModQuizOfflineProvider {
protected logger: CoreLogger;
constructor() {
this.logger = CoreLogger.getInstance('AddonModQuizOfflineProvider');
}
/**
* Classify the answers in questions.
*
* @param answers List of answers.
* @return Object with the questions, the keys are the slot. Each question contains its answers.
*/
classifyAnswersInQuestions(answers: CoreQuestionsAnswers): AddonModQuizQuestionsWithAnswers {
const questionsWithAnswers: AddonModQuizQuestionsWithAnswers = {};
// Classify the answers in each question.
for (const name in answers) {
const slot = CoreQuestion.instance.getQuestionSlotFromName(name);
const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name);
if (!questionsWithAnswers[slot]) {
questionsWithAnswers[slot] = {
answers: {},
prefix: name.substr(0, name.indexOf(nameWithoutPrefix)),
};
}
questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name];
}
return questionsWithAnswers;
}
/**
* Given a list of questions with answers classified in it, returns a list of answers (including prefix in the name).
*
* @param questions Questions.
* @return Answers.
*/
extractAnswersFromQuestions(questions: AddonModQuizQuestionsWithAnswers): CoreQuestionsAnswers {
const answers: CoreQuestionsAnswers = {};
for (const slot in questions) {
const question = questions[slot];
for (const name in question.answers) {
answers[question.prefix + name] = question.answers[name];
}
}
return answers;
}
/**
* Get all the offline attempts in a certain site.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the offline attempts.
*/
async getAllAttempts(siteId?: string): Promise<AddonModQuizAttemptDBRecord[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
return db.getAllRecords(ATTEMPTS_TABLE_NAME);
}
/**
* Retrieve an attempt answers from site DB.
*
* @param attemptId Attempt ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the answers.
*/
getAttemptAnswers(attemptId: number, siteId?: string): Promise<CoreQuestionAnswerDBRecord[]> {
return CoreQuestion.instance.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId);
}
/**
* Retrieve an attempt from site DB.
*
* @param attemptId Attempt ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the attempt.
*/
async getAttemptById(attemptId: number, siteId?: string): Promise<AddonModQuizAttemptDBRecord> {
const db = await CoreSites.instance.getSiteDb(siteId);
return db.getRecord(ATTEMPTS_TABLE_NAME, { id: attemptId });
}
/**
* Retrieve an attempt from site DB.
*
* @param attemptId Attempt ID.
* @param siteId Site ID. If not defined, current site.
* @param userId User ID. If not defined, user current site's user.
* @return Promise resolved with the attempts.
*/
async getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise<AddonModQuizAttemptDBRecord[]> {
const site = await CoreSites.instance.getSite(siteId);
return site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { quizid: quizId, userid: userId || site.getUserId() });
}
/**
* Load local state in the questions.
*
* @param attemptId Attempt ID.
* @param questions List of questions.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async loadQuestionsLocalStates(
attemptId: number,
questions: CoreQuestionQuestionParsed[],
siteId?: string,
): Promise<CoreQuestionQuestionParsed[]> {
await Promise.all(questions.map(async (question) => {
const dbQuestion = await CoreUtils.instance.ignoreErrors(
CoreQuestion.instance.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId),
);
if (!dbQuestion) {
// Question not found.
return;
}
const state = CoreQuestion.instance.getState(dbQuestion.state);
question.state = dbQuestion.state;
question.status = Translate.instance.instant('core.question.' + state.status);
}));
return questions;
}
/**
* Process an attempt, saving its data.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @param questions Object with the questions of the quiz. The keys should be the question slot.
* @param data Data to save.
* @param finish Whether to finish the quiz.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success, rejected otherwise.
*/
async processAttempt(
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData,
questions: Record<number, CoreQuestionQuestionParsed>,
data: CoreQuestionsAnswers,
finish?: boolean,
siteId?: string,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const now = CoreTimeUtils.instance.timestamp();
const db = await CoreSites.instance.getSiteDb(siteId);
// Check if an attempt already exists. Return a new one if it doesn't.
let entry = await CoreUtils.instance.ignoreErrors(this.getAttemptById(attempt.id, siteId));
if (entry) {
entry.timemodified = now;
entry.finished = finish ? 1 : 0;
} else {
entry = {
quizid: quiz.id,
userid: attempt.userid!,
id: attempt.id,
courseid: quiz.course,
timecreated: now,
attempt: attempt.attempt!,
currentpage: attempt.currentpage,
timemodified: now,
finished: finish ? 1 : 0,
};
}
// Save attempt in DB.
await db.insertRecord(ATTEMPTS_TABLE_NAME, entry);
// Attempt has been saved, now we need to save the answers.
await this.saveAnswers(quiz, attempt, questions, data, now, siteId);
}
/**
* Remove an attempt and its answers from local DB.
*
* @param attemptId Attempt ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const db = await CoreSites.instance.getSiteDb(siteId);
await Promise.all([
CoreQuestion.instance.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId),
CoreQuestion.instance.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId),
db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }),
]);
}
/**
* Remove a question and its answers from local DB.
*
* @param attemptId Attempt ID.
* @param slot Question slot.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when finished.
*/
async removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
await Promise.all([
CoreQuestion.instance.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
]);
}
/**
* Save an attempt's answers and calculate state for questions modified.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @param questions Object with the questions of the quiz. The keys should be the question slot.
* @param answers Answers to save.
* @param timeMod Time modified to set in the answers. If not defined, current time.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async saveAnswers(
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData,
questions: Record<number, CoreQuestionQuestionParsed>,
answers: CoreQuestionsAnswers,
timeMod?: number,
siteId?: string,
): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
timeMod = timeMod || CoreTimeUtils.instance.timestamp();
const questionsWithAnswers: Record<number, CoreQuestionQuestionWithAnswers> = {};
const newStates: Record<number, string> = {};
// Classify the answers in each question.
for (const name in answers) {
const slot = CoreQuestion.instance.getQuestionSlotFromName(name);
const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name);
if (questions[slot]) {
if (!questionsWithAnswers[slot]) {
questionsWithAnswers[slot] = questions[slot];
questionsWithAnswers[slot].answers = {};
}
questionsWithAnswers[slot].answers![nameWithoutPrefix] = answers[name];
}
}
// First determine the new state of each question. We won't save the new state yet.
await Promise.all(Object.values(questionsWithAnswers).map(async (question) => {
const state = await CoreQuestionBehaviourDelegate.instance.determineNewState(
quiz.preferredbehaviour!,
AddonModQuizProvider.COMPONENT,
attempt.id,
question,
quiz.coursemodule,
siteId,
);
// Check if state has changed.
if (state && state.name != question.state) {
newStates[question.slot] = state.name;
}
// Delete previously stored answers for this question.
await CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId);
}));
// Now save the answers.
await CoreQuestion.instance.saveAnswers(
AddonModQuizProvider.COMPONENT,
quiz.id,
attempt.id,
attempt.userid!,
answers,
timeMod,
siteId,
);
try {
// Answers have been saved, now we can save the questions with the states.
await CoreUtils.instance.allPromises(Object.keys(newStates).map(async (slot) => {
const question = questionsWithAnswers[Number(slot)];
await CoreQuestion.instance.saveQuestion(
AddonModQuizProvider.COMPONENT,
quiz.id,
attempt.id,
attempt.userid!,
question,
newStates[slot],
siteId,
);
}));
} catch (error) {
// Ignore errors when saving question state.
this.logger.error('Error saving question state', error);
}
}
/**
* Set attempt's current page.
*
* @param attemptId Attempt ID.
* @param page Page to set.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success, rejected otherwise.
*/
async setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise<void> {
const db = await CoreSites.instance.getSiteDb(siteId);
await db.updateRecords(ATTEMPTS_TABLE_NAME, { currentpage: page }, { id: attemptId });
}
}
export class AddonModQuizOffline extends makeSingleton(AddonModQuizOfflineProvider) {}
/**
* Answers classified by question slot.
*/
export type AddonModQuizQuestionsWithAnswers = Record<number, {
prefix: string;
answers: CoreQuestionsAnswers;
}>;

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

File diff suppressed because it is too large Load Diff

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

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

View 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 { AddonQbehaviourAdaptiveNoPenaltyHandler } from './services/handlers/adaptivenopenalty';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveNoPenaltyHandler.instance);
},
},
],
})
export class AddonQbehaviourAdaptiveNoPenaltyModule {}

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

View File

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

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

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

View File

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

View 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 { AddonQbehaviourDeferredFeedbackHandler } from './services/handlers/deferredfeedback';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredFeedbackHandler.instance);
},
},
],
})
export class AddonQbehaviourDeferredFeedbackModule {}

View File

@ -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;

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

View File

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

View 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 { AddonQbehaviourImmediateFeedbackHandler } from './services/handlers/immediatefeedback';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateFeedbackHandler.instance);
},
},
],
})
export class AddonQbehaviourImmediateFeedbackModule {}

View File

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

View File

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