commit
656ce16249
|
@ -24,6 +24,8 @@ import { AddonNotificationsModule } from './notifications/notifications.module';
|
||||||
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
|
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
|
||||||
import { AddonMessagesModule } from './messages/messages.module';
|
import { AddonMessagesModule } from './messages/messages.module';
|
||||||
import { AddonModModule } from './mod/mod.module';
|
import { AddonModModule } from './mod/mod.module';
|
||||||
|
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
|
||||||
|
import { AddonQtypeModule } from './qtype/qtype.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -37,6 +39,8 @@ import { AddonModModule } from './mod/mod.module';
|
||||||
AddonNotificationsModule,
|
AddonNotificationsModule,
|
||||||
AddonMessageOutputModule,
|
AddonMessageOutputModule,
|
||||||
AddonModModule,
|
AddonModModule,
|
||||||
|
AddonQbehaviourModule,
|
||||||
|
AddonQtypeModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AddonsModule {}
|
export class AddonsModule {}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div *ngFor="let item of items">
|
<div *ngFor="let item of items">
|
||||||
<ion-card>
|
<ion-card>
|
||||||
<ion-item class="core-course-module-handler item-media ion-text-wrap" detail="false" (click)="action($event, item)"
|
<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">
|
<img slot="start" [src]="item.iconUrl" alt="" role="presentation" *ngIf="item.iconUrl" class="core-module-icon">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<h2>
|
<h2>
|
||||||
|
|
|
@ -30,7 +30,6 @@ export class AddonBlockRecentlyAccessedItemsHandlerService extends CoreBlockBase
|
||||||
/**
|
/**
|
||||||
* Returns the data needed to render the block.
|
* Returns the data needed to render the block.
|
||||||
*
|
*
|
||||||
* @param injector Injector.
|
|
||||||
* @param block The block to render.
|
* @param block The block to render.
|
||||||
* @param contextLevel The context where the block will be used.
|
* @param contextLevel The context where the block will be used.
|
||||||
* @param instanceId The instance ID associated with the context level.
|
* @param instanceId The instance ID associated with the context level.
|
||||||
|
|
|
@ -53,7 +53,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
|
||||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||||
*/
|
*/
|
||||||
async syncAllEvents(siteId?: string, force = false): Promise<void> {
|
async syncAllEvents(siteId?: string, force = false): Promise<void> {
|
||||||
await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId);
|
await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, force), siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -74,17 +74,17 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider<AddonMessage
|
||||||
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
|
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
|
||||||
const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : '');
|
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.
|
* 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 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.
|
* @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 userIds: number[] = [];
|
||||||
const conversationIds: number[] = [];
|
const conversationIds: number[] = [];
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
|
|
|
@ -897,10 +897,7 @@ export class AddonMessagesProvider {
|
||||||
): Promise<{members: AddonMessagesConversationMember[]; canLoadMore: boolean}> {
|
): Promise<{members: AddonMessagesConversationMember[]; canLoadMore: boolean}> {
|
||||||
const site = await CoreSites.instance.getSite(siteId);
|
const site = await CoreSites.instance.getSite(siteId);
|
||||||
userId = userId || site.getUserId();
|
userId = userId || site.getUserId();
|
||||||
|
limitTo = limitTo ?? AddonMessagesProvider.LIMIT_MESSAGES;
|
||||||
if (typeof limitTo == 'undefined' || limitTo === null) {
|
|
||||||
limitTo = AddonMessagesProvider.LIMIT_MESSAGES;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId),
|
cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId),
|
||||||
|
@ -948,11 +945,9 @@ export class AddonMessagesProvider {
|
||||||
|
|
||||||
options.userId = options.userId || site.getUserId();
|
options.userId = options.userId || site.getUserId();
|
||||||
options.limitFrom = options.limitFrom || 0;
|
options.limitFrom = options.limitFrom || 0;
|
||||||
options.limitTo = options.limitTo === undefined || options.limitTo === null
|
options.limitTo = options.limitTo ?? AddonMessagesProvider.LIMIT_MESSAGES;
|
||||||
? AddonMessagesProvider.LIMIT_MESSAGES
|
|
||||||
: options.limitTo;
|
|
||||||
options.timeFrom = options.timeFrom || 0;
|
options.timeFrom = options.timeFrom || 0;
|
||||||
options.newestFirst = options.newestFirst === undefined || options.newestFirst === null ? true : options.newestFirst;
|
options.newestFirst = options.newestFirst ?? true;
|
||||||
|
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getCacheKeyForConversationMessages(options.userId, conversationId),
|
cacheKey: this.getCacheKeyForConversationMessages(options.userId, conversationId),
|
||||||
|
|
|
@ -64,7 +64,7 @@
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-button expand="block" type="submit">
|
<ion-button expand="block" type="submit">
|
||||||
{{ 'addon.mod_lesson.continue' | translate }}
|
{{ '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>
|
</ion-button>
|
||||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||||
<input type="submit" class="core-submit-hidden-enter" />
|
<input type="submit" class="core-submit-hidden-enter" />
|
||||||
|
|
|
@ -531,7 +531,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formattedData.lessonscored) {
|
if (formattedData.lessonscored) {
|
||||||
if (formattedData.numofattempts) {
|
if (formattedData.numofattempts && formattedData.avescore != null) {
|
||||||
formattedData.avescore = CoreTextUtils.instance.roundToDecimals(formattedData.avescore, 2);
|
formattedData.avescore = CoreTextUtils.instance.roundToDecimals(formattedData.avescore, 2);
|
||||||
}
|
}
|
||||||
if (formattedData.highscore != null) {
|
if (formattedData.highscore != null) {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<ion-buttons slot="end">
|
<ion-buttons slot="end">
|
||||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
<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-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<ion-buttons slot="end">
|
<ion-buttons slot="end">
|
||||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
<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-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-button expand="block" type="submit">
|
<ion-button expand="block" type="submit">
|
||||||
{{ 'addon.mod_lesson.continue' | translate }}
|
{{ '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>
|
</ion-button>
|
||||||
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||||
<input type="submit" class="core-submit-hidden-enter" />
|
<input type="submit" class="core-submit-hidden-enter" />
|
||||||
|
|
|
@ -99,7 +99,7 @@
|
||||||
<ng-container *ngSwitchCase="'multichoice'">
|
<ng-container *ngSwitchCase="'multichoice'">
|
||||||
<!-- Single choice. -->
|
<!-- Single choice. -->
|
||||||
<ion-radio-group *ngIf="!question.multi" [formControlName]="question.controlName">
|
<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>
|
<ion-label>
|
||||||
<core-format-text [component]="component" [componentId]="lesson.coursemodule"
|
<core-format-text [component]="component" [componentId]="lesson.coursemodule"
|
||||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||||
|
@ -113,7 +113,7 @@
|
||||||
|
|
||||||
<!-- Multiple choice. -->
|
<!-- Multiple choice. -->
|
||||||
<ng-container *ngIf="question.multi">
|
<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>
|
<ion-label>
|
||||||
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
<core-format-text [component]="component" [componentId]="lesson?.coursemodule"
|
||||||
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
[text]="option.text" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { CoreUrlUtils } from '@services/utils/url';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreWSExternalFile } from '@services/ws';
|
import { CoreWSExternalFile } from '@services/ws';
|
||||||
import { ModalController, Translate } from '@singletons';
|
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 { AddonModLessonMenuModalPage } from '../../components/menu-modal/menu-modal';
|
||||||
import {
|
import {
|
||||||
AddonModLesson,
|
AddonModLesson,
|
||||||
|
@ -409,7 +409,7 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave {
|
||||||
this.messages = this.messages.concat(data.messages);
|
this.messages = this.messages.concat(data.messages);
|
||||||
this.processData = undefined;
|
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.
|
// Format activity link if present.
|
||||||
if (this.eolData.activitylink) {
|
if (this.eolData.activitylink) {
|
||||||
|
|
|
@ -60,13 +60,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref
|
||||||
|
|
||||||
await modal.present();
|
await modal.present();
|
||||||
|
|
||||||
const password = <string | undefined> await modal.onWillDismiss();
|
const result = await modal.onWillDismiss();
|
||||||
|
|
||||||
if (typeof password != 'string') {
|
if (typeof result.data != 'string') {
|
||||||
throw new CoreCanceledError();
|
throw new CoreCanceledError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return password;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -253,7 +253,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync finished, set sync time.
|
// 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.
|
// All done, return the result.
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -2231,14 +2231,14 @@ export class AddonModLessonProvider {
|
||||||
* @param data Data containing the user answer.
|
* @param data Data containing the user answer.
|
||||||
* @return User response.
|
* @return User response.
|
||||||
*/
|
*/
|
||||||
protected getUserResponseMultichoice(data: Record<string, unknown>): number[] | null {
|
protected getUserResponseMultichoice(data: Record<string, unknown>): number[] | undefined {
|
||||||
if (data.answer) {
|
if (data.answer) {
|
||||||
// The data is already stored as expected. If it's valid, parse the values to int.
|
// The data is already stored as expected. If it's valid, parse the values to int.
|
||||||
if (Array.isArray(data.answer)) {
|
if (Array.isArray(data.answer)) {
|
||||||
return data.answer.map((value) => parseInt(value, 10));
|
return data.answer.map((value) => parseInt(value, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data is stored in properties like 'answer[379]'. Recreate the answer array.
|
// Data is stored in properties like 'answer[379]'. Recreate the answer array.
|
||||||
|
@ -3979,12 +3979,12 @@ export type AddonModLessonGetAttemptsOverviewWSResponse = {
|
||||||
export type AddonModLessonAttemptsOverviewWSData = {
|
export type AddonModLessonAttemptsOverviewWSData = {
|
||||||
lessonscored: boolean; // True if the lesson was scored.
|
lessonscored: boolean; // True if the lesson was scored.
|
||||||
numofattempts: number; // Number of attempts.
|
numofattempts: number; // Number of attempts.
|
||||||
avescore: number; // Average score.
|
avescore: number | null; // Average score.
|
||||||
highscore: number; // High score.
|
highscore: number | null; // High score.
|
||||||
lowscore: number; // Low score.
|
lowscore: number | null; // Low score.
|
||||||
avetime: number; // Average time (spent in taking the lesson).
|
avetime: number | null; // Average time (spent in taking the lesson).
|
||||||
hightime: number; // High time.
|
hightime: number | null; // High time.
|
||||||
lowtime: number; // Low time.
|
lowtime: number | null; // Low time.
|
||||||
students?: AddonModLessonAttemptsOverviewsStudentWSData[]; // Students data, including attempts.
|
students?: AddonModLessonAttemptsOverviewsStudentWSData[]; // Students data, including attempts.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4003,7 +4003,7 @@ export type AddonModLessonAttemptsOverviewsStudentWSData = {
|
||||||
*/
|
*/
|
||||||
export type AddonModLessonAttemptsOverviewsAttemptWSData = {
|
export type AddonModLessonAttemptsOverviewsAttemptWSData = {
|
||||||
try: number; // Attempt number.
|
try: number; // Attempt number.
|
||||||
grade: number; // Attempt grade.
|
grade: number | null; // Attempt grade.
|
||||||
timestart: number; // Attempt time started.
|
timestart: number; // Attempt time started.
|
||||||
timeend: number; // Attempt last time continued.
|
timeend: number; // Attempt last time continued.
|
||||||
end: number; // Attempt time ended.
|
end: number; // Attempt time ended.
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { AddonModAssignModule } from './assign/assign.module';
|
||||||
import { AddonModBookModule } from './book/book.module';
|
import { AddonModBookModule } from './book/book.module';
|
||||||
import { AddonModLessonModule } from './lesson/lesson.module';
|
import { AddonModLessonModule } from './lesson/lesson.module';
|
||||||
import { AddonModPageModule } from './page/page.module';
|
import { AddonModPageModule } from './page/page.module';
|
||||||
|
import { AddonModQuizModule } from './quiz/quiz.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
|
@ -26,6 +27,7 @@ import { AddonModPageModule } from './page/page.module';
|
||||||
AddonModBookModule,
|
AddonModBookModule,
|
||||||
AddonModLessonModule,
|
AddonModLessonModule,
|
||||||
AddonModPageModule,
|
AddonModPageModule,
|
||||||
|
AddonModQuizModule,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessDelayBetweenAttemptsModule } from './delaybetweenattempts/delaybetweenattempts.module';
|
||||||
|
import { AddonModQuizAccessIpAddressModule } from './ipaddress/ipaddress.module';
|
||||||
|
import { AddonModQuizAccessNumAttemptsModule } from './numattempts/numattempts.module';
|
||||||
|
import { AddonModQuizAccessOfflineAttemptsModule } from './offlineattempts/offlineattempts.module';
|
||||||
|
import { AddonModQuizAccessOpenCloseDateModule } from './openclosedate/openclosedate.module';
|
||||||
|
import { AddonModQuizAccessPasswordModule } from './password/password.module';
|
||||||
|
import { AddonModQuizAccessSafeBrowserModule } from './safebrowser/safebrowser.module';
|
||||||
|
import { AddonModQuizAccessSecureWindowModule } from './securewindow/securewindow.module';
|
||||||
|
import { AddonModQuizAccessTimeLimitModule } from './timelimit/timelimit.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
AddonModQuizAccessDelayBetweenAttemptsModule,
|
||||||
|
AddonModQuizAccessIpAddressModule,
|
||||||
|
AddonModQuizAccessNumAttemptsModule,
|
||||||
|
AddonModQuizAccessOfflineAttemptsModule,
|
||||||
|
AddonModQuizAccessOpenCloseDateModule,
|
||||||
|
AddonModQuizAccessPasswordModule,
|
||||||
|
AddonModQuizAccessSafeBrowserModule,
|
||||||
|
AddonModQuizAccessSecureWindowModule,
|
||||||
|
AddonModQuizAccessTimeLimitModule,
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessRulesModule { }
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessDelayBetweenAttemptsHandler } from './services/handlers/delaybetweenattempts';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessDelayBetweenAttemptsHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessDelayBetweenAttemptsModule {}
|
|
@ -0,0 +1,54 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support delay between attempts access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessDelayBetweenAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessDelayBetweenAttempts';
|
||||||
|
ruleName = 'quizaccess_delaybetweenattempts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessDelayBetweenAttemptsHandler
|
||||||
|
extends makeSingleton(AddonModQuizAccessDelayBetweenAttemptsHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessIpAddressHandler } from './services/handlers/ipaddress';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessIpAddressHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessIpAddressModule {}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support IP address access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessIpAddressHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessIpAddress';
|
||||||
|
ruleName = 'quizaccess_ipaddress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessIpAddressHandler extends makeSingleton(AddonModQuizAccessIpAddressHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessNumAttemptsHandler } from './services/handlers/numattempts';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessNumAttemptsHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessNumAttemptsModule {}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support num attempts access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessNumAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessNumAttempts';
|
||||||
|
ruleName = 'quizaccess_numattempts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessNumAttemptsHandler extends makeSingleton(AddonModQuizAccessNumAttemptsHandlerService) {}
|
|
@ -0,0 +1,6 @@
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>
|
||||||
|
<h3 class="item-heading">{{ 'core.settings.synchronization' | translate }}</h3>
|
||||||
|
<p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
|
@ -0,0 +1,56 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync';
|
||||||
|
import { Component, OnInit, Input } from '@angular/core';
|
||||||
|
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render the preflight for offline attempts.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-access-offline-attempts',
|
||||||
|
templateUrl: 'addon-mod-quiz-access-offline-attempts.html',
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessOfflineAttemptsComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() rule?: string; // The name of the rule.
|
||||||
|
@Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
|
||||||
|
@Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
|
||||||
|
@Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
|
||||||
|
@Input() siteId?: string; // Site ID.
|
||||||
|
@Input() form?: FormGroup; // Form where to add the form control.
|
||||||
|
|
||||||
|
syncTimeReadable = '';
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
// Always set confirmdatasaved to 1. Sending the data means the user accepted.
|
||||||
|
this.form?.addControl('confirmdatasaved', this.fb.control(1));
|
||||||
|
|
||||||
|
if (!this.quiz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = await AddonModQuizSync.instance.getSyncTime(this.quiz.id);
|
||||||
|
|
||||||
|
this.syncTimeReadable = AddonModQuizSync.instance.getReadableTimeFromTimestamp(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { AddonModQuizAccessOfflineAttemptsComponent } from './component/offlineattempts';
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessOfflineAttemptsHandler } from './services/handlers/offlineattempts';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModQuizAccessOfflineAttemptsComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOfflineAttemptsHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonModQuizAccessOfflineAttemptsComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessOfflineAttemptsModule {}
|
|
@ -0,0 +1,102 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { AddonModQuizAccessOfflineAttemptsComponent } from '../../component/offlineattempts';
|
||||||
|
import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support offline attempts access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessOfflineAttemptsHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessOfflineAttempts';
|
||||||
|
ruleName = 'quizaccess_offlineattempts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param preflightData Object where to add the preflight data.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
getFixedPreflightData(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
): void | Promise<void> {
|
||||||
|
preflightData.confirmdatasaved = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Component to use to display the access rule preflight.
|
||||||
|
* Implement this if your access rule requires a preflight check with user interaction.
|
||||||
|
* It's recommended to return the class of the component, but you can also return an instance of the component.
|
||||||
|
*
|
||||||
|
* @return The component (or promise resolved with component) to use, undefined if not found.
|
||||||
|
*/
|
||||||
|
getPreflightComponent(): Type<unknown> | Promise<Type<unknown>> {
|
||||||
|
return AddonModQuizAccessOfflineAttemptsComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
async isPreflightCheckRequired(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (prefetch) {
|
||||||
|
// Don't show the warning if the user is prefetching.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempt) {
|
||||||
|
// User is starting a new attempt, show the warning.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncTime = await AddonModQuizSync.instance.getSyncTime(quiz.id);
|
||||||
|
|
||||||
|
// Show warning if last sync was a while ago.
|
||||||
|
return Date.now() - AddonModQuizSync.instance.syncInterval > syncTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessOfflineAttemptsHandler extends makeSingleton(AddonModQuizAccessOfflineAttemptsHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessOpenCloseDateHandler } from './services/handlers/openclosedate';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOpenCloseDateHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessOpenCloseDateModule {}
|
|
@ -0,0 +1,76 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizProvider } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support open/close date access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessOpenCloseDateHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessOpenCloseDate';
|
||||||
|
ruleName = 'quizaccess_openclosedate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the time left of an attempt should be displayed.
|
||||||
|
*
|
||||||
|
* @param attempt The attempt.
|
||||||
|
* @param endTime The attempt end time (in seconds).
|
||||||
|
* @param timeNow The current time in seconds.
|
||||||
|
* @return Whether it should be displayed.
|
||||||
|
*/
|
||||||
|
shouldShowTimeLeft(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean {
|
||||||
|
// If this is a teacher preview after the close date, do not show the time.
|
||||||
|
if (attempt.preview && timeNow > endTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the time left only if it's less than QUIZ_SHOW_TIME_BEFORE_DEADLINE.
|
||||||
|
if (timeNow > endTime - AddonModQuizProvider.QUIZ_SHOW_TIME_BEFORE_DEADLINE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessOpenCloseDateHandler extends makeSingleton(AddonModQuizAccessOpenCloseDateHandlerService) {}
|
|
@ -0,0 +1,14 @@
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>
|
||||||
|
<h3 class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</h3>
|
||||||
|
<p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item [formGroup]="form">
|
||||||
|
<ion-label></ion-label>
|
||||||
|
<core-show-password [name]="'quizpassword'">
|
||||||
|
<ion-input id="addon-mod_quiz-accessrule-password-input" name="quizpassword" type="password"
|
||||||
|
placeholder="{{ 'addon.mod_quiz.quizpassword' | translate }}" [formControlName]="'quizpassword'" [clearOnEdit]="false">
|
||||||
|
</ion-input>
|
||||||
|
</core-show-password>
|
||||||
|
</ion-item>
|
|
@ -0,0 +1,46 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, OnInit, Input } from '@angular/core';
|
||||||
|
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||||
|
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render the preflight for password.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-access-password',
|
||||||
|
templateUrl: 'addon-mod-quiz-access-password.html',
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessPasswordComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() rule?: string; // The name of the rule.
|
||||||
|
@Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
|
||||||
|
@Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
|
||||||
|
@Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
|
||||||
|
@Input() siteId?: string; // Site ID.
|
||||||
|
@Input() form?: FormGroup; // Form where to add the form control.
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Add the control for the password.
|
||||||
|
this.form?.addControl('quizpassword', this.fb.control(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CORE_SITE_SCHEMAS } from '@services/sites';
|
||||||
|
import { AddonModQuizAccessPasswordComponent } from './component/password';
|
||||||
|
import { AddonModQuizAccessPasswordHandler } from './services/handlers/password';
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { SITE_SCHEMA } from './services/database/password';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModQuizAccessPasswordComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CORE_SITE_SCHEMAS,
|
||||||
|
useValue: [SITE_SCHEMA],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessPasswordHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonModQuizAccessPasswordComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessPasswordModule {}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreSiteSchema } from '@services/sites';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database variables for AddonModQuizAccessPasswordHandlerService.
|
||||||
|
*/
|
||||||
|
export const PASSWORD_TABLE_NAME = 'addon_mod_quiz_access_password';
|
||||||
|
export const SITE_SCHEMA: CoreSiteSchema = {
|
||||||
|
name: 'AddonModQuizAccessPasswordHandler',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: PASSWORD_TABLE_NAME,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
type: 'TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timemodified',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiz attempt.
|
||||||
|
*/
|
||||||
|
export type AddonModQuizAccessPasswordDBRecord = {
|
||||||
|
id: number;
|
||||||
|
password: string;
|
||||||
|
timemodified: number;
|
||||||
|
};
|
|
@ -0,0 +1,198 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { AddonModQuizAccessPasswordDBRecord, PASSWORD_TABLE_NAME } from '../database/password';
|
||||||
|
import { AddonModQuizAccessPasswordComponent } from '../../component/password';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support password access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessPasswordHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessPassword';
|
||||||
|
ruleName = 'quizaccess_password';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param preflightData Object where to add the preflight data.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
async getFixedPreflightData(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof preflightData.quizpassword != 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get a password stored. If it's found, use it.
|
||||||
|
const entry = await this.getPasswordEntry(quiz.id, siteId);
|
||||||
|
|
||||||
|
preflightData.quizpassword = entry.password;
|
||||||
|
} catch {
|
||||||
|
// No password stored.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a password stored in DB.
|
||||||
|
*
|
||||||
|
* @param quizId Quiz ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the DB entry on success.
|
||||||
|
*/
|
||||||
|
protected async getPasswordEntry(quizId: number, siteId?: string): Promise<AddonModQuizAccessPasswordDBRecord> {
|
||||||
|
const site = await CoreSites.instance.getSite(siteId);
|
||||||
|
|
||||||
|
return site.getDb().getRecord(PASSWORD_TABLE_NAME, { id: quizId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Component to use to display the access rule preflight.
|
||||||
|
* Implement this if your access rule requires a preflight check with user interaction.
|
||||||
|
* It's recommended to return the class of the component, but you can also return an instance of the component.
|
||||||
|
*
|
||||||
|
* @return The component (or promise resolved with component) to use, undefined if not found.
|
||||||
|
*/
|
||||||
|
getPreflightComponent(): Type<unknown> | Promise<Type<unknown>> {
|
||||||
|
return AddonModQuizAccessPasswordComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
async isPreflightCheckRequired(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// If there's a password stored don't require the preflight since we'll use the stored one.
|
||||||
|
const entry = await CoreUtils.instance.ignoreErrors(this.getPasswordEntry(quiz.id, siteId));
|
||||||
|
|
||||||
|
return !entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when the preflight check has passed. This is a chance to record that fact in some way.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
async notifyPreflightCheckPassed(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// The password is right, store it to use it automatically in following executions.
|
||||||
|
if (typeof preflightData.quizpassword != 'undefined') {
|
||||||
|
return this.storePassword(quiz.id, preflightData.quizpassword, siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when the preflight check fails. This is a chance to record that fact in some way.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
notifyPreflightCheckFailed?(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// The password is wrong, remove it from DB if it's there.
|
||||||
|
return this.removePassword(quiz.id, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a password from DB.
|
||||||
|
*
|
||||||
|
* @param quizId Quiz ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async removePassword(quizId: number, siteId?: string): Promise<void> {
|
||||||
|
const site = await CoreSites.instance.getSite(siteId);
|
||||||
|
|
||||||
|
await site.getDb().deleteRecords(PASSWORD_TABLE_NAME, { id: quizId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a password in DB.
|
||||||
|
*
|
||||||
|
* @param quizId Quiz ID.
|
||||||
|
* @param password Password.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async storePassword(quizId: number, password: string, siteId?: string): Promise<void> {
|
||||||
|
const site = await CoreSites.instance.getSite(siteId);
|
||||||
|
|
||||||
|
const entry: AddonModQuizAccessPasswordDBRecord = {
|
||||||
|
id: quizId,
|
||||||
|
password,
|
||||||
|
timemodified: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await site.getDb().insertRecord(PASSWORD_TABLE_NAME, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessPasswordHandler extends makeSingleton(AddonModQuizAccessPasswordHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessSafeBrowserHandler } from './services/handlers/safebrowser';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSafeBrowserHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessSafeBrowserModule {}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support safe address access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessSafeBrowserHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessSafeBrowser';
|
||||||
|
ruleName = 'quizaccess_safebrowser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessSafeBrowserHandler extends makeSingleton(AddonModQuizAccessSafeBrowserHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessSecureWindowHandler } from './services/handlers/securewindow';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSecureWindowHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessSecureWindowModule {}
|
|
@ -0,0 +1,53 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support secure window access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessSecureWindowHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessSecureWindow';
|
||||||
|
ruleName = 'quizaccess_securewindow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(): boolean | Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessSecureWindowHandler extends makeSingleton(AddonModQuizAccessSecureWindowHandlerService) {}
|
|
@ -0,0 +1,6 @@
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>
|
||||||
|
<h3 class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</h3>
|
||||||
|
<p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
|
@ -0,0 +1,47 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to render the preflight for time limit.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-access-time-limit',
|
||||||
|
templateUrl: 'addon-mod-quiz-access-time-limit.html',
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessTimeLimitComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() rule?: string; // The name of the rule.
|
||||||
|
@Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to.
|
||||||
|
@Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued.
|
||||||
|
@Input() prefetch?: boolean; // Whether the user is prefetching the quiz.
|
||||||
|
@Input() siteId?: string; // Site ID.
|
||||||
|
@Input() form?: FormGroup; // Form where to add the form control.
|
||||||
|
|
||||||
|
readableTimeLimit = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.quiz?.timelimit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz?.timelimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz';
|
||||||
|
import { AddonModQuizAccessTimeLimitComponent } from '../../component/timelimit';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support time limit access rule.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessTimeLimitHandlerService implements AddonModQuizAccessRuleHandler {
|
||||||
|
|
||||||
|
name = 'AddonModQuizAccessTimeLimit';
|
||||||
|
ruleName = 'quizaccess_timelimit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Component to use to display the access rule preflight.
|
||||||
|
* Implement this if your access rule requires a preflight check with user interaction.
|
||||||
|
* It's recommended to return the class of the component, but you can also return an instance of the component.
|
||||||
|
*
|
||||||
|
* @return The component (or promise resolved with component) to use, undefined if not found.
|
||||||
|
*/
|
||||||
|
getPreflightComponent(): Type<unknown> | Promise<Type<unknown>> {
|
||||||
|
return AddonModQuizAccessTimeLimitComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
): boolean | Promise<boolean> {
|
||||||
|
// Warning only required if the attempt is not already started.
|
||||||
|
return !attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the time left of an attempt should be displayed.
|
||||||
|
*
|
||||||
|
* @param attempt The attempt.
|
||||||
|
* @param endTime The attempt end time (in seconds).
|
||||||
|
* @param timeNow The current time in seconds.
|
||||||
|
* @return Whether it should be displayed.
|
||||||
|
*/
|
||||||
|
shouldShowTimeLeft(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean {
|
||||||
|
// If this is a teacher preview after the time limit expires, don't show the time left.
|
||||||
|
return !(attempt.preview && timeNow > endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessTimeLimitHandler extends makeSingleton(AddonModQuizAccessTimeLimitHandlerService) {}
|
|
@ -0,0 +1,43 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { AddonModQuizAccessTimeLimitComponent } from './component/timelimit';
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAccessTimeLimitHandler } from './services/handlers/timelimit';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModQuizAccessTimeLimitComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessTimeLimitHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonModQuizAccessTimeLimitComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizAccessTimeLimitModule {}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||||
|
import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error';
|
||||||
|
import { AddonModQuizIndexComponent } from './index/index';
|
||||||
|
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
|
||||||
|
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModQuizIndexComponent,
|
||||||
|
AddonModQuizConnectionErrorComponent,
|
||||||
|
AddonModQuizNavigationModalComponent,
|
||||||
|
AddonModQuizPreflightModalComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
CoreCourseComponentsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonModQuizIndexComponent,
|
||||||
|
AddonModQuizConnectionErrorComponent,
|
||||||
|
AddonModQuizNavigationModalComponent,
|
||||||
|
AddonModQuizPreflightModalComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModQuizComponentsModule {}
|
|
@ -0,0 +1,3 @@
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>{{ "addon.mod_quiz.connectionerror" | translate }}</ion-label>
|
||||||
|
</ion-item>
|
|
@ -0,0 +1,7 @@
|
||||||
|
:host {
|
||||||
|
background-color: var(--red-light);
|
||||||
|
|
||||||
|
.item {
|
||||||
|
--background: var(--red-light);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a quiz entry page.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-connection-error',
|
||||||
|
templateUrl: 'connection-error.html',
|
||||||
|
styleUrls: ['connection-error.scss'],
|
||||||
|
})
|
||||||
|
export class AddonModQuizConnectionErrorComponent {
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,674 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreConstants } from '@/core/constants';
|
||||||
|
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
|
||||||
|
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
|
||||||
|
import { CoreCourse } from '@features/course/services/course';
|
||||||
|
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { IonContent } from '@ionic/angular';
|
||||||
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { Translate } from '@singletons';
|
||||||
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
|
import { AddonModQuizPrefetchHandler } from '../../services/handlers/prefetch';
|
||||||
|
import {
|
||||||
|
AddonModQuiz,
|
||||||
|
AddonModQuizAttemptFinishedData,
|
||||||
|
AddonModQuizAttemptWSData,
|
||||||
|
AddonModQuizCombinedReviewOptions,
|
||||||
|
AddonModQuizGetAttemptAccessInformationWSResponse,
|
||||||
|
AddonModQuizGetQuizAccessInformationWSResponse,
|
||||||
|
AddonModQuizGetUserBestGradeWSResponse,
|
||||||
|
AddonModQuizProvider,
|
||||||
|
} from '../../services/quiz';
|
||||||
|
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
|
||||||
|
import {
|
||||||
|
AddonModQuizAutoSyncData,
|
||||||
|
AddonModQuizSync,
|
||||||
|
AddonModQuizSyncProvider,
|
||||||
|
AddonModQuizSyncResult,
|
||||||
|
} from '../../services/quiz-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a quiz entry page.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-index',
|
||||||
|
templateUrl: 'addon-mod-quiz-index.html',
|
||||||
|
styleUrls: ['index.scss'],
|
||||||
|
})
|
||||||
|
export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
component = AddonModQuizProvider.COMPONENT;
|
||||||
|
moduleName = 'quiz';
|
||||||
|
quiz?: AddonModQuizQuizData; // The quiz.
|
||||||
|
now?: number; // Current time.
|
||||||
|
syncTime?: string; // Last synchronization time.
|
||||||
|
hasOffline = false; // Whether the quiz has offline data.
|
||||||
|
hasSupportedQuestions = false; // Whether the quiz has at least 1 supported question.
|
||||||
|
accessRules: string[] = []; // List of access rules of the quiz.
|
||||||
|
unsupportedRules: string[] = []; // List of unsupported access rules of the quiz.
|
||||||
|
unsupportedQuestions: string[] = []; // List of unsupported question types of the quiz.
|
||||||
|
behaviourSupported = false; // Whether the quiz behaviour is supported.
|
||||||
|
showResults = false; // Whether to show the result of the quiz (grade, etc.).
|
||||||
|
gradeOverridden = false; // Whether grade has been overridden.
|
||||||
|
gradebookFeedback?: string; // The feedback in the gradebook.
|
||||||
|
gradeResult?: string; // Message with the grade.
|
||||||
|
overallFeedback?: string; // The feedback for the grade.
|
||||||
|
buttonText?: string; // Text to display in the start/continue button.
|
||||||
|
preventMessages: string[] = []; // List of messages explaining why the quiz cannot be attempted.
|
||||||
|
showStatusSpinner = true; // Whether to show a spinner due to quiz status.
|
||||||
|
gradeMethodReadable?: string; // Grade method in a readable format.
|
||||||
|
showReviewColumn = false; // Whether to show the review column.
|
||||||
|
attempts: AddonModQuizAttempt[] = []; // List of attempts the user has made.
|
||||||
|
|
||||||
|
protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents.
|
||||||
|
protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED;
|
||||||
|
|
||||||
|
// protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown
|
||||||
|
protected autoReview?: AddonModQuizAttemptFinishedData; // Data to auto-review an attempt after finishing.
|
||||||
|
protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access info.
|
||||||
|
protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info.
|
||||||
|
protected moreAttempts = false; // Whether user can create/continue attempts.
|
||||||
|
protected options?: AddonModQuizCombinedReviewOptions; // Combined review options.
|
||||||
|
protected bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data.
|
||||||
|
protected gradebookData?: { grade?: number; feedback?: string }; // The gradebook grade and feedback.
|
||||||
|
protected overallStats = false; // Equivalent to overallstats in mod_quiz_view_object in Moodle.
|
||||||
|
protected finishedObserver?: CoreEventObserver; // It will observe attempt finished events.
|
||||||
|
protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted).
|
||||||
|
protected candidateQuiz?: AddonModQuizQuizData;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected content?: IonContent,
|
||||||
|
@Optional() courseContentsPage?: CoreCourseContentsPage,
|
||||||
|
) {
|
||||||
|
super('AddonModQuizIndexComponent', content, courseContentsPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
super.ngOnInit();
|
||||||
|
|
||||||
|
// Listen for attempt finished events.
|
||||||
|
this.finishedObserver = CoreEvents.on<AddonModQuizAttemptFinishedData>(
|
||||||
|
AddonModQuizProvider.ATTEMPT_FINISHED_EVENT,
|
||||||
|
(data) => {
|
||||||
|
// Go to review attempt if an attempt in this quiz was finished and synced.
|
||||||
|
if (this.quiz && data.quizId == this.quiz.id) {
|
||||||
|
this.autoReview = data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
this.siteId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.loadContent(false, true);
|
||||||
|
|
||||||
|
if (!this.quiz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AddonModQuiz.instance.logViewQuiz(this.quiz.id, this.quiz.name);
|
||||||
|
|
||||||
|
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt the quiz.
|
||||||
|
*/
|
||||||
|
async attemptQuiz(): Promise<void> {
|
||||||
|
if (this.showStatusSpinner || !this.quiz) {
|
||||||
|
// Quiz is being downloaded or synchronized, abort.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AddonModQuiz.instance.isQuizOffline(this.quiz)) {
|
||||||
|
// Quiz isn't offline, just open it.
|
||||||
|
return this.openQuiz();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiz supports offline, check if it needs to be downloaded.
|
||||||
|
// If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new.
|
||||||
|
const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED;
|
||||||
|
|
||||||
|
if (isDownloaded && CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) {
|
||||||
|
// Already downloaded, open it.
|
||||||
|
return this.openQuiz();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch the quiz.
|
||||||
|
this.showStatusSpinner = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AddonModQuizPrefetchHandler.instance.prefetch(this.module!, this.courseId, true);
|
||||||
|
|
||||||
|
// Success downloading, open quiz.
|
||||||
|
this.openQuiz();
|
||||||
|
} catch (error) {
|
||||||
|
if (this.hasOffline || (isDownloaded && !CoreCourseModulePrefetchDelegate.instance.canCheckUpdates())) {
|
||||||
|
// Error downloading but there is something offline, allow continuing it.
|
||||||
|
// If the site doesn't support check updates, continue too because we cannot tell if there's something new.
|
||||||
|
this.openQuiz();
|
||||||
|
} else {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.showStatusSpinner = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the quiz data.
|
||||||
|
*
|
||||||
|
* @param refresh If it's refreshing content.
|
||||||
|
* @param sync If it should try to sync.
|
||||||
|
* @param showErrors If show errors to the user of hide them.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
|
||||||
|
try {
|
||||||
|
// First get the quiz instance.
|
||||||
|
const quiz = await AddonModQuiz.instance.getQuiz(this.courseId!, this.module!.id);
|
||||||
|
|
||||||
|
this.gradeMethodReadable = AddonModQuiz.instance.getQuizGradeMethod(quiz.grademethod);
|
||||||
|
this.now = Date.now();
|
||||||
|
this.dataRetrieved.emit(quiz);
|
||||||
|
this.description = quiz.intro || this.description;
|
||||||
|
this.candidateQuiz = quiz;
|
||||||
|
|
||||||
|
// Try to get warnings from automatic sync.
|
||||||
|
const warnings = await AddonModQuizSync.instance.getSyncWarnings(quiz.id);
|
||||||
|
|
||||||
|
if (warnings?.length) {
|
||||||
|
// Show warnings and delete them so they aren't shown again.
|
||||||
|
CoreDomUtils.instance.showErrorModal(CoreTextUtils.instance.buildMessage(warnings));
|
||||||
|
|
||||||
|
await AddonModQuizSync.instance.setSyncWarnings(quiz.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AddonModQuiz.instance.isQuizOffline(quiz) && sync) {
|
||||||
|
// Try to sync the quiz.
|
||||||
|
try {
|
||||||
|
await this.syncActivity(showErrors);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors, keep getting data even if sync fails.
|
||||||
|
this.autoReview = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.autoReview = undefined;
|
||||||
|
this.showStatusSpinner = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AddonModQuiz.instance.isQuizOffline(quiz)) {
|
||||||
|
// Handle status.
|
||||||
|
this.setStatusListener();
|
||||||
|
|
||||||
|
// Get last synchronization time and check if sync button should be seen.
|
||||||
|
this.syncTime = await AddonModQuizSync.instance.getReadableSyncTime(quiz.id);
|
||||||
|
this.hasOffline = await AddonModQuizSync.instance.hasDataToSync(quiz.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get quiz access info.
|
||||||
|
this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, { cmId: this.module!.id });
|
||||||
|
|
||||||
|
this.showReviewColumn = this.quizAccessInfo.canreviewmyattempts;
|
||||||
|
this.accessRules = this.quizAccessInfo.accessrules;
|
||||||
|
this.unsupportedRules = AddonModQuiz.instance.getUnsupportedRules(this.quizAccessInfo.activerulenames);
|
||||||
|
|
||||||
|
if (quiz.preferredbehaviour) {
|
||||||
|
this.behaviourSupported = CoreQuestionBehaviourDelegate.instance.isBehaviourSupported(quiz.preferredbehaviour);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get question types in the quiz.
|
||||||
|
const types = await AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, { cmId: this.module!.id });
|
||||||
|
|
||||||
|
this.unsupportedQuestions = AddonModQuiz.instance.getUnsupportedQuestions(types);
|
||||||
|
this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1);
|
||||||
|
|
||||||
|
await this.getAttempts(quiz);
|
||||||
|
|
||||||
|
// Quiz is ready to be shown, move it to the variable that is displayed.
|
||||||
|
this.quiz = quiz;
|
||||||
|
} finally {
|
||||||
|
this.fillContextMenu(refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user attempts in the quiz and the result info.
|
||||||
|
*
|
||||||
|
* @param quiz Quiz instance.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async getAttempts(quiz: AddonModQuizQuizData): Promise<void> {
|
||||||
|
|
||||||
|
// Get access information of last attempt (it also works if no attempts made).
|
||||||
|
this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, { cmId: this.module!.id });
|
||||||
|
|
||||||
|
// Get attempts.
|
||||||
|
const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: this.module!.id });
|
||||||
|
|
||||||
|
this.attempts = await this.treatAttempts(quiz, attempts);
|
||||||
|
|
||||||
|
// Check if user can create/continue attempts.
|
||||||
|
if (this.attempts.length) {
|
||||||
|
const last = this.attempts[this.attempts.length - 1];
|
||||||
|
this.moreAttempts = !AddonModQuiz.instance.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished;
|
||||||
|
} else {
|
||||||
|
this.moreAttempts = !this.attemptAccessInfo.isfinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getButtonText(quiz);
|
||||||
|
|
||||||
|
await this.getResultInfo(quiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the text to show in the button. It also sets restriction messages if needed.
|
||||||
|
*
|
||||||
|
* @param quiz Quiz.
|
||||||
|
*/
|
||||||
|
protected getButtonText(quiz: AddonModQuizQuizData): void {
|
||||||
|
this.buttonText = '';
|
||||||
|
|
||||||
|
if (quiz.hasquestions !== 0) {
|
||||||
|
if (this.attempts.length && !AddonModQuiz.instance.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) {
|
||||||
|
// Last attempt is unfinished.
|
||||||
|
if (this.quizAccessInfo?.canattempt) {
|
||||||
|
this.buttonText = 'addon.mod_quiz.continueattemptquiz';
|
||||||
|
} else if (this.quizAccessInfo?.canpreview) {
|
||||||
|
this.buttonText = 'addon.mod_quiz.continuepreview';
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Last attempt is finished or no attempts.
|
||||||
|
if (this.quizAccessInfo?.canattempt) {
|
||||||
|
this.preventMessages = this.attemptAccessInfo?.preventnewattemptreasons || [];
|
||||||
|
if (!this.preventMessages.length) {
|
||||||
|
if (!this.attempts.length) {
|
||||||
|
this.buttonText = 'addon.mod_quiz.attemptquiznow';
|
||||||
|
} else {
|
||||||
|
this.buttonText = 'addon.mod_quiz.reattemptquiz';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.quizAccessInfo?.canpreview) {
|
||||||
|
this.buttonText = 'addon.mod_quiz.previewquiznow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.buttonText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// So far we think a button should be printed, check if they will be allowed to access it.
|
||||||
|
this.preventMessages = this.quizAccessInfo?.preventaccessreasons || [];
|
||||||
|
|
||||||
|
if (!this.moreAttempts) {
|
||||||
|
this.buttonText = '';
|
||||||
|
} else if (this.quizAccessInfo?.canattempt && this.preventMessages.length) {
|
||||||
|
this.buttonText = '';
|
||||||
|
} else if (!this.hasSupportedQuestions || this.unsupportedRules.length || !this.behaviourSupported) {
|
||||||
|
this.buttonText = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get result info to show.
|
||||||
|
*
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async getResultInfo(quiz: AddonModQuizQuizData): Promise<void> {
|
||||||
|
|
||||||
|
if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade ||
|
||||||
|
this.gradebookData?.grade === undefined) {
|
||||||
|
this.showResults = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedGradebookGrade = AddonModQuiz.instance.formatGrade(this.gradebookData.grade, quiz.decimalpoints);
|
||||||
|
const formattedBestGrade = AddonModQuiz.instance.formatGrade(this.bestGrade.grade, quiz.decimalpoints);
|
||||||
|
let gradeToShow = formattedGradebookGrade; // By default we show the grade in the gradebook.
|
||||||
|
|
||||||
|
this.showResults = true;
|
||||||
|
this.gradeOverridden = formattedGradebookGrade != formattedBestGrade;
|
||||||
|
this.gradebookFeedback = this.gradebookData.feedback;
|
||||||
|
|
||||||
|
if (this.bestGrade.grade! > this.gradebookData.grade && this.gradebookData.grade == quiz.grade) {
|
||||||
|
// The best grade is higher than the max grade for the quiz.
|
||||||
|
// We'll do like Moodle web and show the best grade instead of the gradebook grade.
|
||||||
|
this.gradeOverridden = false;
|
||||||
|
gradeToShow = formattedBestGrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overallStats) {
|
||||||
|
// Show the quiz grade. The message shown is different if the quiz is finished.
|
||||||
|
if (this.moreAttempts) {
|
||||||
|
this.gradeResult = Translate.instance.instant('addon.mod_quiz.gradesofar', { $a: {
|
||||||
|
method: this.gradeMethodReadable,
|
||||||
|
mygrade: gradeToShow,
|
||||||
|
quizgrade: quiz.gradeFormatted,
|
||||||
|
} });
|
||||||
|
} else {
|
||||||
|
const outOfShort = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: {
|
||||||
|
grade: gradeToShow,
|
||||||
|
maxgrade: quiz.gradeFormatted,
|
||||||
|
} });
|
||||||
|
|
||||||
|
this.gradeResult = Translate.instance.instant('addon.mod_quiz.yourfinalgradeis', { $a: outOfShort });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quiz.showFeedbackColumn) {
|
||||||
|
// Get the quiz overall feedback.
|
||||||
|
const response = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, this.gradebookData.grade, {
|
||||||
|
cmId: this.module!.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overallFeedback = response.feedbacktext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to review an attempt that has just been finished.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async goToAutoReview(): Promise<void> {
|
||||||
|
if (!this.autoReview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we go to auto review it means an attempt was finished. Check completion status.
|
||||||
|
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
|
||||||
|
|
||||||
|
// Verify that user can see the review.
|
||||||
|
const attemptId = this.autoReview.attemptId;
|
||||||
|
|
||||||
|
if (this.quizAccessInfo?.canreviewmyattempts) {
|
||||||
|
try {
|
||||||
|
await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id });
|
||||||
|
|
||||||
|
await CoreNavigator.instance.navigate(`review/${attemptId}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if sync has succeed from result sync data.
|
||||||
|
*
|
||||||
|
* @param result Data returned on the sync function.
|
||||||
|
* @return If suceed or not.
|
||||||
|
*/
|
||||||
|
protected hasSyncSucceed(result: AddonModQuizSyncResult): boolean {
|
||||||
|
if (result.attemptFinished) {
|
||||||
|
// An attempt was finished, check completion status.
|
||||||
|
CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the sync call isn't rejected it means the sync was successful.
|
||||||
|
return result.updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entered the page that contains the component.
|
||||||
|
*/
|
||||||
|
async ionViewDidEnter(): Promise<void> {
|
||||||
|
super.ionViewDidEnter();
|
||||||
|
|
||||||
|
if (!this.hasPlayed) {
|
||||||
|
this.autoReview = undefined;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasPlayed = false;
|
||||||
|
let promise = Promise.resolve();
|
||||||
|
|
||||||
|
// Update data when we come back from the player since the attempt status could have changed.
|
||||||
|
// Check if we need to go to review an attempt automatically.
|
||||||
|
if (this.autoReview && this.autoReview.synced) {
|
||||||
|
promise = this.goToAutoReview();
|
||||||
|
this.autoReview = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh data.
|
||||||
|
this.loaded = false;
|
||||||
|
this.refreshIcon = CoreConstants.ICON_LOADING;
|
||||||
|
this.syncIcon = CoreConstants.ICON_LOADING;
|
||||||
|
this.content?.scrollToTop();
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
await CoreUtils.instance.ignoreErrors(this.refreshContent(true));
|
||||||
|
|
||||||
|
this.loaded = true;
|
||||||
|
this.refreshIcon = CoreConstants.ICON_REFRESH;
|
||||||
|
this.syncIcon = CoreConstants.ICON_SYNC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User left the page that contains the component.
|
||||||
|
*/
|
||||||
|
ionViewDidLeave(): void {
|
||||||
|
super.ionViewDidLeave();
|
||||||
|
this.autoReview = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the invalidate content function.
|
||||||
|
*
|
||||||
|
* @return Resolved when done.
|
||||||
|
*/
|
||||||
|
protected async invalidateContent(): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId!));
|
||||||
|
|
||||||
|
if (this.quiz) {
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateQuizRequiredQtypes(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateAttemptAccessInformation(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateUserBestGradeForUser(this.quiz.id));
|
||||||
|
promises.push(AddonModQuiz.instance.invalidateGradeFromGradebook(this.courseId!));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares sync event data with current data to check if refresh content is needed.
|
||||||
|
*
|
||||||
|
* @param syncEventData Data receiven on sync observer.
|
||||||
|
* @return True if refresh is needed, false otherwise.
|
||||||
|
*/
|
||||||
|
protected isRefreshSyncNeeded(syncEventData: AddonModQuizAutoSyncData): boolean {
|
||||||
|
if (!this.courseId || !this.module) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncEventData.attemptFinished) {
|
||||||
|
// An attempt was finished, check completion status.
|
||||||
|
CoreCourse.instance.checkModuleCompletion(this.courseId, this.module.completiondata);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.quiz && syncEventData.quizId == this.quiz.id) {
|
||||||
|
this.content?.scrollToTop();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a quiz to attempt it.
|
||||||
|
*/
|
||||||
|
protected openQuiz(): void {
|
||||||
|
this.hasPlayed = true;
|
||||||
|
|
||||||
|
CoreNavigator.instance.navigate('player', {
|
||||||
|
params: {
|
||||||
|
moduleUrl: this.module?.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays some data based on the current status.
|
||||||
|
*
|
||||||
|
* @param status The current status.
|
||||||
|
* @param previousStatus The previous status. If not defined, there is no previous status.
|
||||||
|
*/
|
||||||
|
protected showStatus(status: string, previousStatus?: string): void {
|
||||||
|
this.showStatusSpinner = status == CoreConstants.DOWNLOADING;
|
||||||
|
|
||||||
|
if (status == CoreConstants.DOWNLOADED && previousStatus == CoreConstants.DOWNLOADING) {
|
||||||
|
// Quiz downloaded now, maybe a new attempt was created. Load content again.
|
||||||
|
this.loaded = false;
|
||||||
|
this.loadContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the sync of the activity.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected sync(): Promise<AddonModQuizSyncResult> {
|
||||||
|
return AddonModQuizSync.instance.syncQuiz(this.candidateQuiz!, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat user attempts.
|
||||||
|
*
|
||||||
|
* @param attempts The attempts to treat.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async treatAttempts(
|
||||||
|
quiz: AddonModQuizQuizData,
|
||||||
|
attempts: AddonModQuizAttemptWSData[],
|
||||||
|
): Promise<AddonModQuizAttempt[]> {
|
||||||
|
if (!attempts || !attempts.length) {
|
||||||
|
// There are no attempts to treat.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastFinished = AddonModQuiz.instance.getLastFinishedAttemptFromList(attempts);
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) {
|
||||||
|
// User just finished an attempt in offline and it seems it's been synced, since it's finished in online.
|
||||||
|
// Go to the review of this attempt if the user hasn't left this view.
|
||||||
|
if (!this.isDestroyed && this.isCurrentView) {
|
||||||
|
promises.push(this.goToAutoReview());
|
||||||
|
}
|
||||||
|
this.autoReview = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get combined review options.
|
||||||
|
promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, { cmId: this.module!.id }).then((options) => {
|
||||||
|
this.options = options;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get best grade.
|
||||||
|
promises.push(this.getQuizGrade(quiz));
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const grade = typeof this.gradebookData?.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade?.grade;
|
||||||
|
const quizGrade = AddonModQuiz.instance.formatGrade(grade, quiz.decimalpoints);
|
||||||
|
|
||||||
|
// Calculate data to construct the header of the attempts table.
|
||||||
|
AddonModQuizHelper.instance.setQuizCalculatedData(quiz, this.options!);
|
||||||
|
|
||||||
|
this.overallStats = !!lastFinished && this.options!.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX;
|
||||||
|
|
||||||
|
// Calculate data to show for each attempt.
|
||||||
|
const formattedAttempts = await Promise.all(attempts.map((attempt, index) => {
|
||||||
|
// Highlight the highest grade if appropriate.
|
||||||
|
const shouldHighlight = this.overallStats && quiz.grademethod == AddonModQuizProvider.GRADEHIGHEST &&
|
||||||
|
attempts.length > 1;
|
||||||
|
const isLast = index == attempts.length - 1;
|
||||||
|
|
||||||
|
return AddonModQuizHelper.instance.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return formattedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quiz grade data.
|
||||||
|
*
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async getQuizGrade(quiz: AddonModQuizQuizData): Promise<void> {
|
||||||
|
this.bestGrade = await AddonModQuiz.instance.getUserBestGrade(quiz.id, { cmId: this.module!.id });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get gradebook grade.
|
||||||
|
const data = await AddonModQuiz.instance.getGradeFromGradebook(this.courseId!, this.module!.id);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
this.gradebookData = {
|
||||||
|
grade: 'graderaw' in data ? data.graderaw : Number(data.grade),
|
||||||
|
feedback: data.feedback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to quiz best grade if failure or not found.
|
||||||
|
this.gradebookData = {
|
||||||
|
grade: this.bestGrade.grade,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to page to view the attempt details.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async viewAttempt(attemptId: number): Promise<void> {
|
||||||
|
CoreNavigator.instance.navigate(`attempt/${attemptId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being destroyed.
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
super.ngOnDestroy();
|
||||||
|
|
||||||
|
this.finishedObserver?.off();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>{{ 'addon.mod_quiz.quiznavigation' | translate }}</ion-title>
|
||||||
|
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||||
|
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content class="addon-mod_quiz-navigation-modal">
|
||||||
|
<nav>
|
||||||
|
<ion-list>
|
||||||
|
<!-- In player, show button to finish attempt. -->
|
||||||
|
<ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
|
||||||
|
<ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- In review we can toggle between all questions in same page or one page at a time. -->
|
||||||
|
<ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
|
||||||
|
<ion-label>
|
||||||
|
<span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
|
||||||
|
<span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item button class="ion-text-wrap {{question.stateClass}}" *ngFor="let question of navigation"
|
||||||
|
[ngClass]='{"core-selected-item": !summaryShown && currentPage == question.page}'
|
||||||
|
(click)="loadPage(question.page, question.slot)" detail="true">
|
||||||
|
|
||||||
|
<ion-label>
|
||||||
|
<span *ngIf="question.number">{{ 'core.question.questionno' | translate:{$a: question.number} }}</span>
|
||||||
|
<span *ngIf="!question.number">{{ 'core.question.information' | translate }}</span>
|
||||||
|
</ion-label>
|
||||||
|
|
||||||
|
<ion-icon *ngIf="!question.number" name="fas-info-circle" slot="end"></ion-icon>
|
||||||
|
<ion-icon *ngIf="question.stateClass == 'core-question-requiresgrading'" name="fas-question-circle"
|
||||||
|
[attr.aria-label]="question.status" slot="end">
|
||||||
|
</ion-icon>
|
||||||
|
<ion-icon *ngIf="question.stateClass == 'core-question-correct'" name="fas-check" color="success"
|
||||||
|
[attr.aria-label]="question.status" slot="end">
|
||||||
|
</ion-icon>
|
||||||
|
<ion-icon *ngIf="question.stateClass == 'core-question-partiallycorrect'" name="fas-check-square"
|
||||||
|
color="warning" [attr.aria-label]="question.status" slot="end">
|
||||||
|
</ion-icon>
|
||||||
|
<ion-icon *ngIf="question.stateClass == 'core-question-incorrect' ||
|
||||||
|
question.stateClass == 'core-question-notanswered'" name="fas-times" color="danger"
|
||||||
|
[attr.aria-label]="question.status" slot="end">
|
||||||
|
</ion-icon>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- In player, show button to finish attempt. -->
|
||||||
|
<ion-item button class="ion-text-wrap" *ngIf="!isReview" (click)="loadPage(-1)" detail="true">
|
||||||
|
<ion-label>{{ 'addon.mod_quiz.finishattemptdots' | translate }}</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- In review we can toggle between all questions in same page or one page at a time. -->
|
||||||
|
<ion-item button class="ion-text-wrap" *ngIf="isReview && numPages > 1" (click)="switchMode()" detail="true">
|
||||||
|
<ion-label>
|
||||||
|
<span *ngIf="!showAll">{{ 'addon.mod_quiz.showall' | translate }}</span>
|
||||||
|
<span *ngIf="showAll">{{ 'addon.mod_quiz.showeachpage' | translate }}</span>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</nav>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,76 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||||
|
import { ModalController } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal that renders the quiz navigation.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-quiz-navigation-modal',
|
||||||
|
templateUrl: 'navigation-modal.html',
|
||||||
|
})
|
||||||
|
export class AddonModQuizNavigationModalComponent {
|
||||||
|
|
||||||
|
static readonly CHANGE_PAGE = 1;
|
||||||
|
static readonly SWITCH_MODE = 2;
|
||||||
|
|
||||||
|
@Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt.
|
||||||
|
@Input() summaryShown?: boolean; // Whether summary is currently being shown.
|
||||||
|
@Input() currentPage?: number; // Current page.
|
||||||
|
@Input() isReview?: boolean; // Whether the user is reviewing the attempt.
|
||||||
|
@Input() numPages = 0; // Num of pages for review mode.
|
||||||
|
@Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close modal.
|
||||||
|
*/
|
||||||
|
closeModal(): void {
|
||||||
|
ModalController.instance.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a certain page.
|
||||||
|
*
|
||||||
|
* @param page The page to load.
|
||||||
|
* @param slot Slot of the question to scroll to.
|
||||||
|
*/
|
||||||
|
loadPage(page: number, slot?: number): void {
|
||||||
|
ModalController.instance.dismiss({
|
||||||
|
action: AddonModQuizNavigationModalComponent.CHANGE_PAGE,
|
||||||
|
page,
|
||||||
|
slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch mode in review.
|
||||||
|
*/
|
||||||
|
switchMode(): void {
|
||||||
|
ModalController.instance.dismiss({
|
||||||
|
action: AddonModQuizNavigationModalComponent.SWITCH_MODE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Question for the navigation menu with some calculated data.
|
||||||
|
*/
|
||||||
|
export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & {
|
||||||
|
stateClass?: string;
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>{{ title | translate }}</ion-title>
|
||||||
|
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||||
|
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content class="addon-mod_quiz-preflight-modal">
|
||||||
|
<core-loading [hideUntil]="loaded">
|
||||||
|
<form [formGroup]="preflightForm" (ngSubmit)="sendData($event)" #preflightFormEl>
|
||||||
|
<ion-list>
|
||||||
|
<!-- Access rules. -->
|
||||||
|
<ng-container *ngFor="let data of accessRulesData; let last = last">
|
||||||
|
<core-dynamic-component [component]="data.component" [data]="data.data">
|
||||||
|
<p class="ion-padding">Couldn't find the directive to render this access rule.</p>
|
||||||
|
</core-dynamic-component>
|
||||||
|
<ion-item-divider *ngIf="!last"><ion-label></ion-label></ion-item-divider>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ion-button expand="block" type="submit" class="ion-margin">
|
||||||
|
{{ title | translate }}
|
||||||
|
</ion-button>
|
||||||
|
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
|
||||||
|
<input type="submit" class="core-submit-hidden-enter" />
|
||||||
|
</ion-list>
|
||||||
|
</form>
|
||||||
|
</core-loading>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,144 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { IonContent } from '@ionic/angular';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { ModalController, Translate } from '@singletons';
|
||||||
|
import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal that renders the access rules for a quiz.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'page-addon-mod-quiz-preflight-modal',
|
||||||
|
templateUrl: 'preflight-modal.html',
|
||||||
|
})
|
||||||
|
export class AddonModQuizPreflightModalComponent implements OnInit {
|
||||||
|
|
||||||
|
@ViewChild(IonContent) content?: IonContent;
|
||||||
|
@ViewChild('preflightFormEl') formElement?: ElementRef;
|
||||||
|
|
||||||
|
@Input() title!: string;
|
||||||
|
@Input() quiz?: AddonModQuizQuizWSData;
|
||||||
|
@Input() attempt?: AddonModQuizAttemptWSData;
|
||||||
|
@Input() prefetch?: boolean;
|
||||||
|
@Input() siteId!: string;
|
||||||
|
@Input() rules!: string[];
|
||||||
|
|
||||||
|
preflightForm: FormGroup;
|
||||||
|
accessRulesData: { component: Type<unknown>; data: Record<string, unknown>}[] = []; // Component and data for each access rule.
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
formBuilder: FormBuilder,
|
||||||
|
protected elementRef: ElementRef,
|
||||||
|
) {
|
||||||
|
// Create an empty form group. The controls will be added by the access rules components.
|
||||||
|
this.preflightForm = formBuilder.group({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt');
|
||||||
|
this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId();
|
||||||
|
this.rules = this.rules || [];
|
||||||
|
|
||||||
|
if (!this.quiz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(this.rules.map(async (rule) => {
|
||||||
|
// Check if preflight is required for rule and, if so, get the component to render it.
|
||||||
|
const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule(
|
||||||
|
rule,
|
||||||
|
this.quiz!,
|
||||||
|
this.attempt,
|
||||||
|
this.prefetch,
|
||||||
|
this.siteId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!required) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule);
|
||||||
|
if (!component) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accessRulesData.push({
|
||||||
|
component: component,
|
||||||
|
data: {
|
||||||
|
rule: rule,
|
||||||
|
quiz: this.quiz,
|
||||||
|
attempt: this.attempt,
|
||||||
|
prefetch: this.prefetch,
|
||||||
|
form: this.preflightForm,
|
||||||
|
siteId: this.siteId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules');
|
||||||
|
} finally {
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the data is valid and send it back.
|
||||||
|
*
|
||||||
|
* @param e Event.
|
||||||
|
*/
|
||||||
|
sendData(e: Event): void {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!this.preflightForm.valid) {
|
||||||
|
// Form not valid. Scroll to the first element with errors.
|
||||||
|
const hasScrolled = CoreDomUtils.instance.scrollToInputError(
|
||||||
|
this.elementRef.nativeElement,
|
||||||
|
this.content,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasScrolled) {
|
||||||
|
// Input not found, show an error modal.
|
||||||
|
CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId);
|
||||||
|
|
||||||
|
ModalController.instance.dismiss(this.preflightForm.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close modal.
|
||||||
|
*/
|
||||||
|
closeModal(): void {
|
||||||
|
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId);
|
||||||
|
|
||||||
|
ModalController.instance.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
{
|
||||||
|
"answercolon": "Answer:",
|
||||||
|
"attemptfirst": "First attempt",
|
||||||
|
"attemptlast": "Last attempt",
|
||||||
|
"attemptnumber": "Attempt",
|
||||||
|
"attemptquiznow": "Attempt quiz now",
|
||||||
|
"attemptstate": "State",
|
||||||
|
"canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:",
|
||||||
|
"cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:",
|
||||||
|
"clearchoice": "Clear my choice",
|
||||||
|
"comment": "Comment",
|
||||||
|
"completedon": "Completed on",
|
||||||
|
"confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.",
|
||||||
|
"confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.",
|
||||||
|
"confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?",
|
||||||
|
"confirmstart": "Your attempt will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?",
|
||||||
|
"confirmstartheader": "Time limit",
|
||||||
|
"connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.",
|
||||||
|
"continueattemptquiz": "Continue the last attempt",
|
||||||
|
"continuepreview": "Continue the last preview",
|
||||||
|
"errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:",
|
||||||
|
"errordownloading": "Error downloading required data.",
|
||||||
|
"errorgetattempt": "Error getting attempt data.",
|
||||||
|
"errorgetquestions": "Error getting questions.",
|
||||||
|
"errorgetquiz": "Error getting quiz data.",
|
||||||
|
"errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.",
|
||||||
|
"errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:",
|
||||||
|
"errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:",
|
||||||
|
"errorsaveattempt": "An error occurred while saving the attempt data.",
|
||||||
|
"feedback": "Feedback",
|
||||||
|
"finishattemptdots": "Finish attempt...",
|
||||||
|
"finishnotsynced": "Finished but not synchronised",
|
||||||
|
"grade": "Grade",
|
||||||
|
"gradeaverage": "Average grade",
|
||||||
|
"gradehighest": "Highest grade",
|
||||||
|
"grademethod": "Grading method",
|
||||||
|
"gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.",
|
||||||
|
"marks": "Marks",
|
||||||
|
"modulenameplural": "Quizzes",
|
||||||
|
"mustbesubmittedby": "This attempt must be submitted by {{$a}}.",
|
||||||
|
"noquestions": "No questions have been added yet",
|
||||||
|
"noreviewattempt": "You are not allowed to review this attempt.",
|
||||||
|
"notyetgraded": "Not yet graded",
|
||||||
|
"opentoc": "Open navigation popover",
|
||||||
|
"outof": "{{$a.grade}} out of {{$a.maxgrade}}",
|
||||||
|
"outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)",
|
||||||
|
"outofshort": "{{$a.grade}}/{{$a.maxgrade}}",
|
||||||
|
"overallfeedback": "Overall feedback",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewquiznow": "Preview quiz now",
|
||||||
|
"question": "Question",
|
||||||
|
"quiznavigation": "Quiz navigation",
|
||||||
|
"quizpassword": "Quiz password",
|
||||||
|
"reattemptquiz": "Re-attempt quiz",
|
||||||
|
"requirepasswordmessage": "To attempt this quiz you need to know the quiz password",
|
||||||
|
"returnattempt": "Return to attempt",
|
||||||
|
"review": "Review",
|
||||||
|
"reviewofattempt": "Review of attempt {{$a}}",
|
||||||
|
"reviewofpreview": "Review of preview",
|
||||||
|
"showall": "Show all questions on one page",
|
||||||
|
"showeachpage": "Show one page at a time",
|
||||||
|
"startattempt": "Start attempt",
|
||||||
|
"startedon": "Started on",
|
||||||
|
"stateabandoned": "Never submitted",
|
||||||
|
"statefinished": "Finished",
|
||||||
|
"statefinisheddetails": "Submitted {{$a}}",
|
||||||
|
"stateinprogress": "In progress",
|
||||||
|
"stateoverdue": "Overdue",
|
||||||
|
"stateoverduedetails": "Must be submitted by {{$a}}",
|
||||||
|
"status": "Status",
|
||||||
|
"submitallandfinish": "Submit all and finish",
|
||||||
|
"summaryofattempt": "Summary of attempt",
|
||||||
|
"summaryofattempts": "Summary of your previous attempts",
|
||||||
|
"timeleft": "Time left",
|
||||||
|
"timetaken": "Time taken",
|
||||||
|
"warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.",
|
||||||
|
"warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.",
|
||||||
|
"warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.",
|
||||||
|
"warningquestionsnotsupported": "This quiz contains questions not supported by the app:",
|
||||||
|
"yourfinalgradeis": "Your final grade for this quiz is {{$a}}."
|
||||||
|
}
|
|
@ -0,0 +1,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>
|
|
@ -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 {}
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -0,0 +1,6 @@
|
||||||
|
:host {
|
||||||
|
.addon-mod_quiz-question-note p {
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -0,0 +1,326 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that all access rules handlers must implement.
|
||||||
|
*/
|
||||||
|
export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the rule the handler supports. E.g. 'password'.
|
||||||
|
*/
|
||||||
|
ruleName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rule requires a preflight check when prefetch/start/continue an attempt.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Whether the rule requires a preflight check.
|
||||||
|
*/
|
||||||
|
isPreflightCheckRequired(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add preflight data that doesn't require user interaction. The data should be added to the preflightData param.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param preflightData Object where to add the preflight data.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
getFixedPreflightData?(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Component to use to display the access rule preflight.
|
||||||
|
* Implement this if your access rule requires a preflight check with user interaction.
|
||||||
|
* It's recommended to return the class of the component, but you can also return an instance of the component.
|
||||||
|
*
|
||||||
|
* @return The component (or promise resolved with component) to use, undefined if not found.
|
||||||
|
*/
|
||||||
|
getPreflightComponent?(): Type<unknown> | Promise<Type<unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when the preflight check has passed. This is a chance to record that fact in some way.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
notifyPreflightCheckPassed?(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when the preflight check fails. This is a chance to record that fact in some way.
|
||||||
|
*
|
||||||
|
* @param quiz The quiz the rule belongs to.
|
||||||
|
* @param attempt The attempt started/continued.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done if async, void if it's synchronous.
|
||||||
|
*/
|
||||||
|
notifyPreflightCheckFailed?(
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the time left of an attempt should be displayed.
|
||||||
|
*
|
||||||
|
* @param attempt The attempt.
|
||||||
|
* @param endTime The attempt end time (in seconds).
|
||||||
|
* @param timeNow The current time in seconds.
|
||||||
|
* @return Whether it should be displayed.
|
||||||
|
*/
|
||||||
|
shouldShowTimeLeft?(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate to register access rules for quiz module.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModQuizAccessRuleDelegateService extends CoreDelegate<AddonModQuizAccessRuleHandler> {
|
||||||
|
|
||||||
|
protected handlerNameProperty = 'ruleName';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('AddonModQuizAccessRulesDelegate', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the handler for a certain rule.
|
||||||
|
*
|
||||||
|
* @param ruleName Name of the access rule.
|
||||||
|
* @return Handler. Undefined if no handler found for the rule.
|
||||||
|
*/
|
||||||
|
getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler {
|
||||||
|
return this.getHandler(ruleName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of rules, get some fixed preflight data (data that doesn't require user interaction).
|
||||||
|
*
|
||||||
|
* @param rules List of active rules names.
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @param preflightData Object where to store the preflight data.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when all the data has been gathered.
|
||||||
|
*/
|
||||||
|
async getFixedPreflightData(
|
||||||
|
rules: string[],
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
rules = rules || [];
|
||||||
|
|
||||||
|
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
|
||||||
|
await this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]);
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Component to use to display the access rule preflight.
|
||||||
|
*
|
||||||
|
* @param rule Rule.
|
||||||
|
* @return Promise resolved with the component to use, undefined if not found.
|
||||||
|
*/
|
||||||
|
getPreflightComponent(rule: string): Promise<Type<unknown> | undefined> {
|
||||||
|
return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an access rule is supported.
|
||||||
|
*
|
||||||
|
* @param ruleName Name of the rule.
|
||||||
|
* @return Whether it's supported.
|
||||||
|
*/
|
||||||
|
isAccessRuleSupported(ruleName: string): boolean {
|
||||||
|
return this.hasHandler(ruleName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of rules, check if preflight check is required.
|
||||||
|
*
|
||||||
|
* @param rules List of active rules names.
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with boolean: whether it's required.
|
||||||
|
*/
|
||||||
|
async isPreflightCheckRequired(
|
||||||
|
rules: string[],
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
rules = rules || [];
|
||||||
|
let isRequired = false;
|
||||||
|
|
||||||
|
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
|
||||||
|
const ruleRequired = await this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId);
|
||||||
|
|
||||||
|
isRequired = isRequired || ruleRequired;
|
||||||
|
})));
|
||||||
|
|
||||||
|
return isRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if preflight check is required for a certain rule.
|
||||||
|
*
|
||||||
|
* @param rule Rule name.
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @param attempt The attempt started/continued. If not supplied, user is starting a new attempt.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with boolean: whether it's required.
|
||||||
|
*/
|
||||||
|
async isPreflightCheckRequiredForRule(
|
||||||
|
rule: string,
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt?: AddonModQuizAttemptWSData,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const isRequired = await this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]);
|
||||||
|
|
||||||
|
return !!isRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all rules that the preflight check has passed.
|
||||||
|
*
|
||||||
|
* @param rules List of active rules names.
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @param attempt Attempt.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async notifyPreflightCheckPassed(
|
||||||
|
rules: string[],
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
rules = rules || [];
|
||||||
|
|
||||||
|
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
|
||||||
|
await this.executeFunctionOnEnabled(
|
||||||
|
rule,
|
||||||
|
'notifyPreflightCheckPassed',
|
||||||
|
[quiz, attempt, preflightData, prefetch, siteId],
|
||||||
|
);
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all rules that the preflight check has failed.
|
||||||
|
*
|
||||||
|
* @param rules List of active rules names.
|
||||||
|
* @param quiz Quiz.
|
||||||
|
* @param attempt Attempt.
|
||||||
|
* @param preflightData Preflight data gathered.
|
||||||
|
* @param prefetch Whether the user is prefetching the quiz.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async notifyPreflightCheckFailed(
|
||||||
|
rules: string[],
|
||||||
|
quiz: AddonModQuizQuizWSData,
|
||||||
|
attempt: AddonModQuizAttemptWSData | undefined,
|
||||||
|
preflightData: Record<string, string>,
|
||||||
|
prefetch?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
rules = rules || [];
|
||||||
|
|
||||||
|
await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => {
|
||||||
|
await this.executeFunctionOnEnabled(
|
||||||
|
rule,
|
||||||
|
'notifyPreflightCheckFailed',
|
||||||
|
[quiz, attempt, preflightData, prefetch, siteId],
|
||||||
|
);
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the time left of an attempt should be displayed.
|
||||||
|
*
|
||||||
|
* @param rules List of active rules names.
|
||||||
|
* @param attempt The attempt.
|
||||||
|
* @param endTime The attempt end time (in seconds).
|
||||||
|
* @param timeNow The current time in seconds.
|
||||||
|
* @return Whether it should be displayed.
|
||||||
|
*/
|
||||||
|
shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean {
|
||||||
|
rules = rules || [];
|
||||||
|
|
||||||
|
for (const i in rules) {
|
||||||
|
const rule = rules[i];
|
||||||
|
|
||||||
|
if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonModQuizAccessRuleDelegate extends makeSingleton(AddonModQuizAccessRuleDelegateService) {}
|
|
@ -0,0 +1,83 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreSiteSchema } from '@services/sites';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database variables for AddonModQuizOfflineProvider.
|
||||||
|
*/
|
||||||
|
export const ATTEMPTS_TABLE_NAME = 'addon_mod_quiz_attempts';
|
||||||
|
export const SITE_SCHEMA: CoreSiteSchema = {
|
||||||
|
name: 'AddonModQuizOfflineProvider',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: ATTEMPTS_TABLE_NAME,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id', // Attempt ID.
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'attempt', // Attempt number.
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'courseid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quizid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'currentpage',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timecreated',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timemodified',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'finished',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiz attempt.
|
||||||
|
*/
|
||||||
|
export type AddonModQuizAttemptDBRecord = {
|
||||||
|
id: number;
|
||||||
|
attempt: number;
|
||||||
|
courseid: number;
|
||||||
|
userid: number;
|
||||||
|
quizid: number;
|
||||||
|
currentpage?: number;
|
||||||
|
timecreated: number;
|
||||||
|
timemodified: number;
|
||||||
|
finished: number;
|
||||||
|
};
|
|
@ -0,0 +1,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) {}
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}>;
|
|
@ -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
|
@ -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 {}
|
|
@ -0,0 +1,56 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||||
|
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support adaptive question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourAdaptiveHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourAdaptive';
|
||||||
|
type = 'adaptive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a question behaviour.
|
||||||
|
* If the behaviour requires a submit button, it should add it to question.behaviourButtons.
|
||||||
|
* If the behaviour requires to show some extra data, it should return the components to render it.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @return Components (or promise resolved with components) to render some extra data in the question
|
||||||
|
* (e.g. certainty options). Don't return anything if no extra data is required.
|
||||||
|
*/
|
||||||
|
handleQuestion(question: CoreQuestionQuestionParsed): void {
|
||||||
|
// Just extract the button, it doesn't need any specific component.
|
||||||
|
CoreQuestionHelper.instance.extractQbehaviourButtons(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourAdaptiveHandler extends makeSingleton(AddonQbehaviourAdaptiveHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { AddonQbehaviourAdaptiveNoPenaltyHandler } from './services/handlers/adaptivenopenalty';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveNoPenaltyHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonQbehaviourAdaptiveNoPenaltyModule {}
|
|
@ -0,0 +1,56 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||||
|
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support adaptive no penalty question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourAdaptiveNoPenaltyHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourAdaptiveNoPenalty';
|
||||||
|
type = 'adaptivenopenalty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a question behaviour.
|
||||||
|
* If the behaviour requires a submit button, it should add it to question.behaviourButtons.
|
||||||
|
* If the behaviour requires to show some extra data, it should return the components to render it.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @return Components (or promise resolved with components) to render some extra data in the question
|
||||||
|
* (e.g. certainty options). Don't return anything if no extra data is required.
|
||||||
|
*/
|
||||||
|
handleQuestion(question: CoreQuestionQuestionParsed): void {
|
||||||
|
// Just extract the button, it doesn't need any specific component.
|
||||||
|
CoreQuestionHelper.instance.extractQbehaviourButtons(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourAdaptiveNoPenaltyHandler extends makeSingleton(AddonQbehaviourAdaptiveNoPenaltyHandlerService) {}
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div *ngIf="question && question.behaviourCertaintyOptions && question.behaviourCertaintyOptions.length">
|
||||||
|
<ion-item class="ion-text-wrap addon-qbehaviour-deferredcbm-certainty-title" >
|
||||||
|
<ion-label><p>{{ 'core.question.certainty' | translate }}</p></ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-radio-group [(ngModel)]="question.behaviourCertaintySelected" [name]="question.behaviourCertaintyOptions[0].name">
|
||||||
|
<ion-item class="ion-text-wrap" *ngFor="let option of question.behaviourCertaintyOptions">
|
||||||
|
<ion-label>{{ option.text }}</ion-label>
|
||||||
|
<ion-radio slot="end" id="{{option.id}}" [value]="option.value" [disabled]="option.disabled"></ion-radio>
|
||||||
|
</ion-item>
|
||||||
|
</ion-radio-group>
|
||||||
|
|
||||||
|
<!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
|
||||||
|
<input type="hidden" [ngModel]="question.behaviourCertaintySelected" [attr.name]="question.behaviourCertaintyOptions[0].name">
|
||||||
|
</div>
|
|
@ -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.
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { AddonQbehaviourDeferredCBMComponent } from './component/deferredcbm';
|
||||||
|
import { AddonQbehaviourDeferredCBMHandler } from './services/handlers/deferredcbm';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonQbehaviourDeferredCBMComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredCBMHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonQbehaviourDeferredCBMComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonQbehaviourDeferredCBMModule {}
|
|
@ -0,0 +1,152 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { AddonQbehaviourDeferredFeedbackHandler } from '@addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback';
|
||||||
|
import { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers, CoreQuestionState } from '@features/question/services/question';
|
||||||
|
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||||
|
import { AddonQbehaviourDeferredCBMComponent } from '../../component/deferredcbm';
|
||||||
|
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support deferred CBM question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourDeferredCBMHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourDeferredCBM';
|
||||||
|
type = 'deferredcbm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine a question new state based on its answer(s).
|
||||||
|
*
|
||||||
|
* @param component Component the question belongs to.
|
||||||
|
* @param attemptId Attempt ID the question belongs to.
|
||||||
|
* @param question The question.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return New state (or promise resolved with state).
|
||||||
|
*/
|
||||||
|
determineNewState(
|
||||||
|
component: string,
|
||||||
|
attemptId: number,
|
||||||
|
question: CoreQuestionQuestionWithAnswers,
|
||||||
|
componentId: string | number,
|
||||||
|
siteId?: string,
|
||||||
|
): CoreQuestionState | Promise<CoreQuestionState> {
|
||||||
|
// Depends on deferredfeedback.
|
||||||
|
return AddonQbehaviourDeferredFeedbackHandler.instance.determineNewStateDeferred(
|
||||||
|
component,
|
||||||
|
attemptId,
|
||||||
|
question,
|
||||||
|
componentId,
|
||||||
|
siteId,
|
||||||
|
this.isCompleteResponse.bind(this),
|
||||||
|
this.isSameResponse.bind(this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a question behaviour.
|
||||||
|
* If the behaviour requires a submit button, it should add it to question.behaviourButtons.
|
||||||
|
* If the behaviour requires to show some extra data, it should return the components to render it.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @return Components (or promise resolved with components) to render some extra data in the question
|
||||||
|
* (e.g. certainty options). Don't return anything if no extra data is required.
|
||||||
|
*/
|
||||||
|
handleQuestion(question: CoreQuestionQuestionParsed): void | Type<unknown>[] {
|
||||||
|
if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) {
|
||||||
|
return [AddonQbehaviourDeferredCBMComponent];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a response is complete.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @param answers Object with the question answers (without prefix).
|
||||||
|
* @param component The component the question is related to.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||||
|
*/
|
||||||
|
protected isCompleteResponse(
|
||||||
|
question: CoreQuestionQuestionParsed,
|
||||||
|
answers: CoreQuestionsAnswers,
|
||||||
|
component: string,
|
||||||
|
componentId: string | number,
|
||||||
|
): number {
|
||||||
|
// First check if the question answer is complete.
|
||||||
|
const complete = CoreQuestionDelegate.instance.isCompleteResponse(question, answers, component, componentId);
|
||||||
|
if (complete > 0) {
|
||||||
|
// Answer is complete, check the user answered CBM too.
|
||||||
|
return answers['-certainty'] ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two responses are the same.
|
||||||
|
*
|
||||||
|
* @param question Question.
|
||||||
|
* @param prevAnswers Object with the previous question answers.
|
||||||
|
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||||
|
* @param newAnswers Object with the new question answers.
|
||||||
|
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||||
|
* @param component The component the question is related to.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @return Whether they're the same.
|
||||||
|
*/
|
||||||
|
protected isSameResponse(
|
||||||
|
question: CoreQuestionQuestionParsed,
|
||||||
|
prevAnswers: CoreQuestionsAnswers,
|
||||||
|
prevBasicAnswers: CoreQuestionsAnswers,
|
||||||
|
newAnswers: CoreQuestionsAnswers,
|
||||||
|
newBasicAnswers: CoreQuestionsAnswers,
|
||||||
|
component: string,
|
||||||
|
componentId: string | number,
|
||||||
|
): boolean {
|
||||||
|
// First check if the question answer is the same.
|
||||||
|
const sameResponse = CoreQuestionDelegate.instance.isSameResponse(
|
||||||
|
question,
|
||||||
|
prevBasicAnswers,
|
||||||
|
newBasicAnswers,
|
||||||
|
component,
|
||||||
|
componentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sameResponse) {
|
||||||
|
// Same response, check the CBM is the same too.
|
||||||
|
return prevAnswers['-certainty'] == newAnswers['-certainty'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sameResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourDeferredCBMHandler extends makeSingleton(AddonQbehaviourDeferredCBMHandlerService) {}
|
|
@ -0,0 +1,33 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { AddonQbehaviourDeferredFeedbackHandler } from './services/handlers/deferredfeedback';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredFeedbackHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonQbehaviourDeferredFeedbackModule {}
|
|
@ -0,0 +1,216 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { CoreQuestionDBRecord } from '@features/question/services/database/question';
|
||||||
|
import {
|
||||||
|
CoreQuestion,
|
||||||
|
CoreQuestionQuestionParsed,
|
||||||
|
CoreQuestionsAnswers,
|
||||||
|
CoreQuestionState,
|
||||||
|
} from '@features/question/services/question';
|
||||||
|
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support deferred feedback question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourDeferredFeedbackHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourDeferredFeedback';
|
||||||
|
type = 'deferredfeedback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine a question new state based on its answer(s).
|
||||||
|
*
|
||||||
|
* @param component Component the question belongs to.
|
||||||
|
* @param attemptId Attempt ID the question belongs to.
|
||||||
|
* @param question The question.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return New state (or promise resolved with state).
|
||||||
|
*/
|
||||||
|
determineNewState(
|
||||||
|
component: string,
|
||||||
|
attemptId: number,
|
||||||
|
question: CoreQuestionQuestionWithAnswers,
|
||||||
|
componentId: string | number,
|
||||||
|
siteId?: string,
|
||||||
|
): CoreQuestionState | Promise<CoreQuestionState> {
|
||||||
|
return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine a question new state based on its answer(s) for deferred question behaviour.
|
||||||
|
*
|
||||||
|
* @param component Component the question belongs to.
|
||||||
|
* @param attemptId Attempt ID the question belongs to.
|
||||||
|
* @param question The question.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param isCompleteFn Function to override the default isCompleteResponse check.
|
||||||
|
* @param isSameFn Function to override the default isSameResponse check.
|
||||||
|
* @return Promise resolved with state.
|
||||||
|
*/
|
||||||
|
async determineNewStateDeferred(
|
||||||
|
component: string,
|
||||||
|
attemptId: number,
|
||||||
|
question: CoreQuestionQuestionWithAnswers,
|
||||||
|
componentId: string | number,
|
||||||
|
siteId?: string,
|
||||||
|
isCompleteFn?: isCompleteResponseFunction,
|
||||||
|
isSameFn?: isSameResponseFunction,
|
||||||
|
): Promise<CoreQuestionState> {
|
||||||
|
|
||||||
|
// Check if we have local data for the question.
|
||||||
|
let dbQuestion: CoreQuestionDBRecord | CoreQuestionQuestionWithAnswers = question;
|
||||||
|
try {
|
||||||
|
dbQuestion = await CoreQuestion.instance.getQuestion(component, attemptId, question.slot, siteId);
|
||||||
|
} catch (error) {
|
||||||
|
// No entry found, use the original data.
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = CoreQuestion.instance.getState(dbQuestion.state);
|
||||||
|
|
||||||
|
if (state.finished || !state.active) {
|
||||||
|
// Question is finished, it cannot change.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBasicAnswers = CoreQuestion.instance.getBasicAnswers(question.answers || {});
|
||||||
|
|
||||||
|
if (dbQuestion.state) {
|
||||||
|
// Question already has a state stored. Check if answer has changed.
|
||||||
|
const prevAnswersList = await CoreQuestion.instance.getQuestionAnswers(
|
||||||
|
component,
|
||||||
|
attemptId,
|
||||||
|
question.slot,
|
||||||
|
false,
|
||||||
|
siteId,
|
||||||
|
);
|
||||||
|
const prevAnswers = CoreQuestion.instance.convertAnswersArrayToObject(prevAnswersList, true);
|
||||||
|
const prevBasicAnswers = CoreQuestion.instance.getBasicAnswers(prevAnswers);
|
||||||
|
|
||||||
|
// If answers haven't changed the state is the same.
|
||||||
|
let sameResponse = false;
|
||||||
|
|
||||||
|
if (isSameFn) {
|
||||||
|
sameResponse = isSameFn(
|
||||||
|
question,
|
||||||
|
prevAnswers,
|
||||||
|
prevBasicAnswers,
|
||||||
|
question.answers || {},
|
||||||
|
newBasicAnswers,
|
||||||
|
component,
|
||||||
|
componentId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sameResponse = CoreQuestionDelegate.instance.isSameResponse(
|
||||||
|
question,
|
||||||
|
prevBasicAnswers,
|
||||||
|
newBasicAnswers,
|
||||||
|
component,
|
||||||
|
componentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameResponse) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answers have changed. Now check if the response is complete and calculate the new state.
|
||||||
|
let complete: number;
|
||||||
|
let newState: string;
|
||||||
|
|
||||||
|
if (isCompleteFn) {
|
||||||
|
// Pass all the answers since some behaviours might need the extra data.
|
||||||
|
complete = isCompleteFn(question, question.answers || {}, component, componentId);
|
||||||
|
} else {
|
||||||
|
// Only pass the basic answers since questions should be independent of extra data.
|
||||||
|
complete = CoreQuestionDelegate.instance.isCompleteResponse(question, newBasicAnswers, component, componentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete < 0) {
|
||||||
|
newState = 'cannotdeterminestatus';
|
||||||
|
} else if (complete > 0) {
|
||||||
|
newState = 'complete';
|
||||||
|
} else {
|
||||||
|
const gradable = CoreQuestionDelegate.instance.isGradableResponse(question, newBasicAnswers, component, componentId);
|
||||||
|
if (gradable < 0) {
|
||||||
|
newState = 'cannotdeterminestatus';
|
||||||
|
} else if (gradable > 0) {
|
||||||
|
newState = 'invalid';
|
||||||
|
} else {
|
||||||
|
newState = 'todo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreQuestion.instance.getState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourDeferredFeedbackHandler extends makeSingleton(AddonQbehaviourDeferredFeedbackHandlerService) {}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a response is complete.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @param answers Object with the question answers (without prefix).
|
||||||
|
* @param component The component the question is related to.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
|
||||||
|
*/
|
||||||
|
export type isCompleteResponseFunction = (
|
||||||
|
question: CoreQuestionQuestionParsed,
|
||||||
|
answers: CoreQuestionsAnswers,
|
||||||
|
component: string,
|
||||||
|
componentId: string | number,
|
||||||
|
) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two responses are the same.
|
||||||
|
*
|
||||||
|
* @param question Question.
|
||||||
|
* @param prevAnswers Object with the previous question answers.
|
||||||
|
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||||
|
* @param newAnswers Object with the new question answers.
|
||||||
|
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
|
||||||
|
* @param component The component the question is related to.
|
||||||
|
* @param componentId Component ID.
|
||||||
|
* @return Whether they're the same.
|
||||||
|
*/
|
||||||
|
export type isSameResponseFunction = (
|
||||||
|
question: CoreQuestionQuestionParsed,
|
||||||
|
prevAnswers: CoreQuestionsAnswers,
|
||||||
|
prevBasicAnswers: CoreQuestionsAnswers,
|
||||||
|
newAnswers: CoreQuestionsAnswers,
|
||||||
|
newBasicAnswers: CoreQuestionsAnswers,
|
||||||
|
component: string,
|
||||||
|
componentId: string | number,
|
||||||
|
) => boolean;
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { AddonQbehaviourImmediateCBMHandler } from './services/handlers/immediatecbm';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateCBMHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonQbehaviourImmediateCBMModule {}
|
|
@ -0,0 +1,61 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { AddonQbehaviourDeferredCBMComponent } from '@addons/qbehaviour/deferredcbm/component/deferredcbm';
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||||
|
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support immediate CBM question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourImmediateCBMHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourImmediateCBM';
|
||||||
|
type = 'immediatecbm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a question behaviour.
|
||||||
|
* If the behaviour requires a submit button, it should add it to question.behaviourButtons.
|
||||||
|
* If the behaviour requires to show some extra data, it should return the components to render it.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @return Components (or promise resolved with components) to render some extra data in the question
|
||||||
|
* (e.g. certainty options). Don't return anything if no extra data is required.
|
||||||
|
*/
|
||||||
|
handleQuestion(question: CoreQuestionQuestionParsed): void | Type<unknown>[] {
|
||||||
|
CoreQuestionHelper.instance.extractQbehaviourButtons(question);
|
||||||
|
|
||||||
|
if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) {
|
||||||
|
// Depends on deferredcbm.
|
||||||
|
return [AddonQbehaviourDeferredCBMComponent];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourImmediateCBMHandler extends makeSingleton(AddonQbehaviourImmediateCBMHandlerService) {}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { AddonQbehaviourImmediateFeedbackHandler } from './services/handlers/immediatefeedback';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateFeedbackHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonQbehaviourImmediateFeedbackModule {}
|
|
@ -0,0 +1,58 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate';
|
||||||
|
import { CoreQuestionQuestionParsed } from '@features/question/services/question';
|
||||||
|
import { CoreQuestionHelper } from '@features/question/services/question-helper';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support immediate feedback question behaviour.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonQbehaviourImmediateFeedbackHandlerService implements CoreQuestionBehaviourHandler {
|
||||||
|
|
||||||
|
name = 'AddonQbehaviourImmediateFeedback';
|
||||||
|
type = 'immediatefeedback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a question behaviour.
|
||||||
|
* If the behaviour requires a submit button, it should add it to question.behaviourButtons.
|
||||||
|
* If the behaviour requires to show some extra data, it should return the components to render it.
|
||||||
|
*
|
||||||
|
* @param question The question.
|
||||||
|
* @return Components (or promise resolved with components) to render some extra data in the question
|
||||||
|
* (e.g. certainty options). Don't return anything if no extra data is required.
|
||||||
|
*/
|
||||||
|
handleQuestion(question: CoreQuestionQuestionParsed): void {
|
||||||
|
// Just extract the button, it doesn't need any specific component.
|
||||||
|
CoreQuestionHelper.instance.extractQbehaviourButtons(question);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return True or promise resolved with true if enabled.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddonQbehaviourImmediateFeedbackHandler extends makeSingleton(AddonQbehaviourImmediateFeedbackHandlerService) {}
|
|
@ -0,0 +1,2 @@
|
||||||
|
<input *ngIf="question && question.behaviourSeenInput" type="hidden" [name]="question.behaviourSeenInput.name"
|
||||||
|
[value]="question.behaviourSeenInput.value" >
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue