diff --git a/packages/backendv2/database/migrations/20210519094353_multiple-choice-grading-policy.ts b/packages/backendv2/database/migrations/20210519094353_multiple-choice-grading-policy.ts new file mode 100644 index 00000000..16c5787d --- /dev/null +++ b/packages/backendv2/database/migrations/20210519094353_multiple-choice-grading-policy.ts @@ -0,0 +1,38 @@ +import * as Knex from "knex" + +export async function up(knex: Knex): Promise { + await knex.schema.table("quiz_item", table => { + table + .enu( + "multiple_selected_options_grading_options", + ["NeedToSelectAllCorrectOptions", "NeedToSelectNCorrectOptions"], + { + useNative: true, + enumName: "multiple_selected_options_grading_policy_enum", + }, + ) + .defaultTo("NeedToSelectAllCorrectOptions") + + table.integer("multiple_selected_options_grading_policy_n").defaultTo(1) + + // TODO: NeedToSelectAllCorrectOptions basically same + // table.dropColumn("multi") + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.table("quiz_item", table => { + table.dropColumn("multiple_selected_options_grading_options") + table.dropColumn("multiple_selected_options_grading_policy_n") + + // TODO + // table + // .boolean("multi") + // .defaultTo(false) + // .notNullable() + }) + + await knex.schema.raw( + `drop type if exists multiple_selected_options_grading_policy_enum;`, + ) +} diff --git a/packages/backendv2/database/seeds/multipleChoiceGradingPolicy.ts b/packages/backendv2/database/seeds/multipleChoiceGradingPolicy.ts new file mode 100644 index 00000000..ac941d86 --- /dev/null +++ b/packages/backendv2/database/seeds/multipleChoiceGradingPolicy.ts @@ -0,0 +1,153 @@ +import * as Knex from "knex" + +export async function seed(knex: Knex): Promise { + await knex("language").insert([ + { + id: "ab_BA", + country: "ab_BA", + name: "ab_BA", + }, + ]) + + await knex("course").insert([ + { + id: "cd02cb32-d8d9-407f-b95f-4357864bee7a", + min_score_to_pass: null, + min_progress_to_pass: null, + min_peer_reviews_received: 2, + min_peer_reviews_given: 3, + min_review_average: 2, + max_spam_flags: 1, + organization_id: null, + moocfi_id: "f3e59b24-ff79-4f67-a9aa-21c94977240a", + max_review_spam_flags: 3, + }, + ]) + + await knex("course_translation").insert([ + { + course_id: "cd02cb32-d8d9-407f-b95f-4357864bee7a", + language_id: "ab_BA", + title: "course 1", + body: "course", + abbreviation: "course", + }, + ]) + + await knex("quiz").insert([ + { + id: "d7389c86-7a3a-4593-b810-b2be35319520", + course_id: "cd02cb32-d8d9-407f-b95f-4357864bee7a", + part: 1, + section: 1, + points: 1, + deadline: null, + open: null, + excluded_from_score: false, + auto_confirm: true, + tries: 1, + tries_limited: true, + award_points_even_if_wrong: false, + grant_points_policy: "grant_whenever_possible", + auto_reject: true, + }, + ]) + + await knex("quiz_translation").insert([ + { + quiz_id: "d7389c86-7a3a-4593-b810-b2be35319520", + language_id: "ab_BA", + title: "quiz 1", + body: "body", + submit_message: "nice one!", + }, + ]) + + await knex("quiz_item").insert([ + { + id: "40f7a704-fbbe-4411-8176-31449a5968fe", + quiz_id: "d7389c86-7a3a-4593-b810-b2be35319520", + type: "multiple-choice", + order: 1, + uses_shared_option_feedback_message: false, + multiple_selected_options_grading_options: + "NeedToSelectAllCorrectOptions", + multi: true, + }, + { + id: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + quiz_id: "d7389c86-7a3a-4593-b810-b2be35319520", + type: "multiple-choice", + order: 2, + uses_shared_option_feedback_message: false, + multiple_selected_options_grading_options: "NeedToSelectNCorrectOptions", + multiple_selected_options_grading_policy_n: 2, + multi: true, + }, + ]) + + await knex("quiz_item_translation").insert([ + { + quiz_item_id: "40f7a704-fbbe-4411-8176-31449a5968fe", + language_id: "ab_BA", + title: "multiple-choice", + body: "item", + success_message: "yay!", + failure_message: "boo!", + shared_option_feedback_message: null, + }, + ]) + + await knex("quiz_option").insert([ + { + id: "6b906647-7956-44b5-ac0f-77157c6c8a74", + quiz_item_id: "40f7a704-fbbe-4411-8176-31449a5968fe", + order: 1, + correct: false, + }, + { + id: "12a56d49-1f21-46ba-bcf7-5e90da61ecd1", + quiz_item_id: "40f7a704-fbbe-4411-8176-31449a5968fe", + order: 2, + correct: false, + }, + { + id: "08e648dd-176c-4ba9-a1e8-44231aef221f", + quiz_item_id: "40f7a704-fbbe-4411-8176-31449a5968fe", + order: 3, + correct: true, + }, + { + id: "53c77121-daf7-485e-805d-78550b6e435d", + quiz_item_id: "40f7a704-fbbe-4411-8176-31449a5968fe", + order: 4, + correct: true, + }, + { + id: "0862ddd4-e9d2-485b-b605-03cdfc94bcc4", + quiz_item_id: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + order: 1, + correct: false, + }, + { + id: "853e536a-a374-4e80-ba81-23c75f475529", + quiz_item_id: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + order: 2, + correct: false, + }, + { + id: "43142a08-2fd6-4356-b5cc-1a4b2d9ea085", + quiz_item_id: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + order: 3, + correct: true, + }, + { + id: "2f5ad9fd-59c2-4909-bf85-42931149e47c", + quiz_item_id: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + order: 4, + correct: true, + }, + ]) + + await knex("user").insert([{ id: 1357 }]) +} diff --git a/packages/backendv2/knexfile.ts b/packages/backendv2/knexfile.ts index 89153eb7..9b61b8e4 100644 --- a/packages/backendv2/knexfile.ts +++ b/packages/backendv2/knexfile.ts @@ -8,6 +8,7 @@ types.setTypeParser(types.builtins.INT8, value => parseInt(value)) const configOptions: { [env: string]: Config } = { development: { + debug: true, client: "pg", connection: { host: env.DB_HOST || "/var/run/postgresql", diff --git a/packages/backendv2/package-lock.json b/packages/backendv2/package-lock.json index 3302f5eb..70d24844 100644 --- a/packages/backendv2/package-lock.json +++ b/packages/backendv2/package-lock.json @@ -5502,6 +5502,11 @@ "minimist": "^1.2.5" } }, + "jsonparse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.0.0.tgz", + "integrity": "sha1-JiL05mwI4arH7b63YFPJt+EhH3Y=" + }, "jsonstream": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/jsonstream/-/jsonstream-1.0.3.tgz", @@ -5509,13 +5514,6 @@ "requires": { "jsonparse": "~1.0.0", "through": ">=2.2.7 <3" - }, - "dependencies": { - "jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" - } } }, "jsprim": { diff --git a/packages/backendv2/package.json b/packages/backendv2/package.json index 153abe9f..c71630d0 100644 --- a/packages/backendv2/package.json +++ b/packages/backendv2/package.json @@ -15,7 +15,8 @@ "migrate": "knex migrate:latest", "seed": "knex seed:run --specific a.ts", "update-expired-courses": "node ./dist/bin/update-expired-courses.js", - "background-producer": "node ./dist/bin/kafkaBackgroundProducer.js" + "background-producer": "node ./dist/bin/kafkaBackgroundProducer.js", + "rollback-latest": "knex migrate:down" }, "devDependencies": { "@types/ioredis": "^4.17.11", diff --git a/packages/backendv2/src/controllers/widget/index.ts b/packages/backendv2/src/controllers/widget/index.ts index 02fdfa23..d2d2d54e 100644 --- a/packages/backendv2/src/controllers/widget/index.ts +++ b/packages/backendv2/src/controllers/widget/index.ts @@ -82,7 +82,8 @@ const widget = new Router({ .post("/answer", accessControl(), async ctx => { const user = ctx.state.user const answer = ctx.request.body - ctx.body = await QuizAnswer.newAnswer(user.id, QuizAnswer.fromJson(answer)) + const newQuizAnswer = QuizAnswer.fromJson(answer) + ctx.body = await QuizAnswer.newAnswer(user.id, newQuizAnswer) }) .post("/answers/report-spam", accessControl(), async ctx => { diff --git a/packages/backendv2/src/models/course.ts b/packages/backendv2/src/models/course.ts index 79643e03..ce09e1c6 100644 --- a/packages/backendv2/src/models/course.ts +++ b/packages/backendv2/src/models/course.ts @@ -4,9 +4,10 @@ import { BadRequestError, NotFoundError } from "./../util/error" import stringify from "csv-stringify" import Quiz from "./quiz" import Language from "./language" -import CourseTranslation from "./course_translation" +import CourseTranslation, { CourseTranslationType } from "./course_translation" import knex from "../../database/knex" import BaseModel from "./base_model" +import { ModelObject } from "objection" class Course extends BaseModel { id!: string @@ -407,4 +408,6 @@ class Course extends BaseModel { } } +export type CourseType = ModelObject + export default Course diff --git a/packages/backendv2/src/models/course_translation.ts b/packages/backendv2/src/models/course_translation.ts index 43c0d943..d75441b1 100644 --- a/packages/backendv2/src/models/course_translation.ts +++ b/packages/backendv2/src/models/course_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import { EditCoursePayloadFields } from "./../types/index" import BaseModel from "./base_model" import Course from "./course" @@ -47,4 +48,6 @@ class CourseTranslation extends BaseModel { } } +export type CourseTranslationType = ModelObject + export default CourseTranslation diff --git a/packages/backendv2/src/models/kafka_message.ts b/packages/backendv2/src/models/kafka_message.ts index 74a754e6..f7ec5e19 100644 --- a/packages/backendv2/src/models/kafka_message.ts +++ b/packages/backendv2/src/models/kafka_message.ts @@ -1,4 +1,5 @@ import Knex from "knex" +import { ModelObject } from "objection" import { ProgressMessage, QuizAnswerMessage, QuizMessage } from "../types" import BaseModel from "./base_model" @@ -45,4 +46,6 @@ class KafkaMessage extends BaseModel { } } +export type KafkaMessageType = ModelObject + export default KafkaMessage diff --git a/packages/backendv2/src/models/language.ts b/packages/backendv2/src/models/language.ts index 7a60b4be..dd928eb4 100644 --- a/packages/backendv2/src/models/language.ts +++ b/packages/backendv2/src/models/language.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" class Language extends BaseModel { @@ -11,4 +12,6 @@ class Language extends BaseModel { } } +export type LanguageType = ModelObject + export default Language diff --git a/packages/backendv2/src/models/peer_review.ts b/packages/backendv2/src/models/peer_review.ts index dc5f38a2..785c3674 100644 --- a/packages/backendv2/src/models/peer_review.ts +++ b/packages/backendv2/src/models/peer_review.ts @@ -2,12 +2,16 @@ import { BadRequestError, NotFoundError } from "./../util/error" import QuizAnswer from "./quiz_answer" import UserQuizState from "./user_quiz_state" import Quiz from "./quiz" -import PeerReviewQuestionAnswer from "./peer_review_question_answer" -import PeerReviewQuestion from "./peer_review_question" +import PeerReviewQuestionAnswer, { + PeerReviewQuestionAnswerType, +} from "./peer_review_question_answer" +import PeerReviewQuestion, { + PeerReviewQuestionType, +} from "./peer_review_question" import knex from "../../database/knex" import BaseModel from "./base_model" import softDelete from "objection-soft-delete" -import { mixin } from "objection" +import { mixin, ModelObject } from "objection" import { Transaction } from "knex" class PeerReview extends BaseModel { @@ -182,4 +186,6 @@ class PeerReview extends BaseModel { } } +export type PeerReviewType = ModelObject + export default PeerReview diff --git a/packages/backendv2/src/models/peer_review_collection.ts b/packages/backendv2/src/models/peer_review_collection.ts index 354eb310..b44a9056 100644 --- a/packages/backendv2/src/models/peer_review_collection.ts +++ b/packages/backendv2/src/models/peer_review_collection.ts @@ -1,8 +1,12 @@ import Quiz from "./quiz_item" -import PeerReviewCollectionTranslation from "./peer_review_collection_translation" -import PeerReviewQuestion from "./peer_review_question" +import PeerReviewCollectionTranslation, { + PeerReviewCollectionTranslationType, +} from "./peer_review_collection_translation" +import PeerReviewQuestion, { + PeerReviewQuestionType, +} from "./peer_review_question" import BaseModel from "./base_model" -import { mixin } from "objection" +import { mixin, ModelObject } from "objection" import softDelete from "objection-soft-delete" export class PeerReviewCollection extends mixin(BaseModel, [ @@ -51,4 +55,6 @@ export class PeerReviewCollection extends mixin(BaseModel, [ } } +export type PeerReviewCollectionType = ModelObject + export default PeerReviewCollection diff --git a/packages/backendv2/src/models/peer_review_collection_translation.ts b/packages/backendv2/src/models/peer_review_collection_translation.ts index 10244191..c6f67aa3 100644 --- a/packages/backendv2/src/models/peer_review_collection_translation.ts +++ b/packages/backendv2/src/models/peer_review_collection_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import PeerReviewCollection from "./peer_review_collection" @@ -25,4 +26,8 @@ class PeerReviewCollectionTranslation extends BaseModel { } } +export type PeerReviewCollectionTranslationType = ModelObject< + PeerReviewCollectionTranslation +> + export default PeerReviewCollectionTranslation diff --git a/packages/backendv2/src/models/peer_review_question.ts b/packages/backendv2/src/models/peer_review_question.ts index 7261907b..b96659c1 100644 --- a/packages/backendv2/src/models/peer_review_question.ts +++ b/packages/backendv2/src/models/peer_review_question.ts @@ -1,7 +1,11 @@ import BaseModel from "./base_model" -import PeerReviewCollection from "./peer_review_collection" -import PeerReviewQuestionTranslation from "./peer_review_question_translation" -import { mixin } from "objection" +import PeerReviewCollection, { + PeerReviewCollectionType, +} from "./peer_review_collection" +import PeerReviewQuestionTranslation, { + PeerReviewQuestionTranslationType, +} from "./peer_review_question_translation" +import { mixin, ModelObject } from "objection" import softDelete from "objection-soft-delete" class PeerReviewQuestion extends mixin(BaseModel, [ @@ -41,4 +45,7 @@ class PeerReviewQuestion extends mixin(BaseModel, [ return await this.query().findById(id) } } + +export type PeerReviewQuestionType = ModelObject + export default PeerReviewQuestion diff --git a/packages/backendv2/src/models/peer_review_question_answer.ts b/packages/backendv2/src/models/peer_review_question_answer.ts index 694581dd..bfacb819 100644 --- a/packages/backendv2/src/models/peer_review_question_answer.ts +++ b/packages/backendv2/src/models/peer_review_question_answer.ts @@ -1,6 +1,9 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import PeerReview from "./peer_review" -import PeerReviewQuestion from "./peer_review_question" +import PeerReviewQuestion, { + PeerReviewQuestionType, +} from "./peer_review_question" class PeerReviewQuestionAnswer extends BaseModel { value!: number @@ -34,4 +37,6 @@ class PeerReviewQuestionAnswer extends BaseModel { } } +export type PeerReviewQuestionAnswerType = ModelObject + export default PeerReviewQuestionAnswer diff --git a/packages/backendv2/src/models/peer_review_question_translation.ts b/packages/backendv2/src/models/peer_review_question_translation.ts index ab4bb643..867ef8eb 100644 --- a/packages/backendv2/src/models/peer_review_question_translation.ts +++ b/packages/backendv2/src/models/peer_review_question_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import PeerReviewQuestion from "./peer_review_question" @@ -29,4 +30,7 @@ class PeerReviewQuestionTranslation extends BaseModel { } } +export type PeerReviewQuestionTranslationType = ModelObject< + PeerReviewQuestionTranslation +> export default PeerReviewQuestionTranslation diff --git a/packages/backendv2/src/models/quiz.ts b/packages/backendv2/src/models/quiz.ts index df8c0746..c90d0bc9 100644 --- a/packages/backendv2/src/models/quiz.ts +++ b/packages/backendv2/src/models/quiz.ts @@ -1,7 +1,9 @@ -import QuizItem from "./quiz_item" -import QuizTranslation from "./quiz_translation" -import PeerReviewCollection from "./peer_review_collection" -import Course from "./course" +import QuizItem, { QuizItemType } from "./quiz_item" +import QuizTranslation, { QuizTranslationType } from "./quiz_translation" +import PeerReviewCollection, { + PeerReviewCollectionType, +} from "./peer_review_collection" +import Course, { CourseType } from "./course" import { NotFoundError } from "../util/error" import moduleInitializer from "../util/initializer" import stringify from "csv-stringify" @@ -11,7 +13,7 @@ import Knex from "knex" import UserQuizState from "./user_quiz_state" import * as Kafka from "../services/kafka" import PeerReviewQuestion from "./peer_review_question" -import { NotNullViolationError } from "objection" +import { ModelObject, NotNullViolationError } from "objection" import BaseModel from "./base_model" import knex from "../../database/knex" @@ -398,4 +400,6 @@ export class Quiz extends BaseModel { } } +export type QuizType = ModelObject + export default Quiz diff --git a/packages/backendv2/src/models/quiz_answer.ts b/packages/backendv2/src/models/quiz_answer.ts index 2a2d5d77..4e410a1e 100644 --- a/packages/backendv2/src/models/quiz_answer.ts +++ b/packages/backendv2/src/models/quiz_answer.ts @@ -13,14 +13,15 @@ import PeerReviewQuestion from "./peer_review_question" import UserCoursePartState from "./user_course_part_state" import * as Kafka from "../services/kafka" import SpamFlag from "./spam_flag" -import _, { cond } from "lodash" -import Objection, { raw } from "objection" +import _ from "lodash" +import Objection, { ModelObject, raw } from "objection" import BaseModel from "./base_model" import QuizOptionAnswer from "./quiz_option_answer" import softDelete from "objection-soft-delete" import { mixin } from "objection" import QuizAnswerStatusModification from "./quiz_answer_status_modification" import { TStatusModificationOperation } from "./../types/index" +import QuizItem from "./quiz_item" type QuizAnswerStatus = | "draft" @@ -531,18 +532,24 @@ class QuizAnswer extends mixin(BaseModel, [ const trx = await knex.transaction() try { - const quizId = quizAnswer.quizId const isUserInDb = await User.getById(userId, trx) if (!isUserInDb) { quizAnswer.user = User.fromJson({ id: userId }) } - quizAnswer.userId = userId + + const quizId = quizAnswer.quizId const quiz = await Quiz.getById(quizId, trx) const course = await Course.getById(quiz.courseId, trx) - quizAnswer.languageId = course.languageId const userQuizState = - (await UserQuizState.getByUserAndQuiz(userId, quizId, trx)) ?? - UserQuizState.fromJson({ userId, quizId, tries: 0 }) + (await UserQuizState.getByUserAndQuiz( + userId, + quizAnswer.quizId, + trx, + )) ?? UserQuizState.fromJson({ userId, quizId, tries: 0 }) + + quizAnswer.userId = userId + quizAnswer.languageId = course.languageId + this.checkIfSubmittable(quiz, userQuizState) this.removeIds(quizAnswer) await this.assessAnswerStatus( @@ -552,9 +559,10 @@ class QuizAnswer extends mixin(BaseModel, [ course, trx, ) - this.assessAnswer(quizAnswer, quiz) + await this.assessAnswer(quizAnswer, quiz, trx) this.gradeAnswer(quizAnswer, userQuizState, quiz) - this.assessUserQuizStatus(quizAnswer, userQuizState, quiz, false) + this.assessUserQuizStatus(quizAnswer, userQuizState, quiz) + let savedQuizAnswer let savedUserQuizState await this.markPreviousAsDeprecated(userId, quizId, trx) @@ -600,7 +608,7 @@ class QuizAnswer extends mixin(BaseModel, [ ) { const course = await Course.getById(quiz.courseId, trx) await this.assessAnswerStatus(quizAnswer, userQuizState, quiz, course, trx) - this.assessAnswer(quizAnswer, quiz) + this.assessAnswer(quizAnswer, quiz, trx) this.gradeAnswer(quizAnswer, userQuizState, quiz) this.assessUserQuizStatus(quizAnswer, userQuizState, quiz, true) if (quizAnswer.status === "confirmed") { @@ -634,7 +642,11 @@ class QuizAnswer extends mixin(BaseModel, [ } } - private static assessAnswer(quizAnswer: QuizAnswer, quiz: Quiz) { + private static async assessAnswer( + quizAnswer: QuizAnswer, + quiz: Quiz, + trx: Knex.Transaction, + ) { const quizItemAnswers = quizAnswer.itemAnswers const quizItems = quiz.items if (!quizItemAnswers || quizItemAnswers.length === 0) { @@ -649,7 +661,6 @@ class QuizAnswer extends mixin(BaseModel, [ quizItemAnswer.correct = false continue } - if (quizItem.allAnswersCorrect) { quizItemAnswer.correct = true continue @@ -675,27 +686,9 @@ class QuizAnswer extends mixin(BaseModel, [ case "multiple-choice-dropdown": case "clickable-multiple-choice": case "multiple-choice": - const quizOptionAnswers = quizItemAnswer.optionAnswers - const quizOptions = quizItem.options - if (!quizOptionAnswers || quizOptionAnswers.length === 0) { - throw new BadRequestError("option answers missing") - } - const correctOptionIds = quizOptions - .filter(quizOption => quizOption.correct === true) - .map(quizOption => quizOption.id) - const selectedCorrectOptions = quizOptionAnswers.filter( - quizOptionAnswer => - correctOptionIds.includes(quizOptionAnswer.quizOptionId), - ) - const allSelectedOptionsAreCorrect = quizOptionAnswers.every( - quizOptionAnswer => - correctOptionIds.includes(quizOptionAnswer.quizOptionId), - ) quizItemAnswer.correct = quizItem.multi - ? correctOptionIds.length === selectedCorrectOptions.length && - allSelectedOptionsAreCorrect - : selectedCorrectOptions.length > 0 && - quizOptionAnswers.length === 1 + ? this.multipleSelectedQuizItemOptions(quizItem, quizItemAnswer) + : this.oneSelectedQuizItemOption(quizItem, quizItemAnswer) break case "custom-frontend-accept-data": break @@ -806,7 +799,7 @@ class QuizAnswer extends mixin(BaseModel, [ quizAnswer: QuizAnswer, userQuizState: UserQuizState, quiz: Quiz, - update: boolean, + update: boolean = false, ) { if (!update) { userQuizState.tries += 1 @@ -1182,6 +1175,55 @@ class QuizAnswer extends mixin(BaseModel, [ itemAnswer.optionAnswers?.forEach(optionAnswer => delete optionAnswer.id) }) } + + private static multipleSelectedQuizItemOptions( + quizItem: QuizItem, + quizItemAnswer: QuizItemAnswer, + ): boolean { + switch (quizItem.multipleSelectedOptionsGradingOptions) { + case "NeedToSelectAllCorrectOptions": + return ( + quizItemAnswer.optionAnswers + .map(qoa => qoa.quizOptionId) + .every(qoa => + quizItem.options + .filter(qo => qo.correct) + .map(qo => qo.id) + .includes(qoa), + ) && + quizItemAnswer.optionAnswers.length === + quizItem.options.filter(qo => qo.correct).length + ) + + case "NeedToSelectNCorrectOptions": + return ( + quizItemAnswer.optionAnswers.filter(qoa => + quizItem.options + .filter(qio => qio.correct) + .map(qio => qio.id) + .includes(qoa.quizOptionId), + ).length === quizItem.multipleSelectedOptionsGradingPolicyN + ) + default: + throw new BadRequestError("Unknown quizOption grading policy") + } + } + + private static oneSelectedQuizItemOption( + quizItem: QuizItem, + quizItemAnswer: QuizItemAnswer, + ): boolean { + if (quizItem.options.length > 1) { + throw new BadRequestError( + "Too many options selected for quizItem, where only one option can be selected", + ) + } + const selectedOptionId = quizItemAnswer.optionAnswers[0].id + const correctOptionId = quizItem.options.filter(qio => qio.correct)[0].id + return selectedOptionId === correctOptionId + } } +export type QuizAnswerType = ModelObject + export default QuizAnswer diff --git a/packages/backendv2/src/models/quiz_answer_status_modification.ts b/packages/backendv2/src/models/quiz_answer_status_modification.ts index 7b762648..9e786421 100644 --- a/packages/backendv2/src/models/quiz_answer_status_modification.ts +++ b/packages/backendv2/src/models/quiz_answer_status_modification.ts @@ -1,4 +1,5 @@ import Knex from "knex" +import { ModelObject } from "objection" import { TStatusModificationOperation } from "./../types/index" import BaseModel from "./base_model" import User from "./user" @@ -76,4 +77,8 @@ class QuizAnswerStatusModification extends BaseModel { } } +export type QuizAnswerStatusModificationType = ModelObject< + QuizAnswerStatusModification +> + export default QuizAnswerStatusModification diff --git a/packages/backendv2/src/models/quiz_item.ts b/packages/backendv2/src/models/quiz_item.ts index 74092b1f..5fa67434 100644 --- a/packages/backendv2/src/models/quiz_item.ts +++ b/packages/backendv2/src/models/quiz_item.ts @@ -2,10 +2,10 @@ import Quiz from "./quiz" import QuizOption from "./quiz_option" import QuizItemTranslation from "./quiz_item_translation" import BaseModel from "./base_model" -import { mixin } from "objection" +import { mixin, ModelObject } from "objection" import softDelete from "objection-soft-delete" -export type QuizItemType = +export type itemType = | "open" | "scale" | "essay" @@ -20,15 +20,18 @@ export type QuizItemType = export type QuizItemFeedbackDisplayPolicy = | "DisplayFeedbackOnQuizItem" | "DisplayFeedbackOnAllOptions" +export type MultipleSelectedOptionsGradingPolicy = + | "NeedToSelectAllCorrectOptions" + | "NeedToSelectNCorrectOptions" class QuizItem extends mixin(BaseModel, [ softDelete({ columnName: "deleted" }), ]) { id!: string - type!: QuizItemType feedbackDisplayPolicy!: QuizItemFeedbackDisplayPolicy + type!: itemType validityRegex!: string - multi!: string + multi!: boolean texts!: QuizItemTranslation[] options!: QuizOption[] title!: string @@ -36,9 +39,11 @@ class QuizItem extends mixin(BaseModel, [ successMessage!: string failureMessage!: string sharedOptionFeedbackMessage!: string - allAnswersCorrect!: string + allAnswersCorrect!: boolean deleted!: boolean direction!: "row" | "column" + multipleSelectedOptionsGradingOptions!: MultipleSelectedOptionsGradingPolicy + multipleSelectedOptionsGradingPolicyN!: number static get tableName() { return "quiz_item" @@ -76,4 +81,6 @@ class QuizItem extends mixin(BaseModel, [ } } +export type QuizItemType = ModelObject + export default QuizItem diff --git a/packages/backendv2/src/models/quiz_item_answer.ts b/packages/backendv2/src/models/quiz_item_answer.ts index 36ac1d39..7983d5a9 100644 --- a/packages/backendv2/src/models/quiz_item_answer.ts +++ b/packages/backendv2/src/models/quiz_item_answer.ts @@ -1,7 +1,8 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" -import QuizAnswer from "./quiz_answer" -import QuizItem from "./quiz_item" -import QuizOptionAnswer from "./quiz_option_answer" +import QuizAnswer, { QuizAnswerType } from "./quiz_answer" +import QuizItem, { QuizItemType } from "./quiz_item" +import QuizOptionAnswer, { QuizOptionAnswerType } from "./quiz_option_answer" class QuizItemAnswer extends BaseModel { id!: string @@ -45,4 +46,6 @@ class QuizItemAnswer extends BaseModel { } } +export type QuizItemAnswerType = ModelObject + export default QuizItemAnswer diff --git a/packages/backendv2/src/models/quiz_item_translation.ts b/packages/backendv2/src/models/quiz_item_translation.ts index 134bb37c..f4828d4a 100644 --- a/packages/backendv2/src/models/quiz_item_translation.ts +++ b/packages/backendv2/src/models/quiz_item_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import QuizItem from "./quiz_item" @@ -28,4 +29,6 @@ class QuizItemTranslation extends BaseModel { } } +export type QuizItemTranslationType = ModelObject + export default QuizItemTranslation diff --git a/packages/backendv2/src/models/quiz_option.ts b/packages/backendv2/src/models/quiz_option.ts index a22e80d3..b8ed5a3a 100644 --- a/packages/backendv2/src/models/quiz_option.ts +++ b/packages/backendv2/src/models/quiz_option.ts @@ -1,7 +1,9 @@ import BaseModel from "./base_model" import QuizItem from "./quiz_item" -import QuizOptionTranslation from "./quiz_option_translation" -import { mixin } from "objection" +import QuizOptionTranslation, { + QuizOptionTranslationType, +} from "./quiz_option_translation" +import { mixin, ModelObject } from "objection" import softDelete from "objection-soft-delete" class QuizOption extends mixin(BaseModel, [ softDelete({ columnName: "deleted" }), @@ -43,4 +45,6 @@ class QuizOption extends mixin(BaseModel, [ } } +export type QuizOptionType = ModelObject + export default QuizOption diff --git a/packages/backendv2/src/models/quiz_option_answer.ts b/packages/backendv2/src/models/quiz_option_answer.ts index d8c440f7..8016f1f7 100644 --- a/packages/backendv2/src/models/quiz_option_answer.ts +++ b/packages/backendv2/src/models/quiz_option_answer.ts @@ -1,9 +1,11 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import QuizItemAnswer from "./quiz_item_answer" class QuizOptionAnswer extends BaseModel { id!: string quizOptionId!: string + quizItemAnswerId!: string static get tableName() { return "quiz_option_answer" @@ -20,4 +22,6 @@ class QuizOptionAnswer extends BaseModel { } } +export type QuizOptionAnswerType = ModelObject + export default QuizOptionAnswer diff --git a/packages/backendv2/src/models/quiz_option_translation.ts b/packages/backendv2/src/models/quiz_option_translation.ts index 9545d0d1..d7697440 100644 --- a/packages/backendv2/src/models/quiz_option_translation.ts +++ b/packages/backendv2/src/models/quiz_option_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import QuizOption from "./quiz_option" @@ -27,4 +28,6 @@ class QuizOptionTranslation extends BaseModel { } } +export type QuizOptionTranslationType = ModelObject + export default QuizOptionTranslation diff --git a/packages/backendv2/src/models/quiz_translation.ts b/packages/backendv2/src/models/quiz_translation.ts index d38c2067..636c10ad 100644 --- a/packages/backendv2/src/models/quiz_translation.ts +++ b/packages/backendv2/src/models/quiz_translation.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import Quiz from "./quiz" @@ -36,4 +37,6 @@ class QuizTranslation extends BaseModel { } } +export type QuizTranslationType = ModelObject + export default QuizTranslation diff --git a/packages/backendv2/src/models/spam_flag.ts b/packages/backendv2/src/models/spam_flag.ts index e2856208..d6dc779a 100644 --- a/packages/backendv2/src/models/spam_flag.ts +++ b/packages/backendv2/src/models/spam_flag.ts @@ -6,6 +6,7 @@ import UserQuizState from "./user_quiz_state" import knex from "../../database/knex" import BaseModel from "./base_model" import QuizAnswerStatusModification from "./quiz_answer_status_modification" +import { ModelObject } from "objection" class SpamFlag extends BaseModel { id!: string @@ -103,4 +104,6 @@ class SpamFlag extends BaseModel { } } +export type SpamFlagType = ModelObject + export default SpamFlag diff --git a/packages/backendv2/src/models/user.ts b/packages/backendv2/src/models/user.ts index fad1fe6c..301eb95e 100644 --- a/packages/backendv2/src/models/user.ts +++ b/packages/backendv2/src/models/user.ts @@ -1,9 +1,11 @@ import QuizAnswer from "./quiz_answer" import UserQuizState from "./user_quiz_state" -import UserCourseRole from "./user_course_role" +import UserCourseRole, { UserCourseRoleType } from "./user_course_role" import Knex from "knex" import BaseModel from "./base_model" +import { ModelObject } from "objection" + class User extends BaseModel { id!: number roles!: UserCourseRole[] @@ -47,4 +49,6 @@ class User extends BaseModel { } } +export type UserType = ModelObject + export default User diff --git a/packages/backendv2/src/models/user_course_part_state.ts b/packages/backendv2/src/models/user_course_part_state.ts index 7b15fd2e..f926c169 100644 --- a/packages/backendv2/src/models/user_course_part_state.ts +++ b/packages/backendv2/src/models/user_course_part_state.ts @@ -4,6 +4,7 @@ import Course from "./course" import { PointsByGroup } from "../types" import Quiz from "./quiz" import BaseModel from "./base_model" +import { ModelObject } from "objection" class UserCoursePartState extends BaseModel { userId!: number @@ -179,4 +180,6 @@ class UserCoursePartState extends BaseModel { } } +export type UserCoursePartStateType = ModelObject + export default UserCoursePartState diff --git a/packages/backendv2/src/models/user_course_role.ts b/packages/backendv2/src/models/user_course_role.ts index 538b2f53..c9f4e9a1 100644 --- a/packages/backendv2/src/models/user_course_role.ts +++ b/packages/backendv2/src/models/user_course_role.ts @@ -1,3 +1,4 @@ +import { ModelObject } from "objection" import BaseModel from "./base_model" import User from "./user" @@ -27,4 +28,6 @@ class UserCourseRole extends BaseModel { } } +export type UserCourseRoleType = ModelObject + export default UserCourseRole diff --git a/packages/backendv2/src/models/user_quiz_state.ts b/packages/backendv2/src/models/user_quiz_state.ts index 485265ff..956bb895 100644 --- a/packages/backendv2/src/models/user_quiz_state.ts +++ b/packages/backendv2/src/models/user_quiz_state.ts @@ -4,6 +4,7 @@ import QuizAnswer from "./quiz_answer" import Knex, { Transaction } from "knex" import BaseModel from "./base_model" import knex from "../../database/knex" +import { ModelObject } from "objection" class UserQuizState extends BaseModel { userId!: number @@ -130,4 +131,6 @@ class UserQuizState extends BaseModel { // } } +export type UserQuizStateType = ModelObject + export default UserQuizState diff --git a/packages/backendv2/tests/quizItemMultipleChoiceGradingPolicyTests.test.ts b/packages/backendv2/tests/quizItemMultipleChoiceGradingPolicyTests.test.ts new file mode 100644 index 00000000..a7c3be60 --- /dev/null +++ b/packages/backendv2/tests/quizItemMultipleChoiceGradingPolicyTests.test.ts @@ -0,0 +1,327 @@ +import nock from "nock" +import { Model, snakeCaseMappers } from "objection" +import request from "supertest" +import app from "../app" +import redis from "../config/redis" +import knex from "../database/knex" +import { UserInfo } from "../src/types" +import { safeClean, safeSeed } from "./util" + +const allWrongQuizItem1 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "40f7a704-fbbe-4411-8176-31449a5968fe", + optionAnswers: [ + { + quizOptionId: "6b906647-7956-44b5-ac0f-77157c6c8a74", + }, + { + quizOptionId: "12a56d49-1f21-46ba-bcf7-5e90da61ecd1", + }, + ], + }, + ], +} + +const someCorrectQuizItem1 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "40f7a704-fbbe-4411-8176-31449a5968fe", + optionAnswers: [ + { + quizOptionId: "6b906647-7956-44b5-ac0f-77157c6c8a74", + }, + { + quizOptionId: "08e648dd-176c-4ba9-a1e8-44231aef221f", + }, + ], + }, + ], +} + +const allCorrectQuizItem1 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "40f7a704-fbbe-4411-8176-31449a5968fe", + optionAnswers: [ + { + quizOptionId: "08e648dd-176c-4ba9-a1e8-44231aef221f", + }, + { + quizOptionId: "53c77121-daf7-485e-805d-78550b6e435d", + }, + ], + }, + ], +} + +const allWrongQuizItem2 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + optionAnswers: [ + { + quizOptionId: "0862ddd4-e9d2-485b-b605-03cdfc94bcc4", + }, + { + quizOptionId: "853e536a-a374-4e80-ba81-23c75f475529", + }, + ], + }, + ], +} + +const someCorrectQuizItem2 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + optionAnswers: [ + { + quizOptionId: "853e536a-a374-4e80-ba81-23c75f475529", + }, + { + quizOptionId: "43142a08-2fd6-4356-b5cc-1a4b2d9ea085", + }, + ], + }, + ], +} + +const allCorrectQuizItem2 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + optionAnswers: [ + { + quizOptionId: "43142a08-2fd6-4356-b5cc-1a4b2d9ea085", + }, + { + quizOptionId: "2f5ad9fd-59c2-4909-bf85-42931149e47c", + }, + ], + }, + ], +} + +const tooManySelectedQuizItem2 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + optionAnswers: [ + { + quizOptionId: "43142a08-2fd6-4356-b5cc-1a4b2d9ea085", + }, + { + quizOptionId: "2f5ad9fd-59c2-4909-bf85-42931149e47c", + }, + { + quizOptionId: "853e536a-a374-4e80-ba81-23c75f475529", + }, + ], + }, + ], +} + +const tooFewSelectedQuizItem2 = { + userId: 1357, + quizId: "d7389c86-7a3a-4593-b810-b2be35319520", + itemAnswers: [ + { + quizItemId: "ae7cd864-236d-4294-a6d8-57cf8ab71383", + optionAnswers: [ + { + quizOptionId: "43142a08-2fd6-4356-b5cc-1a4b2d9ea085", + }, + ], + }, + ], +} + +describe("multiple-choice grading tests", () => { + beforeAll(() => { + Model.knex(knex) + Model.columnNameMappers = snakeCaseMappers() + }) + + afterAll(async () => { + await safeClean() + await knex.destroy() + await redis.client?.quit() + }) + + describe("NeedToSelectAllCorrectOptions correct grading", () => { + beforeEach(async () => { + await safeSeed({ + directory: "./database/seeds", + specific: "multipleChoiceGradingPolicy.ts", + }) + }) + + afterEach(async () => { + await safeClean() + }) + + beforeEach(() => { + nock("https://tmc.mooc.fi") + .get("/api/v8/users/current?show_user_fields=true") + .reply(function() { + const auth = this.req.headers.authorization + if (auth === "Bearer PLEB_TOKEN") { + return [ + 200, + { + id: 1234, + administrator: false, + } as UserInfo, + ] + } + if (auth === "Bearer ADMIN_TOKEN") { + return [ + 200, + { + administrator: true, + } as UserInfo, + ] + } + }) + }) + + it("grades correctly when all wrong", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(allWrongQuizItem1) + + expect(res.status).toEqual(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(false) + }) + + it("grades correctly when some correct", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(someCorrectQuizItem1) + + expect(res.status).toEqual(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(false) + }) + + it("grades correctly when all correct", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(allCorrectQuizItem1) + + expect(res.status).toEqual(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(true) + }) + }) + + describe("NeedToSelectNCorrectOptions correct grading", () => { + beforeEach(async () => { + await safeSeed({ + directory: "./database/seeds", + specific: "multipleChoiceGradingPolicy.ts", + }) + }) + + afterEach(async () => { + await safeClean() + }) + + beforeEach(() => { + nock("https://tmc.mooc.fi") + .get("/api/v8/users/current?show_user_fields=true") + .reply(function() { + const auth = this.req.headers.authorization + if (auth === "Bearer PLEB_TOKEN") { + return [ + 200, + { + id: 1234, + administrator: false, + } as UserInfo, + ] + } + if (auth === "Bearer ADMIN_TOKEN") { + return [ + 200, + { + administrator: true, + } as UserInfo, + ] + } + }) + }) + + it("grades correctly when all wrong", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(allWrongQuizItem2) + + expect(res.status).toBe(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(false) + }) + + it("grades correctly when some correct", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(someCorrectQuizItem2) + + expect(res.status).toBe(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(false) + }) + + it("grades correctly when all correct", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(allCorrectQuizItem2) + + expect(res.status).toBe(200) + expect(res.body.quizAnswer.itemAnswers[0].correct).toEqual(true) + }) + + it("grades correctly when too many options selected", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(tooManySelectedQuizItem2) + + expect(500) + }) + + it("grades correctly when too few options selected", async () => { + const res = await request(app.callback()) + .post("/api/v2/widget/answer") + .set("Authorization", "bearer PLEB_TOKEN") + .set("Accept", "application/json") + .send(tooFewSelectedQuizItem2) + + expect(500) + }) + }) +}) diff --git a/packages/quizzes-dashboard/components/QuizEditForms/QuizItem/MultipleChoiceContent/MultipleChoiceModalContent.tsx b/packages/quizzes-dashboard/components/QuizEditForms/QuizItem/MultipleChoiceContent/MultipleChoiceModalContent.tsx index 4c71027a..c4ff3aa7 100644 --- a/packages/quizzes-dashboard/components/QuizEditForms/QuizItem/MultipleChoiceContent/MultipleChoiceModalContent.tsx +++ b/packages/quizzes-dashboard/components/QuizEditForms/QuizItem/MultipleChoiceContent/MultipleChoiceModalContent.tsx @@ -11,8 +11,15 @@ import { RadioGroup, FormControl, FormHelperText, + useFormControl, Select, MenuItem, + TextField, + Fade, + Grow, + Collapse, + Slide, + InputLabel, } from "@material-ui/core" import { editedQuizItemTitle, @@ -24,6 +31,8 @@ import { toggledAllAnswersCorrect, editedItemDirection, editedQuizItemFeedbackDisplayPolicy, + editedMultipleSelectedOptionsGradingOptions, + editedMultipleSelectedOptionsGradingPolicyN, } from "../../../../store/editor/items/itemAction" import { useTypedSelector } from "../../../../store/store" import { useDispatch } from "react-redux" @@ -39,7 +48,7 @@ import { ModalWrapper } from "../../../Shared/Modal" const ModalContent = styled.div` display: flex; padding: 1rem; - justify-content: center; + justify-content: space-between; @media only screen and (max-width: 600px) { width: 100%; } @@ -80,69 +89,12 @@ export const MultipleChoiceModalContent = ({ item }: EditorModalProps) => { const storeItem = useTypedSelector(state => state.editor.items[item.id]) const storeOptions = useTypedSelector(state => state.editor.options) const dispatch = useDispatch() + return ( Advanced editing - - - - dispatch( - toggledSharedOptionFeedbackMessage( - storeItem.id, - event.target.checked, - ), - ) - } - /> - } - /> - - dispatch( - toggledMultiOptions(storeItem.id, event.target.checked), - ) - } - /> - } - /> - - - - - { /> - - + + + dispatch(toggledAllAnswersCorrect(storeItem.id)) + } + /> + } + label="All answers correct (no matter what one answers it is correct)" + labelPlacement="start" + /> + - dispatch(toggledAllAnswersCorrect(storeItem.id)) + + dispatch( + toggledMultiOptions(storeItem.id, event.target.checked), + ) } /> } - label="All answers correct (no matter what one answers it is correct)" /> - - + + + + + + Feedback display policy + + + + + + + + dispatch( + editedMultipleSelectedOptionsGradingPolicyN( + storeItem.id, + (e.target.value as unknown) as number, + ), + ) + } + > + + +