MOBILE-4450 quiz: Calculate in app whether attempt can be reviewed
parent
17bd64a5e0
commit
08797cc9d5
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue