diff --git a/scripts/langindex.json b/scripts/langindex.json index 302324170..e9d1ac227 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts b/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts index 27488cdde..919fabc41 100644 --- a/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts +++ b/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts @@ -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; } diff --git a/src/addons/mod/quiz/components/attempt-info/attempt-info.html b/src/addons/mod/quiz/components/attempt-info/attempt-info.html new file mode 100644 index 000000000..a23777ddd --- /dev/null +++ b/src/addons/mod/quiz/components/attempt-info/attempt-info.html @@ -0,0 +1,78 @@ + + +

{{ 'addon.mod_quiz.attemptstate' | translate }}

+ +
+
+ + + +

{{ 'addon.mod_quiz.startedon' | translate }}

+

{{ attempt.timestart! * 1000 | coreFormatDate }}

+
+
+ +@if (isFinished) { + + +

{{ 'addon.mod_quiz.completedon' | translate }}

+

{{ attempt.timefinish! * 1000 | coreFormatDate }}

+
+
+ +@if (timeTaken) { + + +

{{ 'addon.mod_quiz.attemptduration' | translate }}

+

{{ timeTaken }}

+
+
+} + +@if (overTime) { + + +

{{ 'addon.mod_quiz.overdue' | translate }}

+

{{ overTime }}

+
+
+} + +@for (gradeItemMark of gradeItemMarks; track $index) { + + +

{{ gradeItemMark.name }}

+

+
+
+} + +@if (quiz.showAttemptsMarks && readableMark && attempt?.sumgrades !== null) { + + +

{{ 'addon.mod_quiz.marks' | translate }}

+

{{ readableMark }}

+
+
+} + +@if (readableGrade) { + + +

{{ 'addon.mod_quiz.grade' | translate }}

+

+
+
+} + +@for (data of additionalData; track data.id) { + + +

{{ data.title }}

+ +
+
+} + +} diff --git a/src/addons/mod/quiz/components/attempt-info/attempt-info.ts b/src/addons/mod/quiz/components/attempt-info/attempt-info.ts new file mode 100644 index 000000000..ebb7be876 --- /dev/null +++ b/src/addons/mod/quiz/components/attempt-info/attempt-info.ts @@ -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 { + 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: '' + AddonModQuiz.formatGrade(gradeItemMark.grade, this.quiz?.decimalpoints) + '', + 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 = { + grade: '' + AddonModQuiz.formatGrade(this.attempt.rescaledGrade, this.quiz.decimalpoints) + '', + 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 = '' + AddonModQuiz.formatGrade(percentage, this.quiz.decimalpoints) + ''; + this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject }); + } else { + this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject }); + } + } + +} diff --git a/src/addons/mod/quiz/components/attempt-state/attempt-state.html b/src/addons/mod/quiz/components/attempt-state/attempt-state.html new file mode 100644 index 000000000..eeedb8767 --- /dev/null +++ b/src/addons/mod/quiz/components/attempt-state/attempt-state.html @@ -0,0 +1,6 @@ + + @if (finishedOffline) { + diff --git a/src/addons/mod/quiz/components/attempt-state/attempt-state.scss b/src/addons/mod/quiz/components/attempt-state/attempt-state.scss new file mode 100644 index 000000000..8f9843ae0 --- /dev/null +++ b/src/addons/mod/quiz/components/attempt-state/attempt-state.scss @@ -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)); + } + } + +} diff --git a/src/addons/mod/quiz/components/attempt-state/attempt-state.ts b/src/addons/mod/quiz/components/attempt-state/attempt-state.ts new file mode 100644 index 000000000..dd165198a --- /dev/null +++ b/src/addons/mod/quiz/components/attempt-state/attempt-state.ts @@ -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 { + this.readableState = AddonModQuiz.getAttemptReadableStateName(this.state, this.finishedOffline); + this.color = AddonModQuiz.getAttemptStateColor(this.state, this.finishedOffline); + } + +} diff --git a/src/addons/mod/quiz/components/components.module.ts b/src/addons/mod/quiz/components/components.module.ts index 9b167b237..5eade44a0 100644 --- a/src/addons/mod/quiz/components/components.module.ts +++ b/src/addons/mod/quiz/components/components.module.ts @@ -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, diff --git a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html index b3bc4b6fa..a9a4e628c 100644 --- a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html +++ b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html @@ -45,91 +45,123 @@ - + @if (quiz && attempts.length) { + {{ 'addon.mod_quiz.summaryofattempts' | translate }} - - - - - - - - {{ 'addon.mod_quiz.attemptnumber' | translate }} - {{ 'addon.mod_quiz.attemptnumber' | translate }} - - - {{ 'addon.mod_quiz.attemptstate' | translate }} - - - {{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }} - - - {{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }} - - - - -
- - - - - - {{ 'addon.mod_quiz.preview' | translate }} - - - {{ attempt.attempt }} - - -

{{ sentence }}

-
- -

{{ attempt.readableMark }}

-
- -

{{ attempt.readableGrade }}

-
-
-
-
-
-
-
- - - - - {{ gradeResult }} - - - {{ 'core.course.overriddennotice' | translate }} - - - -

{{ 'addon.mod_quiz.comment' | translate }}

-

- -

-
-
- - -

{{ 'addon.mod_quiz.overallfeedback' | translate }}

-

- -

-
-
-
+ + @if (quiz && showResults && (gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedback && overallFeedback))) { + + @if (overallStats && gradeResult) { + + +
+ @if (moreAttempts) { + {{ gradeMethodReadable }} + {{ gradeResult }} + } @else { + {{ 'addon.mod_quiz.yourfinalgradeis' | translate:{ $a: gradeResult } }} + } +
+ + @if (gradeOverridden) { +

+

+ } +
+
+ } + + @if (gradebookFeedback) { + + +

{{ 'addon.mod_quiz.comment' | translate }}

+

+ +

+
+
+ } + + @if (quiz.showFeedback && overallFeedback) { +
+ + } + + } + + + @for (attempt of attempts; track attempt.id) { + + + +

{{ 'addon.mod_quiz.attempt' | translate:{ $a: attempt.attempt } }}

+
+
+ + @if (attempt.finished && quiz.showAttemptsGrades) { + @if (attempt.rescaledGrade !== undefined && attempt.rescaledGrade >= 0) { +

+ {{ 'core.grades.gradelong' | translate: { $a: { + grade: attempt.formattedGrade, + max: quiz.gradeFormatted, + } } }} +

+ } @else { +

{{ 'addon.mod_quiz.notyetgraded' | translate }}

+ } + } +
+
+
+ + + @if (attempt.canReview) { +
+ + + } @else if (attempt.completed && attempt.cannotReviewMessage) { +
+ + +

+

+
+
+ } @else if (attempt.finishedOffline) { +
+ + +

+

+
+
+ } +
+
+ } +
+ } diff --git a/src/addons/mod/quiz/components/index/index.scss b/src/addons/mod/quiz/components/index/index.scss index c153eae1b..647124ebb 100644 --- a/src/addons/mod/quiz/components/index/index.scss +++ b/src/addons/mod/quiz/components/index/index.scss @@ -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); - } - } } diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index cef7514ad..5c571eb9a 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -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 { + protected async getAttempts( + quiz: AddonModQuizQuizData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + ): Promise { // 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 { - 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 { + protected async goToAutoReview(attempts: AddonModQuizAttemptWSData[]): Promise { 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 { + ): Promise { 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, + 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 { + async reviewAttempt(attemptId: number): Promise { 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. +}; diff --git a/src/addons/mod/quiz/constants.ts b/src/addons/mod/quiz/constants.ts index dc11c9d2e..cc5e687d3 100644 --- a/src/addons/mod/quiz/constants.ts +++ b/src/addons/mod/quiz/constants.ts @@ -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, +} diff --git a/src/addons/mod/quiz/lang.json b/src/addons/mod/quiz/lang.json index dc328c5e1..135e71f37 100644 --- a/src/addons/mod/quiz/lang.json +++ b/src/addons/mod/quiz/lang.json @@ -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 won’t 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.", diff --git a/src/addons/mod/quiz/pages/attempt/attempt.html b/src/addons/mod/quiz/pages/attempt/attempt.html deleted file mode 100644 index 5b4692081..000000000 --- a/src/addons/mod/quiz/pages/attempt/attempt.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - -

- -

-
-
-
- - - - - - - - -

{{ 'addon.mod_quiz.attemptnumber' | translate }}

-

{{ 'addon.mod_quiz.preview' | translate }}

-

{{ attempt.attempt }}

-
-
- - -

{{ 'addon.mod_quiz.attemptstate' | translate }}

-

{{ sentence }}

-
-
- - - -

{{ gradeItemMark.name }} / {{ gradeItemMark.maxgrade }}

-

{{ gradeItemMark.grade }}

-
-
-
- - -

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

-

{{ attempt.readableMark }}

-
-
- - -

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

-

{{ attempt.readableGrade }}

-
-
- - -

{{ 'addon.mod_quiz.feedback' | translate }}

-

- -

-
-
- - - -

{{ 'addon.mod_quiz.noreviewattempt' | translate }}

-
-
-
- -
-
- - -
-
-
-
diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts deleted file mode 100644 index 71f6c87b5..000000000 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ /dev/null @@ -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 { - 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 { - // 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 { - 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 { - const promises: Promise[] = []; - - 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 { - if (!this.attempt) { - return; - } - - CoreNavigator.navigate(`../../review/${this.attempt.id}`); - } - -} diff --git a/src/addons/mod/quiz/pages/player/player.scss b/src/addons/mod/quiz/pages/player/player.scss index fb6ba1e5f..3242512cc 100644 --- a/src/addons/mod/quiz/pages/player/player.scss +++ b/src/addons/mod/quiz/pages/player/player.scss @@ -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 { diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts index 8cb628905..b2b0a39e9 100644 --- a/src/addons/mod/quiz/pages/player/player.ts +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -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 = {}; // 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; +}; diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html index 4fa9412b2..217705533 100644 --- a/src/addons/mod/quiz/pages/review/review.html +++ b/src/addons/mod/quiz/pages/review/review.html @@ -24,61 +24,7 @@ - - -

{{ 'addon.mod_quiz.startedon' | translate }}

-

{{ attempt.timestart! * 1000 | coreFormatDate }}

-
-
- - -

{{ 'addon.mod_quiz.attemptstate' | translate }}

-

{{ readableState }}

-
-
- - -

{{ 'addon.mod_quiz.completedon' | translate }}

-

{{ attempt.timefinish! * 1000 | coreFormatDate }}

-
-
- - -

{{ 'addon.mod_quiz.timetaken' | translate }}

-

{{ timeTaken }}

-
-
- - -

{{ 'addon.mod_quiz.overdue' | translate }}

-

{{ overTime }}

-
-
- - -

{{ gradeItemMark.name }}

-

{{ gradeItemMark.grade }}

-
-
- - -

{{ 'addon.mod_quiz.marks' | translate }}

-

{{ readableMark }}

-
-
- - -

{{ 'addon.mod_quiz.grade' | translate }}

-

{{ readableGrade }}

-
-
- - -

{{ data.title }}

- -
-
+
diff --git a/src/addons/mod/quiz/pages/review/review.ts b/src/addons/mod/quiz/pages/review/review.ts index 188a756e6..66b2291df 100644 --- a/src/addons/mod/quiz/pages/review/review.ts +++ b/src/addons/mod/quiz/pages/review/review.ts @@ -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 { - 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 = { - 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. */ diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index 51f1e3d04..99f8d8607 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -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, ], diff --git a/src/addons/mod/quiz/quiz.module.ts b/src/addons/mod/quiz/quiz.module.ts index 3dbf6688e..e51dc8888 100644 --- a/src/addons/mod/quiz/quiz.module.ts +++ b/src/addons/mod/quiz/quiz.module.ts @@ -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); }, }, ], diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts index 05c470835..b6c17276b 100644 --- a/src/addons/mod/quiz/services/handlers/prefetch.ts +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -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, siteId?: string, ): Promise { - const pages = AddonModQuiz.getPagesFromLayout(attempt.layout); const isSequential = AddonModQuiz.isNavigationSequential(quiz); let promises: Promise[] = []; @@ -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 { + // 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[] = []; + + // 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 { // 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); } diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts index cba2dcf1f..b5bf2f317 100644 --- a/src/addons/mod/quiz/services/quiz-helper.ts +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -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 { + 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 { + 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, - attempt?: AddonModQuizAttemptWSData, - offline?: boolean, - prefetch?: boolean, - title?: string, - siteId?: string, - retrying?: boolean, + options: GetAndCheckPreflightOptions = {}, ): Promise { 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> { const notSupported: string[] = []; const rules = accessInfo?.activerulenames; @@ -163,11 +273,11 @@ export class AddonModQuizHelperProvider { const modalData = await CoreDomUtils.openModal>({ 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 { const formattedAttempt = 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, - attempt?: AddonModQuizAttempt, - offline?: boolean, - prefetch?: boolean, - siteId?: string, + options: ValidatePreflightOptions = {}, ): Promise { 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; diff --git a/src/addons/mod/quiz/services/quiz-offline.ts b/src/addons/mod/quiz/services/quiz-offline.ts index 15663f3d0..94354859b 100644 --- a/src/addons/mod/quiz/services/quiz-offline.ts +++ b/src/addons/mod/quiz/services/quiz-offline.ts @@ -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 { - 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(), diff --git a/src/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts index 1dd59d96c..60ef311bd 100644 --- a/src/addons/mod/quiz/services/quiz-sync.ts +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -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, ); diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index d8edc9157..e360df44c 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -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( + 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( + 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; +}; diff --git a/src/addons/mod/quiz/tests/behat/attempts_list.feature b/src/addons/mod/quiz/tests/behat/attempts_list.feature new file mode 100644 index 000000000..8082926e5 --- /dev/null +++ b/src/addons/mod/quiz/tests/behat/attempts_list.feature @@ -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 diff --git a/src/addons/mod/quiz/tests/behat/basic-usage-403.feature b/src/addons/mod/quiz/tests/behat/basic-usage-403.feature index 1ed062922..b5a4719eb 100644 --- a/src/addons/mod/quiz/tests/behat/basic-usage-403.feature +++ b/src/addons/mod/quiz/tests/behat/basic-usage-403.feature @@ -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" diff --git a/src/addons/mod/quiz/tests/behat/basic_usage-311.feature b/src/addons/mod/quiz/tests/behat/basic_usage-311.feature index e04ec0fa6..be10f47fb 100644 --- a/src/addons/mod/quiz/tests/behat/basic_usage-311.feature +++ b/src/addons/mod/quiz/tests/behat/basic_usage-311.feature @@ -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" diff --git a/src/addons/mod/quiz/tests/behat/basic_usage.feature b/src/addons/mod/quiz/tests/behat/basic_usage.feature index 8a1a95057..d02d3e1e2 100755 --- a/src/addons/mod/quiz/tests/behat/basic_usage.feature +++ b/src/addons/mod/quiz/tests/behat/basic_usage.feature @@ -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" diff --git a/src/addons/mod/quiz/tests/behat/can_review.feature b/src/addons/mod/quiz/tests/behat/can_review.feature new file mode 100644 index 000000000..c7c43fdde --- /dev/null +++ b/src/addons/mod/quiz/tests/behat/can_review.feature @@ -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 diff --git a/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_27.png b/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_27.png index 4dee92729..63ba23ad2 100644 Binary files a/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_27.png and b/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_27.png differ diff --git a/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_39.png b/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_39.png deleted file mode 100644 index c36de5e70..000000000 Binary files a/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_39.png and /dev/null differ diff --git a/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_40.png b/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_40.png new file mode 100644 index 000000000..679b91517 Binary files /dev/null and b/src/addons/mod/quiz/tests/behat/snapshots/attempt-a-quiz-in-app-submit-a-quiz--review-a-quiz-attempt_40.png differ diff --git a/src/core/features/grades/lang.json b/src/core/features/grades/lang.json index ab8013e76..c24a0fdb4 100644 --- a/src/core/features/grades/lang.json +++ b/src/core/features/grades/lang.json @@ -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", diff --git a/src/core/features/question/constants.ts b/src/core/features/question/constants.ts index 21b7e6768..117435661 100644 --- a/src/core/features/question/constants.ts +++ b/src/core/features/question/constants.ts @@ -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, +} diff --git a/src/core/lang.json b/src/core/lang.json index dc987b022..99e49b466 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -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.", diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index 27a97bed3..7c6acb55e 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -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; }