Merge pull request #4026 from dpalou/MOBILE-4550

Mobile 4550
main
Pau Ferrer Ocaña 2024-04-30 18:05:04 +02:00 committed by GitHub
commit 023db99e2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1354 additions and 961 deletions

View File

@ -872,6 +872,8 @@
"addon.mod_page.errorwhileloadingthepage": "local_moodlemobileapp",
"addon.mod_page.modulenameplural": "page",
"addon.mod_quiz.answercolon": "qtype_numerical",
"addon.mod_quiz.attempt": "quiz",
"addon.mod_quiz.attemptduration": "quiz",
"addon.mod_quiz.attemptfirst": "quiz",
"addon.mod_quiz.attemptlast": "quiz",
"addon.mod_quiz.attemptnumber": "quiz",
@ -901,7 +903,7 @@
"addon.mod_quiz.errorsaveattempt": "local_moodlemobileapp",
"addon.mod_quiz.feedback": "quiz",
"addon.mod_quiz.finishattemptdots": "quiz",
"addon.mod_quiz.finishnotsynced": "local_moodlemobileapp",
"addon.mod_quiz.finishedofflinenotice": "local_moodlemobileapp",
"addon.mod_quiz.grade": "quiz",
"addon.mod_quiz.gradeaverage": "quiz",
"addon.mod_quiz.gradehighest": "quiz",
@ -912,6 +914,8 @@
"addon.mod_quiz.mustbesubmittedby": "quiz",
"addon.mod_quiz.noquestions": "quiz",
"addon.mod_quiz.noreviewattempt": "quiz",
"addon.mod_quiz.noreviewuntil": "quiz",
"addon.mod_quiz.noreviewuntilshort": "quiz",
"addon.mod_quiz.notyetgraded": "quiz",
"addon.mod_quiz.opentoc": "local_moodlemobileapp",
"addon.mod_quiz.outof": "quiz",
@ -945,7 +949,6 @@
"addon.mod_quiz.summaryofattempt": "quiz",
"addon.mod_quiz.summaryofattempts": "quiz",
"addon.mod_quiz.timeleft": "quiz",
"addon.mod_quiz.timetaken": "quiz",
"addon.mod_quiz.unit": "quiz",
"addon.mod_quiz.warningattemptfinished": "local_moodlemobileapp",
"addon.mod_quiz.warningdatadiscarded": "local_moodlemobileapp",
@ -1864,6 +1867,7 @@
"core.grades.grade": "grades",
"core.grades.gradebook": "grades",
"core.grades.gradeitem": "grades",
"core.grades.gradelong": "grades",
"core.grades.gradepass": "grades",
"core.grades.grades": "grades",
"core.grades.lettergrade": "grades",
@ -2560,6 +2564,7 @@
"core.strftimetime12": "langconfig",
"core.strftimetime24": "langconfig",
"core.submit": "moodle",
"core.submittedoffline": "local_moodlemobileapp",
"core.success": "moodle",
"core.summary": "moodle",
"core.swipenavigationtourdescription": "local_moodlemobileapp",

View File

@ -15,8 +15,9 @@
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 { AddonModQuizAttemptWSData } from '@addons/mod/quiz/services/quiz';
import { makeSingleton } from '@singletons';
import { ADDON_MOD_QUIZ_SHOW_TIME_BEFORE_DEADLINE } from '@addons/mod/quiz/constants';
/**
* Handler to support open/close date access rule.
@ -50,8 +51,8 @@ export class AddonModQuizAccessOpenCloseDateHandlerService implements AddonModQu
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) {
// Show the time left only if it's less than ADDON_MOD_QUIZ_SHOW_TIME_BEFORE_DEADLINE.
if (timeNow > endTime - ADDON_MOD_QUIZ_SHOW_TIME_BEFORE_DEADLINE) {
return true;
}

View File

@ -0,0 +1,78 @@
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-status">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<addon-mod-quiz-attempt-state [state]="attempt.state" [finishedOffline]="attempt.finishedOffline" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-startedon">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@if (isFinished) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-completedon">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@if (timeTaken) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-duration">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptduration' | translate }}</p>
<p>{{ timeTaken }}</p>
</ion-label>
</ion-item>
}
@if (overTime) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-overdue">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p>
</ion-label>
</ion-item>
}
@for (gradeItemMark of gradeItemMarks; track $index) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-gradeitemmark">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }}</p>
<p [innerHTML]="gradeItemMark.grade"></p>
</ion-label>
</ion-item>
}
@if (quiz.showAttemptsMarks && readableMark && attempt?.sumgrades !== null) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-mark">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p>
</ion-label>
</ion-item>
}
@if (readableGrade) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-grade">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p [innerHTML]="readableGrade"></p>
</ion-label>
</ion-item>
}
@for (data of additionalData; track data.id) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-{{data.id}}">
<ion-label>
<p class="item-heading">{{ data.title }}</p>
<core-format-text [component]="component" [componentId]="quiz.coursemodule" [text]="data.content" contextLevel="module"
[contextInstanceId]="quiz.coursemodule" [courseId]="quiz.course" />
</ion-label>
</ion-item>
}
}

View File

@ -0,0 +1,123 @@
// (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, OnChanges, SimpleChanges } from '@angular/core';
import { AddonModQuizAttempt, AddonModQuizQuizData } from '../../services/quiz-helper';
import { AddonModQuiz, AddonModQuizWSAdditionalData } from '../../services/quiz';
import { ADDON_MOD_QUIZ_COMPONENT, AddonModQuizAttemptStates } from '../../constants';
import { CoreTime } from '@singletons/time';
import { Translate } from '@singletons';
import { CoreDomUtils } from '@services/utils/dom';
import { isSafeNumber } from '@/core/utils/types';
/**
* Component that displays an attempt info.
*/
@Component({
selector: 'addon-mod-quiz-attempt-info',
templateUrl: 'attempt-info.html',
})
export class AddonModQuizAttemptInfoComponent implements OnChanges {
@Input() quiz!: AddonModQuizQuizData;
@Input() attempt!: AddonModQuizAttempt;
@Input() additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt.
isFinished = false;
readableMark = '';
readableGrade = '';
timeTaken?: string;
overTime?: string;
gradeItemMarks: { name: string; grade: string }[] = [];
component = ADDON_MOD_QUIZ_COMPONENT;
/**
* @inheritdoc
*/
async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (changes.additionalData) {
this.additionalData?.forEach((data) => {
// Remove help links from additional data.
data.content = CoreDomUtils.removeElementFromHtml(data.content, '.helptooltip');
});
}
if (!changes.attempt) {
return;
}
this.isFinished = this.attempt.state === AddonModQuizAttemptStates.FINISHED;
if (!this.isFinished) {
return;
}
const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0);
if (timeTaken > 0) {
// Format time taken.
this.timeTaken = CoreTime.formatTime(timeTaken);
// Calculate overdue time.
if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit);
}
} else {
this.timeTaken = undefined;
}
// Treat grade item marks.
if (this.attempt.sumgrades === null || !this.attempt.gradeitemmarks) {
this.gradeItemMarks = [];
} else {
this.gradeItemMarks = this.attempt.gradeitemmarks.map((gradeItemMark) => ({
name: gradeItemMark.name,
grade: Translate.instant('addon.mod_quiz.outof', { $a: {
grade: '<strong>' + AddonModQuiz.formatGrade(gradeItemMark.grade, this.quiz?.decimalpoints) + '</strong>',
maxgrade: AddonModQuiz.formatGrade(gradeItemMark.maxgrade, this.quiz?.decimalpoints),
} }),
}));
}
if (!this.quiz.showAttemptsGrades) {
return;
}
// Treat grade and mark.
if (!isSafeNumber(this.attempt.rescaledGrade)) {
this.readableGrade = Translate.instant('addon.mod_quiz.notyetgraded');
return;
}
if (this.quiz.showAttemptsMarks) {
this.readableMark = Translate.instant('addon.mod_quiz.outofshort', { $a: {
grade: AddonModQuiz.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints),
} });
}
const gradeObject: Record<string, unknown> = {
grade: '<strong>' + AddonModQuiz.formatGrade(this.attempt.rescaledGrade, this.quiz.decimalpoints) + '</strong>',
maxgrade: AddonModQuiz.formatGrade(this.quiz.grade, this.quiz.decimalpoints),
};
if (this.quiz.grade != 100) {
const percentage = (this.attempt.sumgrades ?? 0) * 100 / (this.quiz.sumgrades ?? 1);
gradeObject.percent = '<strong>' + AddonModQuiz.formatGrade(percentage, this.quiz.decimalpoints) + '</strong>';
this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject });
} else {
this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject });
}
}
}

View File

@ -0,0 +1,6 @@
<ion-badge [color]="color">
@if (finishedOffline) {
<ion-icon name="fas-clock" aria-hidden="true" />
}
{{ readableState }}
</ion-badge>

View File

@ -0,0 +1,14 @@
@use "theme/globals" as *;
:host {
ion-badge {
display: inline-flex;
align-items: center;
ion-icon {
@include margin-horizontal(0px, var(--mdl-spacing-1));
}
}
}

View File

@ -0,0 +1,42 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnChanges } from '@angular/core';
import { AddonModQuiz } from '../../services/quiz';
/**
* Component that displays an attempt state.
*/
@Component({
selector: 'addon-mod-quiz-attempt-state',
templateUrl: 'attempt-state.html',
styleUrls: ['attempt-state.scss'],
})
export class AddonModQuizAttemptStateComponent implements OnChanges {
@Input() state = '';
@Input() finishedOffline = false;
readableState = '';
color = '';
/**
* @inheritdoc
*/
async ngOnChanges(): Promise<void> {
this.readableState = AddonModQuiz.getAttemptReadableStateName(this.state, this.finishedOffline);
this.color = AddonModQuiz.getAttemptStateColor(this.state, this.finishedOffline);
}
}

View File

@ -20,9 +20,13 @@ import { AddonModQuizConnectionErrorComponent } from './connection-error/connect
import { AddonModQuizIndexComponent } from './index/index';
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
import { AddonModQuizAttemptInfoComponent } from './attempt-info/attempt-info';
import { AddonModQuizAttemptStateComponent } from './attempt-state/attempt-state';
@NgModule({
declarations: [
AddonModQuizAttemptInfoComponent,
AddonModQuizAttemptStateComponent,
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,
@ -35,6 +39,8 @@ import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight
providers: [
],
exports: [
AddonModQuizAttemptInfoComponent,
AddonModQuizAttemptStateComponent,
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,

View File

@ -45,91 +45,123 @@
</ion-card>
<!-- List of user attempts. -->
<ion-card class="addon-mod_quiz-table" *ngIf="quiz && attempts.length">
@if (quiz && attempts.length) {
<ion-card class="addon-mod_quiz-attempts-summary">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_quiz.summaryofattempts' | translate }}</ion-card-title>
</ion-card-header>
<ion-card-content role="table">
<!-- "Header" of the table -->
<ion-item class="ion-text-wrap addon-mod_quiz-table-header hide-detail" [detail]="true">
<ion-label role="rowgroup">
<ion-row class="ion-align-items-center" role="row">
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn" role="columnheader">
<strong class="ion-hide-md-up" aria-hidden="true">#</strong>
<span class="sr-only ion-hide-md-up">{{ 'addon.mod_quiz.attemptnumber' | translate }}</span>
<strong class="ion-hide-md-down">{{ 'addon.mod_quiz.attemptnumber' | translate }}</strong>
</ion-col>
<ion-col size="7" role="columnheader">
<strong>{{ 'addon.mod_quiz.attemptstate' | translate }}</strong>
</ion-col>
<ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn" role="columnheader">
<strong>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }}</strong>
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn" role="columnheader">
<strong>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }}</strong>
</ion-col>
</ion-row>
</ion-label>
</ion-item>
<div role="rowgroup">
<!-- List of attempts. -->
<ion-item button [detail]="true" *ngFor="let attempt of attempts" class="ion-text-wrap"
[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" role="row">
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && attempt.preview" role="cell">
{{ 'addon.mod_quiz.preview' | translate }}
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && !attempt.preview" role="cell">
{{ attempt.attempt }}
</ion-col>
<ion-col size="7" role="cell">
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-col>
<ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn" role="cell">
<p>{{ attempt.readableMark }}</p>
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn" role="cell">
<p>{{ attempt.readableGrade }}</p>
</ion-col>
</ion-row>
</ion-label>
</ion-item>
</div>
</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>
<p class="item-heading">{{ 'addon.mod_quiz.comment' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="gradebookFeedback"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz.showFeedbackColumn && overallFeedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overallfeedback' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="overallFeedback" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
</ion-list>
<!-- Quiz result info. -->
@if (quiz && showResults && (gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedback && overallFeedback))) {
@if (overallStats && gradeResult) {
<ion-item class="ion-text-wrap addon-mod_quiz-grade-result">
<ion-label>
<div class="addon-mod_quiz-grade-result-grade">
@if (moreAttempts) {
<span>{{ gradeMethodReadable }}</span>
<span>{{ gradeResult }}</span>
} @else {
<span>{{ 'addon.mod_quiz.yourfinalgradeis' | translate:{ $a: gradeResult } }}</span>
}
</div>
@if (gradeOverridden) {
<p class="addon-mod_quiz-grade-overridden-notice">
<ion-icon name="fas-circle-info" color="info" slot="start" aria-hidden="true" />
{{ 'core.course.overriddennotice' | translate }}
</p>
}
</ion-label>
</ion-item>
}
@if (gradebookFeedback) {
<ion-item class="ion-text-wrap addon-mod_quiz-gradebook-feedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.comment' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="gradebookFeedback" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
}
@if (quiz.showFeedback && overallFeedback) {
<hr>
<ion-item class="ion-text-wrap addon-mod_quiz-overall-feedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overallfeedback' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="overallFeedback" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
}
}
<ion-accordion-group>
@for (attempt of attempts; track attempt.id) {
<ion-accordion [value]="attempt.id" toggleIconSlot="start">
<ion-item slot="header" class="ion-text-wrap addon-mod_quiz-attempt-title" lines="none">
<ion-label>
<h3>{{ 'addon.mod_quiz.attempt' | translate:{ $a: attempt.attempt } }}</h3>
</ion-label>
<div slot="end" class="addon-mod_quiz-attempt-title-info">
<addon-mod-quiz-attempt-state [state]="attempt.state" [finishedOffline]="attempt.finishedOffline" />
@if (attempt.finished && quiz.showAttemptsGrades) {
@if (attempt.rescaledGrade !== undefined && attempt.rescaledGrade >= 0) {
<p>
{{ 'core.grades.gradelong' | translate: { $a: {
grade: attempt.formattedGrade,
max: quiz.gradeFormatted,
} } }}
</p>
} @else {
<p>{{ 'addon.mod_quiz.notyetgraded' | translate }}</p>
}
}
</div>
</ion-item>
<div class="addon-mod_quiz-attempt-details" slot="content">
<addon-mod-quiz-attempt-info [quiz]="quiz" [attempt]="attempt" [additionalData]="attempt.additionalData" />
@if (attempt.canReview) {
<hr>
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="reviewAttempt(attempt.id)" fill="outline">
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.review' | translate }}
</ion-button>
} @else if (attempt.completed && attempt.cannotReviewMessage) {
<hr>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-noreview">
<ion-label>
<p>
<ion-icon name="fas-circle-info" color="info" slot="start" aria-hidden="true" />
{{ attempt.cannotReviewMessage }}
</p>
</ion-label>
</ion-item>
} @else if (attempt.finishedOffline) {
<hr>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-finishedoffline">
<ion-label>
<p>
<ion-icon name="fas-clock" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.finishedofflinenotice' | translate }}
</p>
</ion-label>
</ion-item>
}
</div>
</ion-accordion>
}
</ion-accordion-group>
</ion-card>
}
<!-- More data. -->
<ng-container *ngIf="quiz">

View File

@ -1,33 +1,75 @@
@use "theme/globals" as *;
:host {
.addon-mod_quiz-table {
ion-card-content {
padding-left: 0;
padding-right: 0;
.addon-mod_quiz-attempts-summary {
ion-card-header {
border-bottom: 1px solid var(--stroke);
}
.item:nth-child(even) {
--background: var(--light);
.addon-mod_quiz-grade-result {
margin-top: var(--mdl-spacing-2);
.addon-mod_quiz-grade-overridden-notice {
margin-top: var(--mdl-spacing-2);
margin-bottom: 0px;
}
.addon-mod_quiz-grade-result-grade {
display: flex;
span:first-child {
flex-grow: 1;
}
}
}
.addon-mod_quiz-highlighted,
.item.addon-mod_quiz-highlighted,
.addon-mod_quiz-highlighted p,
.item.addon-mod_quiz-highlighted p {
--background: var(--primary-tint);
color: var(--primary-shade);
.addon-mod_quiz-attempt-title-info {
display: flex;
flex-direction: column;
align-items: end;
justify-content: center;
min-height: 60px;
padding-top: var(--mdl-spacing-2);
padding-bottom: var(--mdl-spacing-2);
p {
margin: 0px;
margin-top: var(--mdl-spacing-2);
}
}
ion-accordion-group {
border-top: 1px solid var(--stroke);
.accordion-expanded .addon-mod_quiz-attempt-title-info,
.accordion-expanding .addon-mod_quiz-attempt-title-info {
visibility: hidden;
}
hr {
background-color: var(--stroke);
height: 1px;
margin: 0px var(-mdl-spacing-4);
}
ion-accordion:nth-child(odd) {
background-color: var(--core-table-odd-cell-background);
::ng-deep ion-item {
--background: var(--core-table-odd-cell-background);
}
}
ion-accordion:nth-child(even) {
background-color: var(--core-table-even-cell-background);
::ng-deep ion-item {
--background: var(--core-table-even-cell-background);
}
}
}
}
}
:host-context(html.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(--primary-shade);
color: var(--primary-tint);
}
}
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { DownloadStatus } from '@/core/constants';
import { safeNumber, SafeNumber } from '@/core/utils/types';
import { isSafeNumber, safeNumber, SafeNumber } from '@/core/utils/types';
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
@ -36,7 +36,7 @@ import {
AddonModQuizGetAttemptAccessInformationWSResponse,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizGetUserBestGradeWSResponse,
AddonModQuizProvider,
AddonModQuizWSAdditionalData,
} from '../../services/quiz';
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
import {
@ -45,6 +45,8 @@ import {
AddonModQuizSyncProvider,
AddonModQuizSyncResult,
} from '../../services/quiz-sync';
import { ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, ADDON_MOD_QUIZ_COMPONENT, AddonModQuizAttemptStates } from '../../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants';
/**
* Component that displays a quiz entry page.
@ -56,7 +58,7 @@ import {
})
export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
component = AddonModQuizProvider.COMPONENT;
component = ADDON_MOD_QUIZ_COMPONENT;
pluginName = 'quiz';
quiz?: AddonModQuizQuizData; // The quiz.
now?: number; // Current time.
@ -77,13 +79,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
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.
attempts: QuizAttempt[] = []; // List of attempts the user has made.
bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data.
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.
@ -110,7 +111,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// Listen for attempt finished events.
this.finishedObserver = CoreEvents.on(
AddonModQuizProvider.ATTEMPT_FINISHED_EVENT,
ADDON_MOD_QUIZ_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) {
@ -195,15 +196,9 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
if (AddonModQuiz.isQuizOffline(quiz)) {
if (sync) {
// Try to sync the quiz.
try {
await this.syncActivity(showErrors);
} catch {
// Ignore errors, keep getting data even if sync fails.
this.autoReview = undefined;
}
await CoreUtils.ignoreErrors(this.syncActivity(showErrors));
}
} else {
this.autoReview = undefined;
this.showStatusSpinner = false;
}
@ -233,7 +228,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.unsupportedQuestions = AddonModQuiz.getUnsupportedQuestions(types);
this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1);
await this.getAttempts(quiz);
await this.getAttempts(quiz, this.quizAccessInfo);
// Quiz is ready to be shown, move it to the variable that is displayed.
this.quiz = quiz;
@ -245,7 +240,10 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* @param quiz Quiz instance.
* @returns Promise resolved when done.
*/
protected async getAttempts(quiz: AddonModQuizQuizData): Promise<void> {
protected async getAttempts(
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
): Promise<void> {
// Always get the best grade because it includes the grade to pass.
this.bestGrade = await AddonModQuiz.getUserBestGrade(quiz.id, { cmId: this.module.id });
@ -255,12 +253,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// Get attempts.
const attempts = await AddonModQuiz.getUserAttempts(quiz.id, { cmId: this.module.id });
this.attempts = await this.treatAttempts(quiz, attempts);
this.attempts = await this.treatAttempts(quiz, accessInfo, attempts);
// Check if user can create/continue attempts.
if (this.attempts.length) {
const last = this.attempts[this.attempts.length - 1];
this.moreAttempts = !AddonModQuiz.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished;
const last = this.attempts[0];
this.moreAttempts = !AddonModQuiz.isAttemptCompleted(last.state) || !this.attemptAccessInfo.isfinished;
} else {
this.moreAttempts = !this.attemptAccessInfo.isfinished;
}
@ -279,7 +277,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.buttonText = '';
if (quiz.hasquestions !== 0) {
if (this.attempts.length && !AddonModQuiz.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) {
if (this.attempts.length && !AddonModQuiz.isAttemptCompleted(this.attempts[0].state)) {
// Last attempt is unfinished.
if (this.quizAccessInfo?.canattempt) {
this.buttonText = 'addon.mod_quiz.continueattemptquiz';
@ -327,7 +325,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* @returns Promise resolved when done.
*/
protected async getResultInfo(quiz: AddonModQuizQuizData): Promise<void> {
if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade ||
if (!this.attempts.length || !quiz.showAttemptsGrades || !this.bestGrade?.hasgrade ||
this.gradebookData?.grade === undefined) {
this.showResults = false;
@ -350,25 +348,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
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.instant('addon.mod_quiz.gradesofar', { $a: {
method: this.gradeMethodReadable,
mygrade: gradeToShow,
quizgrade: quiz.gradeFormatted,
} });
} else {
const outOfShort = Translate.instant('addon.mod_quiz.outofshort', { $a: {
grade: gradeToShow,
maxgrade: quiz.gradeFormatted,
} });
this.gradeResult = Translate.instant('core.grades.gradelong', { $a: {
grade: gradeToShow,
max: quiz.gradeFormatted,
} });
this.gradeResult = Translate.instant('addon.mod_quiz.yourfinalgradeis', { $a: outOfShort });
}
}
if (quiz.showFeedbackColumn) {
if (quiz.showFeedback) {
// Get the quiz overall feedback.
const response = await AddonModQuiz.getFeedbackForGrade(quiz.id, this.gradebookData.grade, {
cmId: this.module.id,
@ -396,7 +381,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
*
* @returns Promise resolved when done.
*/
protected async goToAutoReview(): Promise<void> {
protected async goToAutoReview(attempts: AddonModQuizAttemptWSData[]): Promise<void> {
if (!this.autoReview) {
return;
}
@ -405,20 +390,19 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.checkCompletion();
// Verify that user can see the review.
const attemptId = this.autoReview.attemptId;
const attempt = attempts.find(attempt => attempt.id === this.autoReview?.attemptId);
this.autoReview = undefined;
if (this.quizAccessInfo?.canreviewmyattempts) {
try {
await AddonModQuiz.getAttemptReview(attemptId, { page: -1, cmId: this.module.id });
await CoreNavigator.navigateToSitePath(
`${AddonModQuizModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/review/${attemptId}`,
);
} catch {
// Ignore errors.
}
if (!this.quiz || !this.quizAccessInfo || !attempt) {
return;
}
const canReview = await AddonModQuizHelper.canReviewAttempt(this.quiz, this.quizAccessInfo, attempt);
if (!canReview) {
return;
}
await this.reviewAttempt(attempt.id);
}
/**
@ -447,22 +431,15 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
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();
}
// Refresh data.
this.showLoading = true;
this.content?.scrollToTop();
await promise;
await CoreUtils.ignoreErrors(this.refreshContent(true));
this.showLoading = false;
this.autoReview = undefined;
}
/**
@ -571,13 +548,15 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* Treat user attempts.
*
* @param quiz Quiz data.
* @param accessInfo Quiz access information.
* @param attempts The attempts to treat.
* @returns Promise resolved when done.
*/
protected async treatAttempts(
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempts: AddonModQuizAttemptWSData[],
): Promise<AddonModQuizAttempt[]> {
): Promise<QuizAttempt[]> {
if (!attempts || !attempts.length) {
// There are no attempts to treat.
quiz.gradeFormatted = AddonModQuiz.formatGrade(quiz.grade, quiz.decimalpoints);
@ -585,10 +564,10 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
return [];
}
const lastFinished = AddonModQuiz.getLastFinishedAttemptFromList(attempts);
const lastCompleted = AddonModQuiz.getLastCompletedAttemptFromList(attempts);
let openReview = false;
if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) {
if (this.autoReview && lastCompleted && lastCompleted.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) {
@ -599,29 +578,50 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
const [options] = await Promise.all([
AddonModQuiz.getCombinedReviewOptions(quiz.id, { cmId: this.module.id }),
this.getQuizGrade(),
openReview ? this.goToAutoReview() : undefined,
openReview ? this.goToAutoReview(attempts) : undefined,
]);
this.options = options;
const grade = this.gradebookData?.grade !== undefined ? this.gradebookData.grade : this.bestGrade?.grade;
const quizGrade = AddonModQuiz.formatGrade(grade, quiz.decimalpoints);
// Calculate data to construct the header of the attempts table.
AddonModQuizHelper.setQuizCalculatedData(quiz, this.options);
this.overallStats = !!lastFinished && this.options.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX;
this.overallStats = !!lastCompleted && this.options.alloptions.marks >= QuestionDisplayOptionsMarks.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;
const formattedAttempts = await Promise.all(attempts.map(async (attempt) => {
const [formattedAttempt, canReview] = await Promise.all([
AddonModQuizHelper.setAttemptCalculatedData(quiz, attempt) as Promise<QuizAttempt>,
AddonModQuizHelper.canReviewAttempt(quiz, accessInfo, attempt),
]);
return AddonModQuizHelper.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast);
formattedAttempt.canReview = canReview;
if (!canReview) {
formattedAttempt.cannotReviewMessage = AddonModQuizHelper.getCannotReviewMessage(quiz, attempt, true);
}
if (quiz.showFeedback && attempt.state === AddonModQuizAttemptStates.FINISHED &&
options.someoptions.overallfeedback && isSafeNumber(formattedAttempt.rescaledGrade)) {
// Feedback should be displayed, get the feedback for the grade.
const response = await AddonModQuiz.getFeedbackForGrade(quiz.id, formattedAttempt.rescaledGrade, {
cmId: quiz.coursemodule,
});
if (response.feedbacktext) {
formattedAttempt.additionalData = [
{
id: 'feedback',
title: Translate.instant('addon.mod_quiz.feedback'),
content: response.feedbacktext,
},
];
}
}
return formattedAttempt;
}));
return formattedAttempts;
return formattedAttempts.reverse();
}
/**
@ -651,13 +651,13 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
/**
* Go to page to view the attempt details.
* Go to page to review the attempt.
*
* @returns Promise resolved when done.
*/
async viewAttempt(attemptId: number): Promise<void> {
async reviewAttempt(attemptId: number): Promise<void> {
await CoreNavigator.navigateToSitePath(
`${AddonModQuizModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/attempt/${attemptId}`,
`${AddonModQuizModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/review/${attemptId}`,
);
}
@ -671,3 +671,9 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
}
type QuizAttempt = AddonModQuizAttempt & {
canReview?: boolean;
cannotReviewMessage?: string;
additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt.
};

View File

@ -12,4 +12,41 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export const ADDON_MOD_QUIZ_COMPONENT = 'mmaModQuiz';
export const ADDON_MOD_QUIZ_FEATURE_NAME = 'CoreCourseModuleDelegate_AddonModQuiz';
export const ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished';
export const ADDON_MOD_QUIZ_SHOW_TIME_BEFORE_DEADLINE = 3600;
export const ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD = 120; // Time considered 'immedately after the attempt', in seconds.
/**
* Possible grade methods for a quiz.
*/
export const enum AddonModQuizGradeMethods {
HIGHEST_GRADE = 1,
AVERAGE_GRADE = 2,
FIRST_ATTEMPT = 3,
LAST_ATTEMPT = 4,
}
/**
* Possible states for an attempt.
*/
export const enum AddonModQuizAttemptStates {
IN_PROGRESS = 'inprogress',
OVERDUE = 'overdue',
FINISHED = 'finished',
ABANDONED = 'abandoned',
}
/**
* Bitmask patterns to determine if data should be displayed based on the attempt state.
*/
export const enum AddonModQuizDisplayOptionsAttemptStates {
DURING = 0x10000,
IMMEDIATELY_AFTER = 0x01000,
LATER_WHILE_OPEN = 0x00100,
AFTER_CLOSE = 0x00010,
}

View File

@ -1,15 +1,17 @@
{
"answercolon": "Answer:",
"attempt": "Attempt {{$a}}",
"attemptduration": "Duration",
"attemptfirst": "First attempt",
"attemptlast": "Last attempt",
"attemptnumber": "Attempt",
"attemptquiznow": "Attempt quiz now",
"attemptstate": "State",
"attemptstate": "Status",
"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",
"completedon": "Completed",
"confirmclose": "Once you submit your answers, you wont be able to change them.",
"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?",
@ -29,7 +31,7 @@
"errorsaveattempt": "An error occurred while saving the attempt data.",
"feedback": "Feedback",
"finishattemptdots": "Finish attempt...",
"finishnotsynced": "Finished but not synchronised",
"finishedofflinenotice": "Your attempt has been submitted and saved. It will be sent to the site when you're online again.",
"grade": "Grade",
"gradeaverage": "Average grade",
"gradehighest": "Highest grade",
@ -40,6 +42,8 @@
"mustbesubmittedby": "This attempt must be submitted by {{$a}}.",
"noquestions": "No questions have been added yet",
"noreviewattempt": "You are not allowed to review this attempt.",
"noreviewuntil": "You are not allowed to review this quiz until {{$a}}",
"noreviewuntilshort": "Available {{$a}}",
"notyetgraded": "Not yet graded",
"opentoc": "Open navigation popover",
"outof": "{{$a.grade}} out of {{$a.maxgrade}}",
@ -60,7 +64,7 @@
"showall": "Show all questions on one page",
"showeachpage": "Show one page at a time",
"startattempt": "Start attempt",
"startedon": "Started on",
"startedon": "Started",
"stateabandoned": "Never submitted",
"statefinished": "Finished",
"statefinisheddetails": "Submitted {{$a}}",
@ -71,9 +75,8 @@
"submission_confirmation_unanswered": "Questions without a response: {{$a}}",
"submitallandfinish": "Submit all and finish",
"summaryofattempt": "Summary of attempt",
"summaryofattempts": "Summary of your previous attempts",
"summaryofattempts": "Your attempts",
"timeleft": "Time left",
"timetaken": "Time taken",
"unit": "Unit",
"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.",

View File

@ -1,79 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>
<core-format-text *ngIf="quiz" [text]="quiz.name" contextLevel="module" [contextInstanceId]="quiz.coursemodule"
[courseId]="courseId" />
</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="attempt">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptnumber' | translate }}</p>
<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>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-label>
</ion-item>
<ng-container *ngIf="attempt.finished && attempt.sumgrades !== null">
<ion-item *ngFor="let gradeItemMark of attempt.gradeitemmarks ?? []" class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }} / {{ gradeItemMark.maxgrade }}</p>
<p>{{ gradeItemMark.grade }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== '' && attempt.sumgrades !== null">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</p>
<p>{{ attempt.readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</p>
<p>{{ attempt.readableGrade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.feedback' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
<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>
<div collapsible-footer appearOnBottom *ngIf="loaded && attempt && showReviewColumn && attempt.finished" slot="fixed">
<div class="list-item-limited-width">
<ion-button class="ion-margin ion-text-wrap" expand="block" (click)="reviewAttempt()">
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.review' | translate }}
</ion-button>
</div>
</div>
</core-loading>
</ion-content>

View File

@ -1,214 +0,0 @@
// (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 { isSafeNumber } from '@/core/utils/types';
import { Component, OnInit } from '@angular/core';
import { CoreError } from '@classes/errors/error';
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.
/**
* @inheritdoc
*/
ngOnInit(): void {
try {
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId');
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
this.fetchQuizData().finally(() => {
this.loaded = true;
});
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
doRefresh(refresher: HTMLIonRefresherElement): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get quiz data and attempt data.
*
* @returns Promise resolved when done.
*/
protected async fetchQuizData(): Promise<void> {
try {
this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId);
this.componentId = this.quiz.coursemodule;
// Load attempt data.
const [options, accessInfo, attempt] = await Promise.all([
AddonModQuiz.getCombinedReviewOptions(this.quiz.id, { cmId: this.quiz.coursemodule }),
this.fetchAccessInfo(this.quiz),
this.fetchAttempt(this.quiz.id),
]);
// Set calculated data.
this.showReviewColumn = accessInfo.canreviewmyattempts;
AddonModQuizHelper.setQuizCalculatedData(this.quiz, options);
this.attempt = await AddonModQuizHelper.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.isAttemptFinished(this.attempt.state) &&
options.someoptions.overallfeedback && isSafeNumber(grade)) {
// Feedback should be displayed, get the feedback for the grade.
const response = await AddonModQuiz.getFeedbackForGrade(this.quiz.id, grade, {
cmId: this.quiz.coursemodule,
});
this.feedback = response.feedbacktext;
} else {
delete this.feedback;
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetattempt', true);
}
}
/**
* Get the attempt.
*
* @param quizId Quiz ID.
* @returns Promise resolved when done.
*/
protected async fetchAttempt(quizId: number): Promise<AddonModQuizAttemptWSData> {
// Get all the attempts and search the one we want.
const attempts = await AddonModQuiz.getUserAttempts(quizId, { 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.instant('addon.mod_quiz.errorgetattempt'));
}
return attempt;
}
/**
* Get the access info.
*
* @param quiz Quiz instance.
* @returns Promise resolved when done.
*/
protected async fetchAccessInfo(quiz: AddonModQuizQuizData): Promise<AddonModQuizGetQuizAccessInformationWSResponse> {
const accessInfo = await AddonModQuiz.getQuizAccessInformation(quiz.id, { cmId: this.cmId });
if (!accessInfo.canreviewmyattempts) {
return accessInfo;
}
// Check if the user can review the attempt.
await CoreUtils.ignoreErrors(AddonModQuiz.invalidateAttemptReviewForPage(this.attemptId, -1));
try {
await AddonModQuiz.getAttemptReview(this.attemptId, { page: -1, cmId: quiz.coursemodule });
} catch {
// Error getting the review, assume the user cannot review the attempt.
accessInfo.canreviewmyattempts = false;
}
return accessInfo;
}
/**
* Refresh the data.
*
* @returns Promise resolved when done.
*/
protected async refreshData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModQuiz.invalidateQuizData(this.courseId));
promises.push(AddonModQuiz.invalidateAttemptReview(this.attemptId));
if (this.quiz) {
promises.push(AddonModQuiz.invalidateUserAttemptsForUser(this.quiz.id));
promises.push(AddonModQuiz.invalidateQuizAccessInformation(this.quiz.id));
promises.push(AddonModQuiz.invalidateCombinedReviewOptionsForUser(this.quiz.id));
if (this.attempt && this.feedback !== undefined) {
promises.push(AddonModQuiz.invalidateFeedback(this.quiz.id));
}
}
await CoreUtils.ignoreErrors(Promise.all(promises));
await this.fetchQuizData();
}
/**
* Go to the page to review the attempt.
*
* @returns Promise resolved when done.
*/
async reviewAttempt(): Promise<void> {
if (!this.attempt) {
return;
}
CoreNavigator.navigate(`../../review/${this.attempt.id}`);
}
}

View File

@ -10,6 +10,7 @@ $quiz-timer-iterations: 15 !default;
font-size: var(--mdl-typography-fontSize-md);
margin-top: 2px;
margin-bottom: 2px;
text-align: end;
}
core-timer {

View File

@ -38,10 +38,9 @@ import {
AddonModQuizAttemptWSData,
AddonModQuizGetAttemptAccessInformationWSResponse,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizProvider,
AddonModQuizQuizWSData,
} from '../../services/quiz';
import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper';
import { AddonModQuizHelper } from '../../services/quiz-helper';
import { AddonModQuizSync } from '../../services/quiz-sync';
import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form';
@ -50,6 +49,7 @@ import { CoreTime } from '@singletons/time';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
import { CoreWSError } from '@classes/errors/wserror';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, AddonModQuizAttemptStates, ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Page that allows attempting a quiz.
@ -66,9 +66,9 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
@ViewChild('quizForm') formElement?: ElementRef;
quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
attempt?: AddonModQuizAttempt; // The attempt being attempted.
attempt?: QuizAttempt; // The attempt being attempted.
moduleUrl?: string; // URL to the module in the site.
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
component = ADDON_MOD_QUIZ_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.
@ -91,7 +91,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
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 lastAttempt?: QuizAttempt; // 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.
@ -146,7 +146,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
if (this.quiz) {
// Unblock the quiz so it can be synced.
CoreSync.unblockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
CoreSync.unblockOperation(ADDON_MOD_QUIZ_COMPONENT, this.quiz.id);
}
}
@ -263,7 +263,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
return;
}
if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) {
if (page != -1 && (this.attempt.state === AddonModQuizAttemptStates.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 && slot !== undefined) {
@ -341,7 +341,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId);
// Block the quiz so it cannot be synced.
CoreSync.blockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
CoreSync.blockOperation(ADDON_MOD_QUIZ_COMPONENT, this.quiz.id);
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
await AddonModQuizSync.waitForSync(this.quiz.id);
@ -381,15 +381,11 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
}
// Get the last attempt. If it's finished, start a new one.
this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData(
this.quiz,
attempts[attempts.length - 1],
false,
undefined,
true,
);
this.lastAttempt = attempts[attempts.length - 1];
this.newAttempt = AddonModQuiz.isAttemptFinished(this.lastAttempt.state);
this.lastAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(this.lastAttempt.id);
this.newAttempt = AddonModQuiz.isAttemptCompleted(this.lastAttempt.state);
}
/**
@ -408,7 +404,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
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) {
if (!timeUp && this.attempt.state === AddonModQuizAttemptStates.IN_PROGRESS) {
let message = Translate.instant('addon.mod_quiz.confirmclose');
const unansweredCount = this.summaryQuestions
@ -444,7 +440,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
await this.processAttempt(userFinish, timeUp);
// Trigger an event to notify the attempt was finished.
CoreEvents.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
CoreEvents.trigger(ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, {
quizId: this.quiz.id,
attemptId: this.attempt.id,
synced: !this.offline,
@ -679,7 +675,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
});
this.showSummary = true;
this.canReturn = this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt.finishedOffline;
this.canReturn = this.attempt.state === AddonModQuizAttemptStates.IN_PROGRESS && !this.attempt.finishedOffline;
this.preventSubmitMessages = AddonModQuiz.getPreventSubmitMessages(this.summaryQuestions);
this.dueDateWarning = AddonModQuiz.getAttemptDueDateWarning(this.quiz, this.attempt);
@ -888,10 +884,12 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
this.quiz,
this.quizAccessInfo,
this.preflightData,
attempt,
this.offline,
false,
'addon.mod_quiz.startattempt',
{
attempt,
offline: this.offline,
finishedOffline: attempt?.finishedOffline,
title: 'addon.mod_quiz.startattempt',
},
);
// Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
@ -904,7 +902,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
await this.loadNavigation();
if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
if (this.attempt.state !== AddonModQuizAttemptStates.OVERDUE && !this.attempt.finishedOffline) {
// Attempt not overdue and not finished in offline, load page.
await this.loadPage(this.attempt.currentpage ?? 0);
@ -944,3 +942,10 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
type QuizQuestion = CoreQuestionQuestionParsed & {
readableMark?: string;
};
/**
* Attempt with some calculated data for the view.
*/
type QuizAttempt = AddonModQuizAttemptWSData & {
finishedOffline?: boolean;
};

View File

@ -24,61 +24,7 @@
<!-- Review summary -->
<ion-card *ngIf="attempt">
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p>{{ readableState }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="showCompleted">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeTaken">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.timetaken' | translate }}</p>
<p>{{ timeTaken }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="overTime">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p>
</ion-label>
</ion-item>
<ion-item *ngFor="let gradeItemMark of gradeItemMarks" class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }}</p>
<p>{{ gradeItemMark.grade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableMark && attempt?.sumgrades !== null">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableGrade">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p>{{ readableGrade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let data of additionalData">
<ion-label>
<p class="item-heading">{{ data.title }}</p>
<core-format-text [component]="component" [componentId]="cmId" [text]="data.content" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId" />
</ion-label>
</ion-item>
<addon-mod-quiz-attempt-info [quiz]="quiz" [attempt]="attempt" [additionalData]="additionalData" />
</ion-list>
</ion-card>

View File

@ -19,7 +19,6 @@ import { IonContent } 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 { CoreDom } from '@singletons/dom';
import { CoreTime } from '@singletons/time';
import {
@ -31,13 +30,12 @@ import {
AddonModQuiz,
AddonModQuizAttemptWSData,
AddonModQuizCombinedReviewOptions,
AddonModQuizGetAttemptReviewResponse,
AddonModQuizProvider,
AddonModQuizQuizWSData,
AddonModQuizWSAdditionalData,
} from '../../services/quiz';
import { AddonModQuizHelper } from '../../services/quiz-helper';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Page that allows reviewing a quiz attempt.
@ -52,7 +50,7 @@ export class AddonModQuizReviewPage implements OnInit {
@ViewChild(IonContent) content?: IonContent;
attempt?: AddonModQuizAttemptWSData; // The attempt being reviewed.
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
component = ADDON_MOD_QUIZ_COMPONENT; // Component to link the files to.
showAll = false; // Whether to view all questions in the same page.
numPages = 1; // Number of pages.
showCompleted = false; // Whether to show completed time.
@ -62,7 +60,6 @@ export class AddonModQuizReviewPage implements OnInit {
questions: QuizQuestion[] = []; // Questions of the current page.
nextPage = -2; // Next page.
previousPage = -2; // Previous page.
readableState?: string;
readableGrade?: string;
readableMark?: string;
timeTaken?: string;
@ -158,6 +155,8 @@ export class AddonModQuizReviewPage implements OnInit {
this.options = await AddonModQuiz.getCombinedReviewOptions(this.quiz.id, { cmId: this.cmId });
AddonModQuizHelper.setQuizCalculatedData(this.quiz, this.options);
// Load the navigation data.
await this.loadNavigation();
@ -177,15 +176,17 @@ export class AddonModQuizReviewPage implements OnInit {
* @returns Promise resolved when done.
*/
protected async loadPage(page: number): Promise<void> {
const data = await AddonModQuiz.getAttemptReview(this.attemptId, { page, cmId: this.quiz?.coursemodule });
if (!this.quiz) {
return;
}
this.attempt = data.attempt;
const data = await AddonModQuiz.getAttemptReview(this.attemptId, { page, cmId: this.quiz.coursemodule });
this.attempt = await AddonModQuizHelper.setAttemptCalculatedData(this.quiz, data.attempt);
this.attempt.currentpage = page;
this.additionalData = data.additionaldata;
this.currentPage = page;
// Set the summary data.
this.setSummaryCalculatedData(data);
this.questions = data.questions;
this.nextPage = page + 1;
this.previousPage = page - 1;
@ -253,91 +254,6 @@ export class AddonModQuizReviewPage implements OnInit {
);
}
/**
* Calculate review summary data.
*
* @param data Result of getAttemptReview.
*/
protected setSummaryCalculatedData(data: AddonModQuizGetAttemptReviewResponse): void {
if (!this.attempt || !this.quiz) {
return;
}
this.readableState = AddonModQuiz.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 = CoreTime.formatTime(timeTaken);
// Calculate overdue time.
if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit);
}
} else {
this.timeTaken = undefined;
}
// Treat grade item marks.
if (this.attempt.sumgrades === null || !this.attempt.gradeitemmarks) {
this.gradeItemMarks = [];
} else {
this.gradeItemMarks = this.attempt.gradeitemmarks.map((gradeItemMark) => ({
name: gradeItemMark.name,
grade: Translate.instant('addon.mod_quiz.outof', { $a: {
grade: AddonModQuiz.formatGrade(gradeItemMark.grade, this.quiz?.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(gradeItemMark.maxgrade, this.quiz?.decimalpoints),
} }),
}));
}
// Treat grade.
if (this.options && this.options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
AddonModQuiz.quizHasGrades(this.quiz)) {
if (data.grade === null || data.grade === undefined) {
this.readableGrade = AddonModQuiz.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.instant('addon.mod_quiz.outofshort', { $a: {
grade: AddonModQuiz.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints),
} });
}
// Now the scaled grade.
const gradeObject: Record<string, unknown> = {
grade: AddonModQuiz.formatGrade(Number(data.grade), this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.grade, this.quiz.decimalpoints),
};
if (this.quiz.grade != 100) {
gradeObject.percent = AddonModQuiz.formatGrade(
(this.attempt.sumgrades ?? 0) * 100 / (this.quiz.sumgrades ?? 1),
this.quiz.decimalpoints,
);
this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject });
} else {
this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject });
}
}
}
// Treat additional data.
this.additionalData.forEach((data) => {
// Remove help links from additional data.
data.content = CoreDomUtils.removeElementFromHtml(data.content, '.helptooltip');
});
}
/**
* Switch mode: all questions in same page OR one page at a time.
*/

View File

@ -19,7 +19,6 @@ import { CoreSharedModule } from '@/core/shared.module';
import { AddonModQuizComponentsModule } from './components/components.module';
import { AddonModQuizIndexPage } from './pages/index';
import { AddonModQuizAttemptPage } from '@addons/mod/quiz/pages/attempt/attempt';
import { CoreQuestionComponentsModule } from '@features/question/components/components.module';
import { AddonModQuizPlayerPage } from '@addons/mod/quiz/pages/player/player';
import { canLeaveGuard } from '@guards/can-leave';
@ -35,10 +34,6 @@ const routes: Routes = [
component: AddonModQuizPlayerPage,
canDeactivate: [canLeaveGuard],
},
{
path: ':courseId/:cmId/attempt/:attemptId',
component: AddonModQuizAttemptPage,
},
{
path: ':courseId/:cmId/review/:attemptId',
component: AddonModQuizReviewPage,
@ -54,7 +49,6 @@ const routes: Routes = [
],
declarations: [
AddonModQuizIndexPage,
AddonModQuizAttemptPage,
AddonModQuizPlayerPage,
AddonModQuizReviewPage,
],

View File

@ -33,7 +33,7 @@ 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';
import { AddonModQuizProvider } from './services/quiz';
import { ADDON_MOD_QUIZ_COMPONENT } from './constants';
/**
* Get mod Quiz services.
@ -98,7 +98,7 @@ const routes: Routes = [
CorePushNotificationsDelegate.registerClickHandler(AddonModQuizPushClickHandler.instance);
CoreCronDelegate.register(AddonModQuizSyncCronHandler.instance);
CoreCourseHelper.registerModuleReminderClick(AddonModQuizProvider.COMPONENT);
CoreCourseHelper.registerModuleReminderClick(ADDON_MOD_QUIZ_COMPONENT);
},
},
],

View File

@ -32,11 +32,11 @@ import {
AddonModQuiz,
AddonModQuizAttemptWSData,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizProvider,
AddonModQuizQuizWSData,
} from '../quiz';
import { AddonModQuizHelper } from '../quiz-helper';
import { AddonModQuizSync, AddonModQuizSyncResult } from '../quiz-sync';
import { AddonModQuizAttemptStates, ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Handler to prefetch quizzes.
@ -46,7 +46,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
name = 'AddonModQuiz';
modName = 'quiz';
component = AddonModQuizProvider.COMPONENT;
component = ADDON_MOD_QUIZ_COMPONENT;
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/;
/**
@ -115,8 +115,8 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
let files: CoreWSFile[] = [];
await Promise.all(attempts.map(async (attempt) => {
if (!AddonModQuiz.isAttemptFinished(attempt.state)) {
// Attempt not finished, no feedback files.
if (!AddonModQuiz.isAttemptCompleted(attempt.state)) {
// Attempt not completed, no feedback files.
return;
}
@ -167,11 +167,12 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
quiz,
accessInfo,
preflightData,
attempt,
false,
true,
title,
siteId,
{
attempt,
prefetch: true,
title,
siteId,
},
);
} else {
// Get some fixed preflight data from access rules (data that doesn't require user interaction).
@ -241,9 +242,9 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
siteId,
});
const isLastFinished = !attempts.length || AddonModQuiz.isAttemptFinished(attempts[attempts.length - 1].state);
const isLastCompleted = !attempts.length || AddonModQuiz.isAttemptCompleted(attempts[attempts.length - 1].state);
return quiz.attempts === 0 || (quiz.attempts ?? 0) > attempts.length || !isLastFinished;
return quiz.attempts === 0 || (quiz.attempts ?? 0) > attempts.length || !isLastCompleted;
}
/**
@ -321,7 +322,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
AddonModQuiz.getUserAttempts(quiz.id, modOptions),
AddonModQuiz.getAttemptAccessInformation(quiz.id, 0, modOptions),
AddonModQuiz.getQuizRequiredQtypes(quiz.id, modOptions),
CoreFilepool.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id),
CoreFilepool.addFilesToQueue(siteId, introFiles, ADDON_MOD_QUIZ_COMPONENT, module.id),
]);
// Check if we need to start a new attempt.
@ -330,7 +331,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
let startAttempt = false;
if (canStart || attempt) {
if (canStart && (!attempt || AddonModQuiz.isAttemptFinished(attempt.state))) {
if (canStart && (!attempt || AddonModQuiz.isAttemptCompleted(attempt.state))) {
// Check if the user can attempt the quiz.
if (attemptAccessInfo.preventnewattemptreasons.length) {
throw new CoreError(CoreTextUtils.buildMessage(attemptAccessInfo.preventnewattemptreasons));
@ -353,17 +354,17 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts, siteId);
return CoreFilepool.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id);
return CoreFilepool.addFilesToQueue(siteId, attemptFiles, ADDON_MOD_QUIZ_COMPONENT, module.id);
}));
// Update the download time to prevent detecting the new attempt as an update.
promises.push(CoreUtils.ignoreErrors(
CoreFilepool.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id),
CoreFilepool.updatePackageDownloadTime(siteId, ADDON_MOD_QUIZ_COMPONENT, module.id),
));
} else {
// Use the already fetched attempts.
promises.push(this.getAttemptsFeedbackFiles(quiz, attempts, siteId).then((attemptFiles) =>
CoreFilepool.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id)));
CoreFilepool.addFilesToQueue(siteId, attemptFiles, ADDON_MOD_QUIZ_COMPONENT, module.id)));
}
// Fetch attempt related data.
@ -379,7 +380,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
// 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);
await this.prefetchAttempt(quiz, quizAccessInfo, attempt, preflightData, siteId);
}));
if (!canStart) {
@ -399,6 +400,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
* Prefetch all WS data for an attempt.
*
* @param quiz Quiz.
* @param accessInfo Quiz access info.
* @param attempt Attempt.
* @param preflightData Preflight required data (like password).
* @param siteId Site ID. If not defined, current site.
@ -406,11 +408,11 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
*/
async prefetchAttempt(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
preflightData: Record<string, string>,
siteId?: string,
): Promise<void> {
const pages = AddonModQuiz.getPagesFromLayout(attempt.layout);
const isSequential = AddonModQuiz.isNavigationSequential(quiz);
let promises: Promise<unknown>[] = [];
@ -420,7 +422,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
siteId,
};
if (AddonModQuiz.isAttemptFinished(attempt.state)) {
if (AddonModQuiz.isAttemptCompleted(attempt.state)) {
// Attempt is finished, get feedback and review data.
const attemptGrade = AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false);
const attemptGradeNumber = attemptGrade !== undefined && Number(attemptGrade);
@ -428,24 +430,17 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
promises.push(AddonModQuiz.getFeedbackForGrade(quiz.id, attemptGradeNumber, modOptions));
}
// Get the review for each page.
pages.forEach((page) => {
promises.push(CoreUtils.ignoreErrors(AddonModQuiz.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));
promises.push(this.prefetchAttemptReview(quiz, accessInfo, attempt, modOptions));
} else {
// Attempt not finished, get data needed to continue the attempt.
promises.push(AddonModQuiz.getAttemptAccessInformation(quiz.id, attempt.id, modOptions));
promises.push(AddonModQuiz.getAttemptSummary(attempt.id, preflightData, modOptions));
if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
if (attempt.state === AddonModQuizAttemptStates.IN_PROGRESS) {
// Get data for each page.
const pages = AddonModQuiz.getPagesFromLayout(attempt.layout);
promises = promises.concat(pages.map(async (page) => {
if (isSequential && typeof attempt.currentpage === 'number' && page < attempt.currentpage) {
// Sequential quiz, cannot get pages before the current one.
@ -472,20 +467,57 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
await Promise.all(promises);
}
/**
* Prefetch attempt review data.
*
* @param quiz Quiz.
* @param accessInfo Quiz access info.
* @param attempt Attempt.
* @param modOptions Other options.
* @param siteId Site ID.
* @returns Promise resolved when done.
*/
protected async prefetchAttemptReview(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
modOptions: CoreCourseCommonModWSOptions,
): Promise<void> {
// Check if attempt can be reviewed.
const canReview = await AddonModQuizHelper.canReviewAttempt(quiz, accessInfo, attempt);
if (!canReview) {
return;
}
const pages = AddonModQuiz.getPagesFromLayout(attempt.layout);
const promises: Promise<unknown>[] = [];
// Get the review for each page.
pages.forEach((page) => {
promises.push(CoreUtils.ignoreErrors(AddonModQuiz.getAttemptReview(attempt.id, {
page,
...modOptions, // Include all options.
})));
});
// Get the review for all questions in same page.
promises.push(this.prefetchAttemptReviewFiles(quiz, attempt, modOptions));
await Promise.all(promises);
}
/**
* Prefetch attempt review and its files.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @param modOptions Other options.
* @param siteId Site ID.
* @returns 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.ignoreErrors(AddonModQuiz.getAttemptReview(attempt.id, {
@ -502,7 +534,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
question,
this.component,
quiz.coursemodule,
siteId,
modOptions.siteId,
attempt.uniqueid,
);
}));
@ -568,7 +600,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
preflightData = await this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId);
// Get data for last attempt.
await this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId);
await this.prefetchAttempt(quiz, quizAccessInfo, lastAttempt, preflightData, siteId);
}
// Prefetch finished, set the right status.
@ -611,8 +643,8 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet
// 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.isAttemptFinished(lastAttempt.state);
const newStatus = isLastFinished ? DownloadStatus.DOWNLOADABLE_NOT_DOWNLOADED : DownloadStatus.DOWNLOADED;
const isLastCompleted = !lastAttempt || AddonModQuiz.isAttemptCompleted(lastAttempt.state);
const newStatus = isLastCompleted ? DownloadStatus.DOWNLOADABLE_NOT_DOWNLOADED : DownloadStatus.DOWNLOADED;
await CoreFilepool.storePackageStatus(options.siteId, newStatus, this.component, quiz.coursemodule);
}

View File

@ -30,10 +30,17 @@ import {
AddonModQuizAttemptWSData,
AddonModQuizCombinedReviewOptions,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizProvider,
AddonModQuizQuizWSData,
} from './quiz';
import { AddonModQuizOffline } from './quiz-offline';
import {
ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD,
AddonModQuizAttemptStates,
AddonModQuizDisplayOptionsAttemptStates,
} from '../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants';
import { CoreGroups } from '@services/groups';
import { CoreTimeUtils } from '@services/utils/time';
/**
* Helper service that provides some features for quiz.
@ -41,6 +48,125 @@ import { AddonModQuizOffline } from './quiz-offline';
@Injectable({ providedIn: 'root' })
export class AddonModQuizHelperProvider {
/**
* Check if current user can review an attempt.
*
* @param quiz Quiz.
* @param accessInfo Access info.
* @param attempt Attempt.
* @returns Whether user can review the attempt.
*/
async canReviewAttempt(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
): Promise<boolean> {
if (!this.hasReviewCapabilityForAttempt(quiz, accessInfo, attempt)) {
return false;
}
if (attempt.userid !== CoreSites.getCurrentSiteUserId()) {
return this.canReviewOtherUserAttempt(quiz, accessInfo, attempt);
}
if (!AddonModQuiz.isAttemptCompleted(attempt.state)) {
// Cannot review own uncompleted attempts.
return false;
}
if (attempt.preview && accessInfo.canpreview) {
// A teacher can always review their own preview no matter the review options settings.
return true;
}
if (!attempt.preview && accessInfo.canviewreports) {
// Users who can see reports should be shown everything, except during preview.
// In LMS, the capability 'moodle/grade:viewhidden' is also checked but the app doesn't have this info.
return true;
}
const options = AddonModQuiz.getDisplayOptionsForQuiz(quiz, AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt));
return options.attempt;
}
/**
* Check if current user can review another user attempt.
*
* @param quiz Quiz.
* @param accessInfo Access info.
* @param attempt Attempt.
* @returns Whether user can review the attempt.
*/
protected async canReviewOtherUserAttempt(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
): Promise<boolean> {
if (!accessInfo.canviewreports) {
return false;
}
try {
const groupInfo = await CoreGroups.getActivityGroupInfo(quiz.coursemodule);
if (groupInfo.canAccessAllGroups || !groupInfo.separateGroups) {
return true;
}
// Check if the current user and the attempt's user share any group.
if (!groupInfo.groups.length) {
return false;
}
const attemptUserGroups = await CoreGroups.getUserGroupsInCourse(quiz.course, undefined, attempt.userid);
return attemptUserGroups.some(attemptUserGroup => groupInfo.groups.find(group => attemptUserGroup.id === group.id));
} catch {
return false;
}
}
/**
* Get cannot review message.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @param short Whether to use a short message or not.
* @returns Cannot review message, or empty string if no message to display.
*/
getCannotReviewMessage(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData, short = false): string {
const displayOption = AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt);
let reviewFrom = 0;
switch (displayOption) {
case AddonModQuizDisplayOptionsAttemptStates.DURING:
return '';
case AddonModQuizDisplayOptionsAttemptStates.IMMEDIATELY_AFTER:
// eslint-disable-next-line no-bitwise
if ((quiz.reviewattempt ?? 0) & AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN) {
reviewFrom = (attempt.timefinish ?? Date.now()) + ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD;
break;
}
// Fall through.
case AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN:
// eslint-disable-next-line no-bitwise
if (quiz.timeclose && ((quiz.reviewattempt ?? 0) & AddonModQuizDisplayOptionsAttemptStates.AFTER_CLOSE)) {
reviewFrom = quiz.timeclose;
break;
}
}
if (reviewFrom) {
return Translate.instant('addon.mod_quiz.noreviewuntil' + (short ? 'short' : ''), {
$a: CoreTimeUtils.userDate(reviewFrom * 1000, short ? 'core.strftimedatetimeshort': undefined),
});
} else {
return Translate.instant('addon.mod_quiz.noreviewattempt');
}
}
/**
* 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.
@ -48,24 +174,14 @@ export class AddonModQuizHelperProvider {
* @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.
* @param options Options.
* @returns 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,
options: GetAndCheckPreflightOptions = {},
): Promise<AddonModQuizAttemptWSData> {
const rules = accessInfo?.activerulenames;
@ -74,30 +190,37 @@ export class AddonModQuizHelperProvider {
const preflightCheckRequired = await AddonModQuizAccessRuleDelegate.isPreflightCheckRequired(
rules,
quiz,
attempt,
prefetch,
siteId,
options.attempt,
options.prefetch,
options.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);
const data = await this.getPreflightData(quiz, accessInfo, options);
// 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.getFixedPreflightData(rules, quiz, preflightData, attempt, prefetch, siteId);
await AddonModQuizAccessRuleDelegate.getFixedPreflightData(
rules,
quiz,
preflightData,
options.attempt,
options.prefetch,
options.siteId,
);
try {
// All the preflight data is gathered, now validate it.
return await this.validatePreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, siteId);
return await this.validatePreflightData(quiz, accessInfo, preflightData, options);
} catch (error) {
if (prefetch) {
if (options.prefetch) {
throw error;
} else if (retrying && !preflightCheckRequired) {
} else if (options.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.
@ -110,17 +233,10 @@ export class AddonModQuizHelperProvider {
CoreDomUtils.showErrorModalDefault(error, 'core.error', true);
}, 100);
return this.getAndCheckPreflightData(
quiz,
accessInfo,
preflightData,
attempt,
offline,
prefetch,
title,
siteId,
true,
);
return this.getAndCheckPreflightData(quiz, accessInfo, preflightData, {
...options,
retrying: true,
});
}
}
@ -129,19 +245,13 @@ export class AddonModQuizHelperProvider {
*
* @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.
* @param options Options.
* @returns Promise resolved with the preflight data. Rejected if user cancels.
*/
async getPreflightData(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt?: AddonModQuizAttemptWSData,
prefetch?: boolean,
title?: string,
siteId?: string,
options: GetPreflightOptions = {},
): Promise<Record<string, string>> {
const notSupported: string[] = [];
const rules = accessInfo?.activerulenames;
@ -163,11 +273,11 @@ export class AddonModQuizHelperProvider {
const modalData = await CoreDomUtils.openModal<Record<string, string>>({
component: AddonModQuizPreflightModalComponent,
componentProps: {
title: title,
title: options.title,
quiz,
attempt,
prefetch: !!prefetch,
siteId: siteId,
attempt: options.attempt,
prefetch: !!options.prefetch,
siteId: options.siteId,
rules: rules,
},
});
@ -252,53 +362,55 @@ export class AddonModQuizHelperProvider {
}
}
/**
* Check if current user has the necessary capabilities to review an attempt.
*
* @param quiz Quiz.
* @param accessInfo Access info.
* @param attempt Attempt.
* @returns Whether user has the capability.
*/
hasReviewCapabilityForAttempt(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
): boolean {
if (accessInfo.canviewreports || accessInfo.canpreview) {
return true;
}
const displayOption = AddonModQuiz.getAttemptStateDisplayOption(quiz, attempt);
return displayOption === AddonModQuizDisplayOptionsAttemptStates.IMMEDIATELY_AFTER ?
accessInfo.canattempt : accessInfo.canreviewmyattempts;
}
/**
* 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.
* @returns Quiz attemptw with calculated data.
* @returns Quiz attempt with calculated data.
*/
async setAttemptCalculatedData(
quiz: AddonModQuizQuizData,
attempt: AddonModQuizAttemptWSData,
highlight?: boolean,
bestGrade?: string,
isLastAttempt?: boolean,
siteId?: string,
): Promise<AddonModQuizAttempt> {
const formattedAttempt = <AddonModQuizAttempt> attempt;
formattedAttempt.rescaledGrade = AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false);
formattedAttempt.finished = AddonModQuiz.isAttemptFinished(attempt.state);
formattedAttempt.readableState = AddonModQuiz.getAttemptReadableState(quiz, attempt);
formattedAttempt.finished = attempt.state === AddonModQuizAttemptStates.FINISHED;
formattedAttempt.completed = AddonModQuiz.isAttemptCompleted(attempt.state);
formattedAttempt.rescaledGrade = Number(AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false));
if (quiz.showMarkColumn && formattedAttempt.finished) {
formattedAttempt.readableMark = AddonModQuiz.formatGrade(attempt.sumgrades, quiz.decimalpoints);
if (quiz.showAttemptsGrades && formattedAttempt.finished) {
formattedAttempt.formattedGrade = AddonModQuiz.formatGrade(formattedAttempt.rescaledGrade, quiz.decimalpoints);
} else {
formattedAttempt.readableMark = '';
formattedAttempt.formattedGrade = '';
}
if (quiz.showGradeColumn && formattedAttempt.finished) {
formattedAttempt.readableGrade = AddonModQuiz.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.isAttemptFinishedOffline(attempt.id, siteId);
}
formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId);
return formattedAttempt;
}
@ -316,11 +428,10 @@ export class AddonModQuizHelperProvider {
formattedQuiz.sumGradesFormatted = AddonModQuiz.formatGrade(quiz.sumgrades, quiz.decimalpoints);
formattedQuiz.gradeFormatted = AddonModQuiz.formatGrade(quiz.grade, quiz.decimalpoints);
formattedQuiz.showAttemptColumn = quiz.attempts != 1;
formattedQuiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX &&
formattedQuiz.showAttemptsGrades = options.someoptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX &&
AddonModQuiz.quizHasGrades(quiz);
formattedQuiz.showMarkColumn = formattedQuiz.showGradeColumn && quiz.grade != quiz.sumgrades;
formattedQuiz.showFeedbackColumn = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;
formattedQuiz.showAttemptsMarks = formattedQuiz.showAttemptsGrades && quiz.grade !== quiz.sumgrades;
formattedQuiz.showFeedback = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;
return formattedQuiz;
}
@ -331,36 +442,32 @@ export class AddonModQuizHelperProvider {
* @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 siteId Site ID. If not defined, current site.
* @param options Options
* @returns 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,
options: ValidatePreflightOptions = {},
): Promise<AddonModQuizAttempt> {
const rules = accessInfo.activerulenames;
const modOptions = {
cmId: quiz.coursemodule,
readingStrategy: offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
siteId,
readingStrategy: options.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
siteId: options.siteId,
};
let attempt = options.attempt;
try {
if (attempt) {
if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) {
if (attempt.state !== AddonModQuizAttemptStates.OVERDUE && !options.finishedOffline) {
// We're continuing an attempt. Call getAttemptData to validate the preflight data.
await AddonModQuiz.getAttemptData(attempt.id, attempt.currentpage ?? 0, preflightData, modOptions);
if (offline) {
if (options.offline) {
// Get current page stored in local.
const storedAttempt = await CoreUtils.ignoreErrors(
AddonModQuizOffline.getAttemptById(attempt.id),
@ -375,7 +482,7 @@ export class AddonModQuizHelperProvider {
}
} else {
// We're starting a new attempt, call startAttempt.
attempt = await AddonModQuiz.startAttempt(quiz.id, preflightData, false, siteId);
attempt = await AddonModQuiz.startAttempt(quiz.id, preflightData, false, options.siteId);
}
// Preflight data validated.
@ -384,8 +491,8 @@ export class AddonModQuizHelperProvider {
quiz,
attempt,
preflightData,
prefetch,
siteId,
options.prefetch,
options.siteId,
);
return attempt;
@ -397,8 +504,8 @@ export class AddonModQuizHelperProvider {
quiz,
attempt,
preflightData,
prefetch,
siteId,
options.prefetch,
options.siteId,
);
}
@ -416,10 +523,9 @@ export const AddonModQuizHelper = makeSingleton(AddonModQuizHelperProvider);
export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
sumGradesFormatted?: string;
gradeFormatted?: string;
showAttemptColumn?: boolean;
showGradeColumn?: boolean;
showMarkColumn?: boolean;
showFeedbackColumn?: boolean;
showAttemptsGrades?: boolean;
showAttemptsMarks?: boolean;
showFeedback?: boolean;
};
/**
@ -427,10 +533,32 @@ export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
*/
export type AddonModQuizAttempt = AddonModQuizAttemptWSData & {
finishedOffline?: boolean;
rescaledGrade?: string;
rescaledGrade?: number;
finished?: boolean;
readableState?: string[];
readableMark?: string;
readableGrade?: string;
highlightGrade?: boolean;
completed?: boolean;
formattedGrade?: string;
};
/**
* Options to validate preflight data.
*/
type ValidatePreflightOptions = {
attempt?: AddonModQuizAttemptWSData; // Attempt to continue. Don't pass any value if the user needs to start a new attempt.
offline?: boolean; // Whether the attempt is offline.
finishedOffline?: boolean; // Whether the attempt is finished offline.
prefetch?: boolean; // Whether user is prefetching.
siteId?: string; // Site ID. If not defined, current site.
};
/**
* Options to check preflight data.
*/
type GetAndCheckPreflightOptions = ValidatePreflightOptions & {
title?: string; // The title to display in the modal and in the submit button.
retrying?: boolean; // Whether we're retrying after a failure.
};
/**
* Options to get preflight data.
*/
type GetPreflightOptions = Omit<GetAndCheckPreflightOptions, 'offline'|'finishedOffline'|'retrying'>;

View File

@ -23,7 +23,8 @@ 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';
import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz';
import { ADDON_MOD_QUIZ_COMPONENT } from '../constants';
/**
* Service to handle offline quiz.
@ -103,7 +104,7 @@ export class AddonModQuizOfflineProvider {
* @returns Promise resolved with the answers.
*/
getAttemptAnswers(attemptId: number, siteId?: string): Promise<CoreQuestionAnswerDBRecord[]> {
return CoreQuestion.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId);
return CoreQuestion.getAttemptAnswers(ADDON_MOD_QUIZ_COMPONENT, attemptId, siteId);
}
/**
@ -149,7 +150,7 @@ export class AddonModQuizOfflineProvider {
await Promise.all(questions.map(async (question) => {
const dbQuestion = await CoreUtils.ignoreErrors(
CoreQuestion.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId),
CoreQuestion.getQuestion(ADDON_MOD_QUIZ_COMPONENT, attemptId, question.slot, siteId),
);
if (!dbQuestion) {
@ -230,8 +231,8 @@ export class AddonModQuizOfflineProvider {
const db = await CoreSites.getSiteDb(siteId);
await Promise.all([
CoreQuestion.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId),
CoreQuestion.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId),
CoreQuestion.removeAttemptAnswers(ADDON_MOD_QUIZ_COMPONENT, attemptId, siteId),
CoreQuestion.removeAttemptQuestions(ADDON_MOD_QUIZ_COMPONENT, attemptId, siteId),
db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }),
]);
}
@ -248,8 +249,8 @@ export class AddonModQuizOfflineProvider {
siteId = siteId || CoreSites.getCurrentSiteId();
await Promise.all([
CoreQuestion.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
CoreQuestion.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId),
CoreQuestion.removeQuestion(ADDON_MOD_QUIZ_COMPONENT, attemptId, slot, siteId),
CoreQuestion.removeQuestionAnswers(ADDON_MOD_QUIZ_COMPONENT, attemptId, slot, siteId),
]);
}
@ -299,7 +300,7 @@ export class AddonModQuizOfflineProvider {
const state = await CoreQuestionBehaviourDelegate.determineNewState(
quiz.preferredbehaviour ?? '',
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
attempt.id,
question,
quiz.coursemodule,
@ -312,12 +313,12 @@ export class AddonModQuizOfflineProvider {
}
// Delete previously stored answers for this question.
await CoreQuestion.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId);
await CoreQuestion.removeQuestionAnswers(ADDON_MOD_QUIZ_COMPONENT, attempt.id, question.slot, siteId);
}));
// Now save the answers.
await CoreQuestion.saveAnswers(
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quiz.id,
attempt.id,
attempt.userid ?? CoreSites.getCurrentSiteUserId(),
@ -332,7 +333,7 @@ export class AddonModQuizOfflineProvider {
const question = questionsWithAnswers[Number(slot)];
await CoreQuestion.saveQuestion(
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quiz.id,
attempt.id,
attempt.userid ?? CoreSites.getCurrentSiteUserId(),

View File

@ -29,8 +29,9 @@ import { makeSingleton, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModQuizAttemptDBRecord } from './database/quiz';
import { AddonModQuizPrefetchHandler } from './handlers/prefetch';
import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz';
import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz';
import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline';
import { ADDON_MOD_QUIZ_COMPONENT } from '../constants';
/**
* Service to sync quizzes.
@ -79,7 +80,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
for (const slot in options.onlineQuestions) {
promises.push(CoreQuestionDelegate.deleteOfflineData(
options.onlineQuestions[slot],
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quiz.coursemodule,
siteId,
));
@ -104,13 +105,13 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
// Check if online attempt was finished because of the sync.
let attemptFinished = false;
if (options.onlineAttempt && !AddonModQuiz.isAttemptFinished(options.onlineAttempt.state)) {
if (options.onlineAttempt && !AddonModQuiz.isAttemptCompleted(options.onlineAttempt.state)) {
// Attempt wasn't finished at start. Check if it's finished now.
const attempts = await AddonModQuiz.getUserAttempts(quiz.id, { cmId: quiz.coursemodule, siteId });
const attempt = attempts.find(attempt => attempt.id == options?.onlineAttempt?.id);
attemptFinished = attempt ? AddonModQuiz.isAttemptFinished(attempt.state) : false;
attemptFinished = attempt ? AddonModQuiz.isAttemptCompleted(attempt.state) : false;
}
return { warnings, attemptFinished, updated: !!options.updated || !!options.removeAttempt };
@ -204,7 +205,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
}
quizIds[attempt.quizid] = true;
if (CoreSync.isBlocked(AddonModQuizProvider.COMPONENT, attempt.quizid, siteId)) {
if (CoreSync.isBlocked(ADDON_MOD_QUIZ_COMPONENT, attempt.quizid, siteId)) {
return;
}
@ -268,7 +269,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
}
// Verify that quiz isn't blocked.
if (CoreSync.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) {
if (CoreSync.isBlocked(ADDON_MOD_QUIZ_COMPONENT, quiz.id, siteId)) {
this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.');
throw new CoreError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
@ -300,7 +301,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
// Sync offline logs.
await CoreUtils.ignoreErrors(
CoreCourseLogHelper.syncActivity(AddonModQuizProvider.COMPONENT, quiz.id, siteId),
CoreCourseLogHelper.syncActivity(ADDON_MOD_QUIZ_COMPONENT, quiz.id, siteId),
);
// Get all the offline attempts for the quiz. It should always be 0 or 1 attempt
@ -323,7 +324,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined;
const onlineAttempt = onlineAttempts.find((attempt) => attempt.id == offlineAttempt.id);
if (!onlineAttempt || AddonModQuiz.isAttemptFinished(onlineAttempt.state)) {
if (!onlineAttempt || AddonModQuiz.isAttemptCompleted(onlineAttempt.state)) {
// Attempt not found or it's finished in online. Discard it.
warnings.push(Translate.instant('addon.mod_quiz.warningattemptfinished'));
@ -381,7 +382,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
await CoreQuestionDelegate.prepareSyncData(
onlineQuestion,
offlineQuestions[slot].answers,
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quiz.coursemodule,
siteId,
);

View File

@ -37,13 +37,24 @@ import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWar
import { makeSingleton, Translate } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate';
import { AddonModQuizAttempt } from './quiz-helper';
import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline';
import { AddonModQuizAutoSyncData, AddonModQuizSyncProvider } from './quiz-sync';
import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site';
import { QUESTION_INVALID_STATE_CLASSES, QUESTION_TODO_STATE_CLASSES } from '@features/question/constants';
const ROOT_CACHE_KEY = 'mmaModQuiz:';
import {
QUESTION_INVALID_STATE_CLASSES,
QUESTION_TODO_STATE_CLASSES,
QuestionDisplayOptionsMarks,
QuestionDisplayOptionsValues,
} from '@features/question/constants';
import {
ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT,
AddonModQuizAttemptStates,
ADDON_MOD_QUIZ_COMPONENT,
AddonModQuizGradeMethods,
AddonModQuizDisplayOptionsAttemptStates,
ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD,
} from '../constants';
import { CoreIonicColorNames } from '@singletons/colors';
declare module '@singletons/events' {
@ -53,7 +64,7 @@ declare module '@singletons/events' {
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[AddonModQuizProvider.ATTEMPT_FINISHED_EVENT]: AddonModQuizAttemptFinishedData;
[ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT]: AddonModQuizAttemptFinishedData;
[AddonModQuizSyncProvider.AUTO_SYNCED]: AddonModQuizAutoSyncData;
}
@ -65,27 +76,7 @@ declare module '@singletons/events' {
@Injectable({ providedIn: 'root' })
export class AddonModQuizProvider {
static readonly COMPONENT = 'mmaModQuiz';
static readonly ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished';
// Grade methods.
static readonly GRADEHIGHEST = 1;
static readonly GRADEAVERAGE = 2;
static readonly ATTEMPTFIRST = 3;
static readonly ATTEMPTLAST = 4;
// Question options.
static readonly QUESTION_OPTIONS_MAX_ONLY = 1;
static readonly QUESTION_OPTIONS_MARK_AND_MAX = 2;
// Attempt state.
static readonly ATTEMPT_IN_PROGRESS = 'inprogress';
static readonly ATTEMPT_OVERDUE = 'overdue';
static readonly ATTEMPT_FINISHED = 'finished';
static readonly ATTEMPT_ABANDONED = 'abandoned';
// Show the countdown timer if there is less than this amount of time left before the the quiz close date.
static readonly QUIZ_SHOW_TIME_BEFORE_DEADLINE = 3600;
protected static readonly ROOT_CACHE_KEY = 'mmaModQuiz:';
protected logger: CoreLogger;
@ -164,7 +155,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getAttemptAccessInformationCommonCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'attemptAccessInformation:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'attemptAccessInformation:' + quizId;
}
/**
@ -189,7 +180,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getAttemptAccessInformationCacheKey(quizId, attemptId),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -215,7 +206,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getAttemptDataCommonCacheKey(attemptId: number): string {
return ROOT_CACHE_KEY + 'attemptData:' + attemptId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'attemptData:' + attemptId;
}
/**
@ -248,7 +239,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getAttemptDataCacheKey(attemptId, page),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -288,10 +279,10 @@ export class AddonModQuizProvider {
}
switch (attempt.state) {
case AddonModQuizProvider.ATTEMPT_IN_PROGRESS:
case AddonModQuizAttemptStates.IN_PROGRESS:
return dueDate * 1000;
case AddonModQuizProvider.ATTEMPT_OVERDUE:
case AddonModQuizAttemptStates.OVERDUE:
return (dueDate + (quiz.graceperiod ?? 0)) * 1000;
default:
@ -311,7 +302,7 @@ export class AddonModQuizProvider {
getAttemptDueDateWarning(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): string | undefined {
const dueDate = this.getAttemptDueDate(quiz, attempt);
if (attempt.state === AddonModQuizProvider.ATTEMPT_OVERDUE) {
if (attempt.state === AddonModQuizAttemptStates.OVERDUE) {
return Translate.instant(
'addon.mod_quiz.overduemustbesubmittedby',
{ $a: CoreTimeUtils.userDate(dueDate) },
@ -322,73 +313,158 @@ export class AddonModQuizProvider {
}
/**
* Turn attempt's state into a readable state, including some extra data depending on the state.
* Get the display option value related to the attempt state.
* Equivalent to LMS quiz_attempt_state.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @returns List of state sentences.
* @returns Display option value.
*/
getAttemptReadableState(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttempt): string[] {
if (attempt.finishedOffline) {
return [Translate.instant('addon.mod_quiz.finishnotsynced')];
getAttemptStateDisplayOption(
quiz: AddonModQuizQuizWSData,
attempt: AddonModQuizAttemptWSData,
): AddonModQuizDisplayOptionsAttemptStates {
if (attempt.state === AddonModQuizAttemptStates.IN_PROGRESS) {
return AddonModQuizDisplayOptionsAttemptStates.DURING;
} else if (quiz.timeclose && Date.now() >= quiz.timeclose * 1000) {
return AddonModQuizDisplayOptionsAttemptStates.AFTER_CLOSE;
} else if (Date.now() < ((attempt.timefinish ?? 0) + ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD) * 1000) {
return AddonModQuizDisplayOptionsAttemptStates.IMMEDIATELY_AFTER;
}
switch (attempt.state) {
case AddonModQuizProvider.ATTEMPT_IN_PROGRESS:
return [Translate.instant('addon.mod_quiz.stateinprogress')];
return AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN;
}
case AddonModQuizProvider.ATTEMPT_OVERDUE: {
const sentences: string[] = [];
const dueDate = this.getAttemptDueDate(quiz, attempt);
/**
* Get display options for a certain quiz.
* Equivalent to LMS display_options::make_from_quiz.
*
* @param quiz Quiz.
* @param state State.
* @returns Display options.
*/
getDisplayOptionsForQuiz(
quiz: AddonModQuizQuizWSData,
state: AddonModQuizDisplayOptionsAttemptStates,
): AddonModQuizDisplayOptions {
const marksOption = this.calculateDisplayOptionValue(
quiz.reviewmarks ?? 0,
state,
QuestionDisplayOptionsMarks.MARK_AND_MAX,
QuestionDisplayOptionsMarks.MAX_ONLY,
);
const feedbackOption = this.calculateDisplayOptionValue(quiz.reviewspecificfeedback ?? 0, state);
sentences.push(Translate.instant('addon.mod_quiz.stateoverdue'));
return {
attempt: this.calculateDisplayOptionValue(quiz.reviewattempt ?? 0, state, true, false),
correctness: this.calculateDisplayOptionValue(quiz.reviewcorrectness ?? 0, state),
marks: quiz.reviewmaxmarks !== undefined ?
this.calculateDisplayOptionValue<QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues>(
quiz.reviewmaxmarks,
state,
marksOption,
QuestionDisplayOptionsValues.HIDDEN,
) :
marksOption,
feedback: feedbackOption,
generalfeedback: this.calculateDisplayOptionValue(quiz.reviewgeneralfeedback ?? 0, state),
rightanswer: this.calculateDisplayOptionValue(quiz.reviewrightanswer ?? 0, state),
overallfeedback: this.calculateDisplayOptionValue(quiz.reviewoverallfeedback ?? 0, state),
numpartscorrect: feedbackOption,
manualcomment: feedbackOption,
markdp: quiz.questiondecimalpoints !== undefined && quiz.questiondecimalpoints !== -1 ?
quiz.questiondecimalpoints :
(quiz.decimalpoints ?? 0),
};
}
if (dueDate) {
sentences.push(Translate.instant(
'addon.mod_quiz.stateoverduedetails',
{ $a: CoreTimeUtils.userDate(dueDate) },
));
}
/**
* Calculate the value for a certain display option.
*
* @param setting Setting value related to the option.
* @param state Display options state.
* @param whenSet Value to return if setting is set.
* @param whenNotSet Value to return if setting is not set.
* @returns Display option.
*/
protected calculateDisplayOptionValue<T = AddonModQuizDisplayOptionValue>(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
whenSet: T,
whenNotSet: T,
): T;
protected calculateDisplayOptionValue(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
): QuestionDisplayOptionsValues;
protected calculateDisplayOptionValue(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
whenSet: AddonModQuizDisplayOptionValue = QuestionDisplayOptionsValues.VISIBLE,
whenNotSet: AddonModQuizDisplayOptionValue = QuestionDisplayOptionsValues.HIDDEN,
): AddonModQuizDisplayOptionValue {
// eslint-disable-next-line no-bitwise
if (setting & state) {
return whenSet;
}
return sentences;
}
return whenNotSet;
}
case AddonModQuizProvider.ATTEMPT_FINISHED:
return [
Translate.instant('addon.mod_quiz.statefinished'),
Translate.instant(
'addon.mod_quiz.statefinisheddetails',
{ $a: CoreTimeUtils.userDate((attempt.timefinish ?? 0) * 1000) },
),
];
/**
* Turn attempt's state into a readable state name.
*
* @param state State.
* @param finishedOffline Whether the attempt was finished offline.
* @returns Readable state name.
*/
getAttemptReadableStateName(state: string, finishedOffline = false): string {
if (finishedOffline) {
return Translate.instant('core.submittedoffline');
}
case AddonModQuizProvider.ATTEMPT_ABANDONED:
return [Translate.instant('addon.mod_quiz.stateabandoned')];
switch (state) {
case AddonModQuizAttemptStates.IN_PROGRESS:
return Translate.instant('addon.mod_quiz.stateinprogress');
case AddonModQuizAttemptStates.OVERDUE:
return Translate.instant('addon.mod_quiz.stateoverdue');
case AddonModQuizAttemptStates.FINISHED:
return Translate.instant('addon.mod_quiz.statefinished');
case AddonModQuizAttemptStates.ABANDONED:
return Translate.instant('addon.mod_quiz.stateabandoned');
default:
return [];
return '';
}
}
/**
* Turn attempt's state into a readable state name, without any more data.
* Get the color to apply to the attempt state.
*
* @param state State.
* @returns Readable state name.
* @param finishedOffline Whether the attempt was finished offline.
* @returns State color.
*/
getAttemptReadableStateName(state: string): string {
getAttemptStateColor(state: string, finishedOffline = false): string {
if (finishedOffline) {
return CoreIonicColorNames.MEDIUM;
}
switch (state) {
case AddonModQuizProvider.ATTEMPT_IN_PROGRESS:
return Translate.instant('addon.mod_quiz.stateinprogress');
case AddonModQuizAttemptStates.IN_PROGRESS:
return CoreIonicColorNames.WARNING;
case AddonModQuizProvider.ATTEMPT_OVERDUE:
return Translate.instant('addon.mod_quiz.stateoverdue');
case AddonModQuizAttemptStates.OVERDUE:
return CoreIonicColorNames.INFO;
case AddonModQuizProvider.ATTEMPT_FINISHED:
return Translate.instant('addon.mod_quiz.statefinished');
case AddonModQuizAttemptStates.FINISHED:
return CoreIonicColorNames.SUCCESS;
case AddonModQuizProvider.ATTEMPT_ABANDONED:
return Translate.instant('addon.mod_quiz.stateabandoned');
case AddonModQuizAttemptStates.ABANDONED:
return CoreIonicColorNames.DANGER;
default:
return '';
@ -413,7 +489,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getAttemptReviewCommonCacheKey(attemptId: number): string {
return ROOT_CACHE_KEY + 'attemptReview:' + attemptId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'attemptReview:' + attemptId;
}
/**
@ -437,8 +513,7 @@ export class AddonModQuizProvider {
};
const preSets = {
cacheKey: this.getAttemptReviewCacheKey(attemptId, page),
cacheErrors: ['noreview'],
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -457,7 +532,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getAttemptSummaryCacheKey(attemptId: number): string {
return ROOT_CACHE_KEY + 'attemptSummary:' + attemptId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'attemptSummary:' + attemptId;
}
/**
@ -487,7 +562,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getAttemptSummaryCacheKey(attemptId),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -521,7 +596,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getCombinedReviewOptionsCommonCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'combinedReviewOptions:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'combinedReviewOptions:' + quizId;
}
/**
@ -544,7 +619,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getCombinedReviewOptionsCacheKey(quizId, userId),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -581,7 +656,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getFeedbackForGradeCommonCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'feedbackForGrade:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'feedbackForGrade:' + quizId;
}
/**
@ -606,7 +681,7 @@ export class AddonModQuizProvider {
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getFeedbackForGradeCacheKey(quizId, grade),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -664,12 +739,12 @@ export class AddonModQuizProvider {
}
/**
* Given a list of attempts, returns the last finished attempt.
* Given a list of attempts, returns the last completed attempt.
*
* @param attempts Attempts sorted. First attempt should be the first on the list.
* @returns Last finished attempt.
* @returns Last completed attempt.
*/
getLastFinishedAttemptFromList(attempts?: AddonModQuizAttemptWSData[]): AddonModQuizAttemptWSData | undefined {
getLastCompletedAttemptFromList(attempts?: AddonModQuizAttemptWSData[]): AddonModQuizAttemptWSData | undefined {
if (!attempts) {
return;
}
@ -677,7 +752,7 @@ export class AddonModQuizProvider {
for (let i = attempts.length - 1; i >= 0; i--) {
const attempt = attempts[i];
if (this.isAttemptFinished(attempt.state)) {
if (this.isAttemptCompleted(attempt.state)) {
return attempt;
}
}
@ -719,7 +794,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getQuizDataCacheKey(courseId: number): string {
return ROOT_CACHE_KEY + 'quiz:' + courseId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'quiz:' + courseId;
}
/**
@ -746,7 +821,7 @@ export class AddonModQuizProvider {
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getQuizDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -797,7 +872,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getQuizAccessInformationCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'quizAccessInformation:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'quizAccessInformation:' + quizId;
}
/**
@ -818,7 +893,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getQuizAccessInformationCacheKey(quizId),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -842,13 +917,13 @@ export class AddonModQuizProvider {
}
switch (method) {
case AddonModQuizProvider.GRADEHIGHEST:
case AddonModQuizGradeMethods.HIGHEST_GRADE:
return Translate.instant('addon.mod_quiz.gradehighest');
case AddonModQuizProvider.GRADEAVERAGE:
case AddonModQuizGradeMethods.AVERAGE_GRADE:
return Translate.instant('addon.mod_quiz.gradeaverage');
case AddonModQuizProvider.ATTEMPTFIRST:
case AddonModQuizGradeMethods.FIRST_ATTEMPT:
return Translate.instant('addon.mod_quiz.attemptfirst');
case AddonModQuizProvider.ATTEMPTLAST:
case AddonModQuizGradeMethods.LAST_ATTEMPT:
return Translate.instant('addon.mod_quiz.attemptlast');
default:
return '';
@ -862,7 +937,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getQuizRequiredQtypesCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'quizRequiredQtypes:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'quizRequiredQtypes:' + quizId;
}
/**
@ -881,7 +956,7 @@ export class AddonModQuizProvider {
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getQuizRequiredQtypesCacheKey(quizId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -1015,7 +1090,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getUserAttemptsCommonCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'userAttempts:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'userAttempts:' + quizId;
}
/**
@ -1045,7 +1120,7 @@ export class AddonModQuizProvider {
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getUserAttemptsCacheKey(quizId, userId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -1073,7 +1148,7 @@ export class AddonModQuizProvider {
* @returns Cache key.
*/
protected getUserBestGradeCommonCacheKey(quizId: number): string {
return ROOT_CACHE_KEY + 'userBestGrade:' + quizId;
return AddonModQuizProvider.ROOT_CACHE_KEY + 'userBestGrade:' + quizId;
}
/**
@ -1093,7 +1168,7 @@ export class AddonModQuizProvider {
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getUserBestGradeCacheKey(quizId, userId),
component: AddonModQuizProvider.COMPONENT,
component: ADDON_MOD_QUIZ_COMPONENT,
componentId: options.cmId,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
@ -1424,13 +1499,13 @@ export class AddonModQuizProvider {
}
/**
* Check if an attempt is finished based on its state.
* Check if an attempt is "completed": finished or abandoned.
*
* @param state Attempt's state.
* @returns Whether it's finished.
*/
isAttemptFinished(state?: string): boolean {
return state == AddonModQuizProvider.ATTEMPT_FINISHED || state == AddonModQuizProvider.ATTEMPT_ABANDONED;
isAttemptCompleted(state?: string): boolean {
return state === AddonModQuizAttemptStates.FINISHED || state === AddonModQuizAttemptStates.ABANDONED;
}
/**
@ -1461,7 +1536,7 @@ export class AddonModQuizProvider {
* @returns Whether it's nearly over or over.
*/
isAttemptTimeNearlyOver(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): boolean {
if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
if (attempt.state !== AddonModQuizAttemptStates.IN_PROGRESS) {
// Attempt not in progress, return true.
return true;
}
@ -1600,7 +1675,7 @@ export class AddonModQuizProvider {
return CoreCourseLogHelper.log(
'mod_quiz_view_attempt_review',
params,
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quizId,
siteId,
);
@ -1633,7 +1708,7 @@ export class AddonModQuizProvider {
return CoreCourseLogHelper.log(
'mod_quiz_view_attempt_summary',
params,
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
quizId,
siteId,
);
@ -1654,7 +1729,7 @@ export class AddonModQuizProvider {
return CoreCourseLogHelper.log(
'mod_quiz_view_quiz',
params,
AddonModQuizProvider.COMPONENT,
ADDON_MOD_QUIZ_COMPONENT,
id,
siteId,
);
@ -1897,7 +1972,7 @@ export class AddonModQuizProvider {
shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number): boolean {
const timeNow = CoreTimeUtils.timestamp();
if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
if (attempt.state !== AddonModQuizAttemptStates.IN_PROGRESS) {
return false;
}
@ -2205,6 +2280,7 @@ export type AddonModQuizQuizWSData = {
questiondecimalpoints?: number; // Number of decimal points to use when displaying question grades.
reviewattempt?: number; // Whether users are allowed to review their quiz attempts at various times.
reviewcorrectness?: number; // Whether users are allowed to review their quiz attempts at various times.
reviewmaxmarks?: number; // @since 4.3. Whether users are allowed to review their quiz attempts at various times.
reviewmarks?: number; // Whether users are allowed to review their quiz attempts at various times.
reviewspecificfeedback?: number; // Whether users are allowed to review their quiz attempts at various times.
reviewgeneralfeedback?: number; // Whether users are allowed to review their quiz attempts at various times.
@ -2392,10 +2468,31 @@ export type AddonModQuizViewQuizWSParams = {
};
/**
* Data passed to ATTEMPT_FINISHED_EVENT event.
* Data passed to ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT event.
*/
export type AddonModQuizAttemptFinishedData = {
quizId: number;
attemptId: number;
synced: boolean;
};
/**
* Quiz display option value.
*/
export type AddonModQuizDisplayOptionValue = QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues | boolean;
/**
* Quiz display options, it can be used to determine which options to display.
*/
export type AddonModQuizDisplayOptions = {
attempt: boolean;
correctness: QuestionDisplayOptionsValues;
marks: QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues;
feedback: QuestionDisplayOptionsValues;
generalfeedback: QuestionDisplayOptionsValues;
rightanswer: QuestionDisplayOptionsValues;
overallfeedback: QuestionDisplayOptionsValues;
numpartscorrect: QuestionDisplayOptionsValues;
manualcomment: QuestionDisplayOptionsValues;
markdp: number;
};

View File

@ -0,0 +1,59 @@
@addon_mod_quiz @app @javascript
Feature: View list of attempts in the app
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username |
| student1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
And quiz "Quiz 1" contains the following questions:
| question | page |
| TF1 | 1 |
And user "student1" has attempted "Quiz 1" with responses:
| slot | response |
| 1 | True |
And user "student1" has started an attempt at quiz "Quiz 1"
Scenario: View finished and in progress attempts
Given I entered the quiz activity "Quiz 1" on course "Course 1" as "student1" in the app
Then I should find "In progress" within "Attempt 2" "ion-item" in the app
And I should find "Finished" within "Attempt 1" "ion-item" in the app
And I should find "100 / 100" within "Attempt 1" "ion-item" in the app
But I should not find "100" within "Attempt 2" "ion-item" in the app
And I should not find "Started" within "Your attempts" "ion-card" in the app
And I should not find "Completed" within "Your attempts" "ion-card" in the app
And I should not find "Marks" within "Your attempts" "ion-card" in the app
And I should not be able to press "Review" in the app
When I press "Attempt 1" in the app
Then I should find "Started" within "Your attempts" "ion-card" in the app
And I should find "Completed" in the app
And I should find "1/1" within "Marks" "ion-item" in the app
And I should be able to press "Review" in the app
Scenario: View abandoned attempts
Given the attempt at "Quiz 1" by "student1" was never submitted
And I entered the quiz activity "Quiz 1" on course "Course 1" as "student1" in the app
Then I should find "Never submitted" within "Attempt 2" "ion-item" in the app
But I should not find "100" within "Attempt 2" "ion-item" in the app
When I press "Attempt 2" in the app
Then I should find "Started" within "Your attempts" "ion-card" in the app
And I should be able to press "Review" in the app
But I should not find "Completed" in the app
And I should not find "Marks" in the app
And I should not find "Grade" in the app

View File

@ -126,10 +126,10 @@ Feature: Attempt a quiz in app
When I press "Submit all and finish" in the app
And I press "Submit" near "Once you submit" in the app
Then I should find "Review" in the app
And I should find "Started on" in the app
And I should find "State" in the app
And I should find "Completed on" in the app
And I should find "Time taken" in the app
And I should find "Started" in the app
And I should find "Status" in the app
And I should find "Completed" in the app
And I should find "Duration" in the app
And I should find "Marks" in the app
And I should find "Grade" in the app
And I should find "Question 1" in the app
@ -203,8 +203,9 @@ Feature: Attempt a quiz in app
And I press "Submit" in the app
Then I should find "Review" in the app
When I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(1) p:nth-child(2)" with "[Started on date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(3) p:nth-child(2)" with "[Completed on date]"
When I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(2) p:nth-child(2)" with "[Started date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(3) p:nth-child(2)" with "[Completed date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(4) p:nth-child(2)" with "[Duration]"
Then the UI should match the snapshot
Given I open a browser tab with url "$WWWROOT"

View File

@ -127,10 +127,10 @@ Feature: Attempt a quiz in app
When I press "Submit all and finish" in the app
And I press "Submit" near "Once you submit" in the app
Then I should find "Review" in the app
And I should find "Started on" in the app
And I should find "State" in the app
And I should find "Completed on" in the app
And I should find "Time taken" in the app
And I should find "Started" in the app
And I should find "Status" in the app
And I should find "Completed" in the app
And I should find "Duration" in the app
And I should find "Marks" in the app
And I should find "Grade" in the app
And I should find "Question 1" in the app
@ -203,9 +203,6 @@ Feature: Attempt a quiz in app
And I press "Submit" in the app
Then I should find "Review" in the app
When I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(1) p:nth-child(2)" with "[Started on date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(3) p:nth-child(2)" with "[Completed on date]"
Given I open a browser tab with url "$WWWROOT"
When I am on the "quiz1" Activity page logged in as teacher1
And I follow "Attempts: 1"

View File

@ -129,10 +129,10 @@ Feature: Attempt a quiz in app
When I press "Submit all and finish" in the app
And I press "Submit" near "Once you submit" in the app
Then I should find "Review" in the app
And I should find "Started on" in the app
And I should find "Completed on" in the app
And I should find "Time taken" in the app
And I should find "Finished" within "State" "ion-item" in the app
And I should find "Started" in the app
And I should find "Completed" in the app
And I should find "Duration" in the app
And I should find "Finished" within "Status" "ion-item" in the app
And I should find "0 out of 1" within "Logic" "ion-item" in the app
And I should find "0 out of 1" within "Cognition" "ion-item" in the app
And I should find "0/2" within "Marks" "ion-item" in the app
@ -151,14 +151,14 @@ Feature: Attempt a quiz in app
| \mod_quiz\event\attempt_summary_viewed | quiz | Quiz 1 | Course 1 | |
When I press the back button in the app
And I press "Finished" in the app
And I press "Attempt 1" in the app
Then I should find "1" within "Attempt" "ion-item" in the app
And I should find "Finished" within "State" "ion-item" in the app
And I should find "0" within "Logic / 1" "ion-item" in the app
And I should find "0" within "Cognition / 1" "ion-item" in the app
And I should find "0" within "Marks / 2" "ion-item" in the app
And I should find "0" within "Grade / 100" "ion-item" in the app
And I should find "Review" in the app
And I should find "Finished" within "Status" "ion-item" in the app
And I should find "0 out of 1" within "Logic" "ion-item" in the app
And I should find "0 out of 1" within "Cognition" "ion-item" in the app
And I should find "0/2" within "Marks" "ion-item" in the app
And I should find "0 out of 100" within "Grade" "ion-item" in the app
And I should be able to press "Review" in the app
Scenario: Attempt a quiz (all question types)
Given I entered the quiz activity "Quiz 2" on course "Course 1" as "student1" in the app
@ -233,8 +233,9 @@ Feature: Attempt a quiz in app
When I press "Submit" in the app
Then I should find "Review" in the app
When I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(1) p:nth-child(2)" with "[Started on date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(3) p:nth-child(2)" with "[Completed on date]"
When I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(2) p:nth-child(2)" with "[Started date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(3) p:nth-child(2)" with "[Completed date]"
And I replace "/.*/" within "page-addon-mod-quiz-review core-loading > ion-card ion-item:nth-child(4) p:nth-child(2)" with "[Duration]"
Then the UI should match the snapshot
Given I open a browser tab with url "$WWWROOT"

View File

@ -0,0 +1,88 @@
@addon_mod_quiz @app @javascript
Feature: Users can only review attempts that are allowed to be reviewed
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username |
| student1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber | timeclose | attemptimmediately | attemptopen | attemptclosed |
| quiz | Quiz never review | C1 | quiz1 | ## 31 December 2035 23:59 ## | 0 | 0 | 0 |
| quiz | Quiz review when closed | C1 | quiz2 | ## 31 December 2035 23:59 ## | 0 | 0 | 1 |
| quiz | Quiz review after immed | C1 | quiz3 | 0 | 0 | 1 | 1 |
| quiz | Quiz review only immed | C1 | quiz4 | 0 | 1 | 0 | 0 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
And quiz "Quiz never review" contains the following questions:
| question | page |
| TF1 | 1 |
And quiz "Quiz review when closed" contains the following questions:
| question | page |
| TF1 | 1 |
And quiz "Quiz review after immed" contains the following questions:
| question | page |
| TF1 | 1 |
And quiz "Quiz review only immed" contains the following questions:
| question | page |
| TF1 | 1 |
And user "student1" has attempted "Quiz never review" with responses:
| slot | response |
| 1 | True |
And user "student1" has attempted "Quiz review when closed" with responses:
| slot | response |
| 1 | True |
And user "student1" has attempted "Quiz review after immed" with responses:
| slot | response |
| 1 | True |
And user "student1" has attempted "Quiz review only immed" with responses:
| slot | response |
| 1 | True |
Scenario: Can review only when the attempt is allowed to be reviewed
Given I entered the quiz activity "Quiz review after immed" on course "Course 1" as "student1" in the app
And I press "Attempt 1" in the app
Then I should not be able to press "Review" in the app
When I press the back button in the app
And I press "Quiz review only immed" in the app
And I press "Attempt 1" in the app
And I press "Review" in the app
Then I should find "Question 1" in the app
# Wait the "immediate after" time and check that now the behaviour is the opposite.
When I press the back button in the app
And I press the back button in the app
And I wait "120" seconds
And I press "Quiz review only immed" in the app
And I press "Attempt 1" in the app
Then I should find "You are not allowed to review this attempt" in the app
And I should not be able to press "Review" in the app
When I press the back button in the app
And I press "Quiz review after immed" in the app
And I press "Attempt 1" in the app
And I press "Review" in the app
Then I should find "Question 1" in the app
When I press the back button in the app
And I press the back button in the app
And I press "Quiz never review" in the app
And I press "Attempt 1" in the app
Then I should find "You are not allowed to review this attempt" in the app
And I should not be able to press "Review" in the app
When I press the back button in the app
And I press "Quiz review when closed" in the app
And I press "Attempt 1" in the app
Then I should find "Available 31/12/35, 23:59" in the app
And I should not be able to press "Review" in the app

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -11,6 +11,7 @@
"grade": "Grade",
"gradebook": "Gradebook",
"gradeitem": "Grade item",
"gradelong": "{{$a.grade}} / {{$a.max}}",
"gradepass": "Grade to pass",
"grades": "Grades",
"lettergrade": "Letter grade",

View File

@ -19,3 +19,21 @@ export const QUESTION_NEEDS_GRADING_STATE_CLASSES = ['requiresgrading', 'complet
export const QUESTION_FINISHED_STATE_CLASSES = ['complete'] as const;
export const QUESTION_GAVE_UP_STATE_CLASSES = ['notanswered'] as const;
export const QUESTION_GRADED_STATE_CLASSES = ['complete', 'incorrect', 'partiallycorrect', 'correct'] as const;
/**
* Possible values to display marks in a question.
*/
export const enum QuestionDisplayOptionsMarks {
MAX_ONLY = 1,
MARK_AND_MAX = 2,
}
/**
* Possible values that most of the display options take.
*/
export const enum QuestionDisplayOptionsValues {
SHOW_ALL = -1,
HIDDEN = 0,
VISIBLE = 1,
EDITABLE = 2,
}

View File

@ -327,6 +327,7 @@
"strftimetime12": "%I:%M %p",
"strftimetime24": "%H:%M",
"submit": "Submit",
"submittedoffline": "Submitted (Offline)",
"success": "Success",
"summary": "Summary",
"swipenavigationtourdescription": "Swipe left and right to navigate around.",

View File

@ -56,6 +56,10 @@ export class TestingBehatDomUtilsService {
}
}
if (element.slot === 'content' && element.parentElement?.tagName === 'ION-ACCORDION') {
return element.parentElement.classList.contains('accordion-expanded');
}
if (!container) {
return true;
}