From 08797cc9d5bcafff26d264a441d72a3833de5118 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 17 Apr 2024 14:50:38 +0200 Subject: [PATCH] MOBILE-4450 quiz: Calculate in app whether attempt can be reviewed --- src/addons/mod/quiz/components/index/index.ts | 13 +- src/addons/mod/quiz/constants.ts | 11 ++ src/addons/mod/quiz/pages/attempt/attempt.ts | 9 +- src/addons/mod/quiz/pages/player/player.ts | 1 + src/addons/mod/quiz/services/quiz-helper.ts | 109 ++++++++++++++- src/addons/mod/quiz/services/quiz.ts | 130 +++++++++++++++++- src/core/features/question/constants.ts | 13 ++ 7 files changed, 279 insertions(+), 7 deletions(-) diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index e06ae0c69..8256be2af 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -234,7 +234,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; @@ -246,7 +246,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 }); @@ -256,7 +259,7 @@ 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) { @@ -572,11 +575,13 @@ 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 { if (!attempts || !attempts.length) { @@ -619,7 +624,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp attempts.length > 1; const isLast = index == attempts.length - 1; - return AddonModQuizHelper.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast); + return AddonModQuizHelper.setAttemptCalculatedData(quiz, accessInfo, attempt, shouldHighlight, quizGrade, isLast); })); return formattedAttempts; diff --git a/src/addons/mod/quiz/constants.ts b/src/addons/mod/quiz/constants.ts index 2f7e47674..cc5e687d3 100644 --- a/src/addons/mod/quiz/constants.ts +++ b/src/addons/mod/quiz/constants.ts @@ -19,6 +19,7 @@ export const ADDON_MOD_QUIZ_FEATURE_NAME = 'CoreCourseModuleDelegate_AddonModQui 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. @@ -39,3 +40,13 @@ export const enum AddonModQuizAttemptStates { 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/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts index 3151a69ea..e1b3e856f 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -102,7 +102,14 @@ export class AddonModQuizAttemptPage implements OnInit { this.showReviewColumn = accessInfo.canreviewmyattempts; AddonModQuizHelper.setQuizCalculatedData(this.quiz, options); - this.attempt = await AddonModQuizHelper.setAttemptCalculatedData(this.quiz, attempt, false, undefined, true); + this.attempt = await AddonModQuizHelper.setAttemptCalculatedData( + this.quiz, + accessInfo, + attempt, + false, + undefined, + true, + ); // Check if the feedback should be displayed. const grade = Number(this.attempt.rescaledGrade); diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts index 0e528f676..9ba2faeb1 100644 --- a/src/addons/mod/quiz/pages/player/player.ts +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -383,6 +383,7 @@ 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, + this.quizAccessInfo, attempts[attempts.length - 1], false, undefined, diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts index 588c29d68..fd72929f2 100644 --- a/src/addons/mod/quiz/services/quiz-helper.ts +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -33,8 +33,9 @@ import { AddonModQuizQuizWSData, } from './quiz'; import { AddonModQuizOffline } from './quiz-offline'; -import { AddonModQuizAttemptStates } from '../constants'; +import { AddonModQuizAttemptStates, AddonModQuizDisplayOptionsAttemptStates } from '../constants'; import { QuestionDisplayOptionsMarks } from '@features/question/constants'; +import { CoreGroups } from '@services/groups'; /** * Helper service that provides some features for quiz. @@ -42,6 +43,84 @@ import { QuestionDisplayOptionsMarks } from '@features/question/constants'; @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.isAttemptFinished(attempt.state)) { + // Cannot review own unfinished 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; + } + } + /** * 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. @@ -253,10 +332,34 @@ 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 accessInfo Quiz access info. * @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. @@ -266,6 +369,7 @@ export class AddonModQuizHelperProvider { */ async setAttemptCalculatedData( quiz: AddonModQuizQuizData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, attempt: AddonModQuizAttemptWSData, highlight?: boolean, bestGrade?: string, @@ -301,6 +405,8 @@ export class AddonModQuizHelperProvider { formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId); } + formattedAttempt.canReview = await this.canReviewAttempt(quiz, accessInfo, attempt); + return formattedAttempt; } @@ -434,4 +540,5 @@ export type AddonModQuizAttempt = AddonModQuizAttemptWSData & { readableMark?: string; readableGrade?: string; highlightGrade?: boolean; + canReview?: boolean; }; diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index 10404764e..52baabc35 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -41,12 +41,19 @@ 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'; +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'; declare module '@singletons/events' { @@ -305,6 +312,105 @@ export class AddonModQuizProvider { } } + /** + * Get the display option value related to the attempt state. + * Equivalent to LMS quiz_attempt_state. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @returns Display option value. + */ + 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; + } + + return AddonModQuizDisplayOptionsAttemptStates.LATER_WHILE_OPEN; + } + + /** + * 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); + + 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), + }; + } + + /** + * 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 whenNotSet; + } + /** * Turn attempt's state into a readable state, including some extra data depending on the state. * @@ -2189,6 +2295,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. @@ -2383,3 +2490,24 @@ export type AddonModQuizAttemptFinishedData = { 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/core/features/question/constants.ts b/src/core/features/question/constants.ts index 961fb13d1..117435661 100644 --- a/src/core/features/question/constants.ts +++ b/src/core/features/question/constants.ts @@ -20,7 +20,20 @@ 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, +}