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.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<void> {
protected async getAttempts(
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
): Promise<void> {
// Always get the best grade because it includes the grade to pass.
this.bestGrade = await AddonModQuiz.getUserBestGrade(quiz.id, { cmId: this.module.id });
@ -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<AddonModQuizAttempt[]> {
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;

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_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,
}

View File

@ -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);

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.
this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData(
this.quiz,
this.quizAccessInfo,
attempts[attempts.length - 1],
false,
undefined,

View File

@ -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<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.
* 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;
};

View File

@ -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<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.
*
@ -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;
};

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_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,
}