MOBILE-4450 quiz: Calculate in app whether attempt can be reviewed

main
Dani Palou 2024-04-17 14:50:38 +02:00
parent 17bd64a5e0
commit 08797cc9d5
7 changed files with 279 additions and 7 deletions

View File

@ -234,7 +234,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.unsupportedQuestions = AddonModQuiz.getUnsupportedQuestions(types); this.unsupportedQuestions = AddonModQuiz.getUnsupportedQuestions(types);
this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1); 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. // Quiz is ready to be shown, move it to the variable that is displayed.
this.quiz = quiz; this.quiz = quiz;
@ -246,7 +246,10 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* @param quiz Quiz instance. * @param quiz Quiz instance.
* @returns Promise resolved when done. * @returns Promise resolved when done.
*/ */
protected async getAttempts(quiz: AddonModQuizQuizData): Promise<void> { protected async getAttempts(
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
): Promise<void> {
// Always get the best grade because it includes the grade to pass. // Always get the best grade because it includes the grade to pass.
this.bestGrade = await AddonModQuiz.getUserBestGrade(quiz.id, { cmId: this.module.id }); this.bestGrade = await AddonModQuiz.getUserBestGrade(quiz.id, { cmId: this.module.id });
@ -256,7 +259,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// Get attempts. // Get attempts.
const attempts = await AddonModQuiz.getUserAttempts(quiz.id, { cmId: this.module.id }); 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. // Check if user can create/continue attempts.
if (this.attempts.length) { if (this.attempts.length) {
@ -572,11 +575,13 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* Treat user attempts. * Treat user attempts.
* *
* @param quiz Quiz data. * @param quiz Quiz data.
* @param accessInfo Quiz access information.
* @param attempts The attempts to treat. * @param attempts The attempts to treat.
* @returns Promise resolved when done. * @returns Promise resolved when done.
*/ */
protected async treatAttempts( protected async treatAttempts(
quiz: AddonModQuizQuizData, quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempts: AddonModQuizAttemptWSData[], attempts: AddonModQuizAttemptWSData[],
): Promise<AddonModQuizAttempt[]> { ): Promise<AddonModQuizAttempt[]> {
if (!attempts || !attempts.length) { if (!attempts || !attempts.length) {
@ -619,7 +624,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
attempts.length > 1; attempts.length > 1;
const isLast = index == 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; return formattedAttempts;

View File

@ -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_ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished';
export const ADDON_MOD_QUIZ_SHOW_TIME_BEFORE_DEADLINE = 3600; 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. * Possible grade methods for a quiz.
@ -39,3 +40,13 @@ export const enum AddonModQuizAttemptStates {
FINISHED = 'finished', FINISHED = 'finished',
ABANDONED = 'abandoned', ABANDONED = 'abandoned',
} }
/**
* Bitmask patterns to determine if data should be displayed based on the attempt state.
*/
export const enum AddonModQuizDisplayOptionsAttemptStates {
DURING = 0x10000,
IMMEDIATELY_AFTER = 0x01000,
LATER_WHILE_OPEN = 0x00100,
AFTER_CLOSE = 0x00010,
}

View File

@ -102,7 +102,14 @@ export class AddonModQuizAttemptPage implements OnInit {
this.showReviewColumn = accessInfo.canreviewmyattempts; this.showReviewColumn = accessInfo.canreviewmyattempts;
AddonModQuizHelper.setQuizCalculatedData(this.quiz, options); 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. // Check if the feedback should be displayed.
const grade = Number(this.attempt.rescaledGrade); const grade = Number(this.attempt.rescaledGrade);

View File

@ -383,6 +383,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
// Get the last attempt. If it's finished, start a new one. // Get the last attempt. If it's finished, start a new one.
this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData( this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData(
this.quiz, this.quiz,
this.quizAccessInfo,
attempts[attempts.length - 1], attempts[attempts.length - 1],
false, false,
undefined, undefined,

View File

@ -33,8 +33,9 @@ import {
AddonModQuizQuizWSData, AddonModQuizQuizWSData,
} from './quiz'; } from './quiz';
import { AddonModQuizOffline } from './quiz-offline'; import { AddonModQuizOffline } from './quiz-offline';
import { AddonModQuizAttemptStates } from '../constants'; import { AddonModQuizAttemptStates, AddonModQuizDisplayOptionsAttemptStates } from '../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants'; import { QuestionDisplayOptionsMarks } from '@features/question/constants';
import { CoreGroups } from '@services/groups';
/** /**
* Helper service that provides some features for quiz. * Helper service that provides some features for quiz.
@ -42,6 +43,84 @@ import { QuestionDisplayOptionsMarks } from '@features/question/constants';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AddonModQuizHelperProvider { export class AddonModQuizHelperProvider {
/**
* Check if current user can review an attempt.
*
* @param quiz Quiz.
* @param accessInfo Access info.
* @param attempt Attempt.
* @returns Whether user can review the attempt.
*/
async canReviewAttempt(
quiz: AddonModQuizQuizWSData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
): Promise<boolean> {
if (!this.hasReviewCapabilityForAttempt(quiz, accessInfo, attempt)) {
return false;
}
if (attempt.userid !== CoreSites.getCurrentSiteUserId()) {
return this.canReviewOtherUserAttempt(quiz, accessInfo, attempt);
}
if (!AddonModQuiz.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<boolean> {
if (!accessInfo.canviewreports) {
return false;
}
try {
const groupInfo = await CoreGroups.getActivityGroupInfo(quiz.coursemodule);
if (groupInfo.canAccessAllGroups || !groupInfo.separateGroups) {
return true;
}
// Check if the current user and the attempt's user share any group.
if (!groupInfo.groups.length) {
return false;
}
const attemptUserGroups = await CoreGroups.getUserGroupsInCourse(quiz.course, undefined, attempt.userid);
return attemptUserGroups.some(attemptUserGroup => groupInfo.groups.find(group => attemptUserGroup.id === group.id));
} catch {
return false;
}
}
/** /**
* Validate a preflight data or show a modal to input the preflight data if required. * 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. * 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. * Add some calculated data to the attempt.
* *
* @param quiz Quiz. * @param quiz Quiz.
* @param accessInfo Quiz access info.
* @param attempt Attempt. * @param attempt Attempt.
* @param highlight Whether we should check if attempt should be highlighted. * @param highlight Whether we should check if attempt should be highlighted.
* @param bestGrade Quiz's best grade (formatted). Required if highlight=true. * @param bestGrade Quiz's best grade (formatted). Required if highlight=true.
@ -266,6 +369,7 @@ export class AddonModQuizHelperProvider {
*/ */
async setAttemptCalculatedData( async setAttemptCalculatedData(
quiz: AddonModQuizQuizData, quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData, attempt: AddonModQuizAttemptWSData,
highlight?: boolean, highlight?: boolean,
bestGrade?: string, bestGrade?: string,
@ -301,6 +405,8 @@ export class AddonModQuizHelperProvider {
formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId); formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId);
} }
formattedAttempt.canReview = await this.canReviewAttempt(quiz, accessInfo, attempt);
return formattedAttempt; return formattedAttempt;
} }
@ -434,4 +540,5 @@ export type AddonModQuizAttempt = AddonModQuizAttemptWSData & {
readableMark?: string; readableMark?: string;
readableGrade?: string; readableGrade?: string;
highlightGrade?: boolean; highlightGrade?: boolean;
canReview?: boolean;
}; };

View File

@ -41,12 +41,19 @@ import { AddonModQuizAttempt } from './quiz-helper';
import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline'; import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline';
import { AddonModQuizAutoSyncData, AddonModQuizSyncProvider } from './quiz-sync'; import { AddonModQuizAutoSyncData, AddonModQuizSyncProvider } from './quiz-sync';
import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; 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 { import {
ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT,
AddonModQuizAttemptStates, AddonModQuizAttemptStates,
ADDON_MOD_QUIZ_COMPONENT, ADDON_MOD_QUIZ_COMPONENT,
AddonModQuizGradeMethods, AddonModQuizGradeMethods,
AddonModQuizDisplayOptionsAttemptStates,
ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD,
} from '../constants'; } from '../constants';
declare module '@singletons/events' { 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<QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues>(
quiz.reviewmaxmarks,
state,
marksOption,
QuestionDisplayOptionsValues.HIDDEN,
) :
marksOption,
feedback: feedbackOption,
generalfeedback: this.calculateDisplayOptionValue(quiz.reviewgeneralfeedback ?? 0, state),
rightanswer: this.calculateDisplayOptionValue(quiz.reviewrightanswer ?? 0, state),
overallfeedback: this.calculateDisplayOptionValue(quiz.reviewoverallfeedback ?? 0, state),
numpartscorrect: feedbackOption,
manualcomment: feedbackOption,
markdp: quiz.questiondecimalpoints !== undefined && quiz.questiondecimalpoints !== -1 ?
quiz.questiondecimalpoints :
(quiz.decimalpoints ?? 0),
};
}
/**
* Calculate the value for a certain display option.
*
* @param setting Setting value related to the option.
* @param state Display options state.
* @param whenSet Value to return if setting is set.
* @param whenNotSet Value to return if setting is not set.
* @returns Display option.
*/
protected calculateDisplayOptionValue<T = AddonModQuizDisplayOptionValue>(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
whenSet: T,
whenNotSet: T,
): T;
protected calculateDisplayOptionValue(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
): QuestionDisplayOptionsValues;
protected calculateDisplayOptionValue(
setting: number,
state: AddonModQuizDisplayOptionsAttemptStates,
whenSet: AddonModQuizDisplayOptionValue = QuestionDisplayOptionsValues.VISIBLE,
whenNotSet: AddonModQuizDisplayOptionValue = QuestionDisplayOptionsValues.HIDDEN,
): AddonModQuizDisplayOptionValue {
// eslint-disable-next-line no-bitwise
if (setting & state) {
return whenSet;
}
return whenNotSet;
}
/** /**
* Turn attempt's state into a readable state, including some extra data depending on the state. * 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. 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. 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. 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. 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. 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. reviewgeneralfeedback?: number; // Whether users are allowed to review their quiz attempts at various times.
@ -2383,3 +2490,24 @@ export type AddonModQuizAttemptFinishedData = {
attemptId: number; attemptId: number;
synced: boolean; synced: boolean;
}; };
/**
* Quiz display option value.
*/
export type AddonModQuizDisplayOptionValue = QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues | boolean;
/**
* Quiz display options, it can be used to determine which options to display.
*/
export type AddonModQuizDisplayOptions = {
attempt: boolean;
correctness: QuestionDisplayOptionsValues;
marks: QuestionDisplayOptionsMarks | QuestionDisplayOptionsValues;
feedback: QuestionDisplayOptionsValues;
generalfeedback: QuestionDisplayOptionsValues;
rightanswer: QuestionDisplayOptionsValues;
overallfeedback: QuestionDisplayOptionsValues;
numpartscorrect: QuestionDisplayOptionsValues;
manualcomment: QuestionDisplayOptionsValues;
markdp: number;
};

View File

@ -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_GAVE_UP_STATE_CLASSES = ['notanswered'] as const;
export const QUESTION_GRADED_STATE_CLASSES = ['complete', 'incorrect', 'partiallycorrect', 'correct'] 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 { export const enum QuestionDisplayOptionsMarks {
MAX_ONLY = 1, MAX_ONLY = 1,
MARK_AND_MAX = 2, 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,
}