From 7501da09dcf43d36c946da2549e90113cf612510 Mon Sep 17 00:00:00 2001 From: Matti Lupari Date: Thu, 22 Aug 2024 17:52:12 +0300 Subject: [PATCH] CSCEXAM-000 Refactor large components/services to smaller --- .../student/student-dashboard.component.ts | 2 +- .../sections/section-question.component.ts | 8 +- .../exam/editor/sections/section.component.ts | 6 +- ui/src/app/exam/exam.service.ts | 10 +- .../basequestion/multiple-choice.component.ts | 6 +- .../weighted-multichoice.component.ts | 6 +- .../app/question/library/library.service.ts | 8 +- .../app/question/question-scoring.service.ts | 168 ++++++++++++ ui/src/app/question/question.service.ts | 157 ------------ ui/src/app/reservation/reservation.model.ts | 46 ++++ ui/src/app/reservation/reservation.service.ts | 186 +++++++++++++- .../app/reservation/reservations.component.ts | 240 ++---------------- .../review/assessment/assessment.component.ts | 8 +- .../print/printed-assessment.component.ts | 6 +- .../print/printed-multi-choice.component.ts | 14 +- .../multi-choice-question.component.ts | 16 +- .../assessment/sections/section.component.ts | 8 +- .../listing/summary/exam-summary.service.ts | 8 +- 18 files changed, 470 insertions(+), 433 deletions(-) create mode 100644 ui/src/app/question/question-scoring.service.ts diff --git a/ui/src/app/dashboard/student/student-dashboard.component.ts b/ui/src/app/dashboard/student/student-dashboard.component.ts index 562c600bd..79d33df32 100644 --- a/ui/src/app/dashboard/student/student-dashboard.component.ts +++ b/ui/src/app/dashboard/student/student-dashboard.component.ts @@ -5,11 +5,11 @@ import type { OnInit } from '@angular/core'; import { Component, signal } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; +import { DashboardEnrolment } from 'src/app/dashboard/dashboard.model'; import { ActiveEnrolmentComponent } from 'src/app/enrolment/active/active-enrolment.component'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; import { PageHeaderComponent } from 'src/app/shared/components/page-header.component'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; -import type { DashboardEnrolment } from './student-dashboard.service'; import { StudentDashboardService } from './student-dashboard.service'; @Component({ diff --git a/ui/src/app/exam/editor/sections/section-question.component.ts b/ui/src/app/exam/editor/sections/section-question.component.ts index a098447fa..fa0af1b46 100644 --- a/ui/src/app/exam/editor/sections/section-question.component.ts +++ b/ui/src/app/exam/editor/sections/section-question.component.ts @@ -22,6 +22,7 @@ import { map } from 'rxjs/operators'; import type { ExamSection } from 'src/app/exam/exam.model'; import { BaseQuestionEditorComponent } from 'src/app/question/examquestion/base-question-editor.component'; import { ExamQuestionEditorComponent } from 'src/app/question/examquestion/exam-question-editor.component'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion, ExamSectionQuestionOption, Question } from 'src/app/question/question.model'; import { QuestionService } from 'src/app/question/question.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; @@ -64,15 +65,16 @@ export class SectionQuestionComponent { private toast: ToastrService, private Confirmation: ConfirmationDialogService, private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Attachment: AttachmentService, private Files: FileService, ) {} - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); - getMinimumOptionScore = () => this.Question.getMinimumOptionScore(this.sectionQuestion); + getMinimumOptionScore = () => this.QuestionScore.getMinimumOptionScore(this.sectionQuestion); editQuestion = () => this.openExamQuestionEditor(); diff --git a/ui/src/app/exam/editor/sections/section.component.ts b/ui/src/app/exam/editor/sections/section.component.ts index 03377bea3..2475c8322 100644 --- a/ui/src/app/exam/editor/sections/section.component.ts +++ b/ui/src/app/exam/editor/sections/section.component.ts @@ -30,8 +30,8 @@ import type { ExamMaterial, ExamSection } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; import { BaseQuestionEditorComponent } from 'src/app/question/examquestion/base-question-editor.component'; import { QuestionSelectorComponent } from 'src/app/question/picker/question-picker.component'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion, Question } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { ConfirmationDialogService } from 'src/app/shared/dialogs/confirmation-dialog.service'; import { FileService } from 'src/app/shared/file/file.service'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; @@ -77,7 +77,7 @@ export class SectionComponent { private modal: NgbModal, private toast: ToastrService, private dialogs: ConfirmationDialogService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Files: FileService, private Exam: ExamService, ) {} @@ -278,7 +278,7 @@ export class SectionComponent { optional: this.section.optional, }); - private getQuestionScore = (question: ExamSectionQuestion) => this.Question.calculateMaxScore(question); + private getQuestionScore = (question: ExamSectionQuestion) => this.QuestionScore.calculateMaxScore(question); private insertExamQuestion = (question: Question, seq: number) => { const resource = this.collaborative diff --git a/ui/src/app/exam/exam.service.ts b/ui/src/app/exam/exam.service.ts index 60c727cde..a692e1272 100644 --- a/ui/src/app/exam/exam.service.ts +++ b/ui/src/app/exam/exam.service.ts @@ -10,7 +10,7 @@ import { parseISO } from 'date-fns'; import { ToastrService } from 'ngx-toastr'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { QuestionService } from 'src/app/question/question.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { SessionService } from 'src/app/session/session.service'; import { ConfirmationDialogService } from 'src/app/shared/dialogs/confirmation-dialog.service'; import { CommonExamService } from 'src/app/shared/miscellaneous/common-exam.service'; @@ -47,7 +47,7 @@ export class ExamService { private translate: TranslateService, private toast: ToastrService, private CommonExam: CommonExamService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Session: SessionService, private ConfirmationDialog: ConfirmationDialogService, ) {} @@ -253,7 +253,7 @@ export class ExamService { getSectionTotalNumericScore = (section: ExamSection): number => { const score = section.sectionQuestions.reduce((n, sq) => { - const points = this.Question.calculateAnswerScore(sq); + const points = this.QuestionScore.calculateAnswerScore(sq); // handle only numeric scores (leave out approved/rejected type of scores) return n + (points.rejected === false && points.approved === false ? points.score : 0); }, 0); @@ -262,7 +262,7 @@ export class ExamService { getSectionTotalScore = (section: ExamSection): number => { const score = section.sectionQuestions.reduce((n, sq) => { - const points = this.Question.calculateAnswerScore(sq); + const points = this.QuestionScore.calculateAnswerScore(sq); return n + points.score; }, 0); return Number.isInteger(score) ? score : parseFloat(score.toFixed(2)); @@ -273,7 +273,7 @@ export class ExamService { if (!sq || !sq.question) { return n; } - return n + this.Question.calculateMaxScore(sq); + return n + this.QuestionScore.calculateMaxScore(sq); }, 0); if (section.lotteryOn) { maxScore = (maxScore * section.lotteryItemCount) / Math.max(1, section.sectionQuestions.length); diff --git a/ui/src/app/question/basequestion/multiple-choice.component.ts b/ui/src/app/question/basequestion/multiple-choice.component.ts index cf2d94e63..9f51855da 100644 --- a/ui/src/app/question/basequestion/multiple-choice.component.ts +++ b/ui/src/app/question/basequestion/multiple-choice.component.ts @@ -6,8 +6,8 @@ import { UpperCasePipe } from '@angular/common'; import { Component, Input, OnInit } from '@angular/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { MultipleChoiceOption, Question, QuestionDraft } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { MultipleChoiceOptionEditorComponent } from './multiple-choice-option.component'; import { WeightedMultipleChoiceOptionEditorComponent } from './weighted-multiple-choice-option.component'; @@ -114,7 +114,7 @@ export class MultipleChoiceEditorComponent implements OnInit { constructor( private translate: TranslateService, private toast: ToastrService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, ) {} ngOnInit() { @@ -135,5 +135,5 @@ export class MultipleChoiceEditorComponent implements OnInit { this.question.options.push(option); }; - calculateDefaultMaxPoints = () => this.Question.calculateDefaultMaxPoints(this.question as Question); + calculateDefaultMaxPoints = () => this.QuestionScore.calculateDefaultMaxPoints(this.question as Question); } diff --git a/ui/src/app/question/examquestion/weighted-multichoice.component.ts b/ui/src/app/question/examquestion/weighted-multichoice.component.ts index db4ad0a46..8b97616f5 100644 --- a/ui/src/app/question/examquestion/weighted-multichoice.component.ts +++ b/ui/src/app/question/examquestion/weighted-multichoice.component.ts @@ -8,8 +8,8 @@ import { ControlContainer, FormsModule, NgForm } from '@angular/forms'; import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestionOption } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; @Component({ selector: 'xm-eq-weighted-mc', @@ -111,12 +111,12 @@ export class WeightedMultiChoiceComponent { lotteryOn = input(false); isInPublishedExam = input(false); optionsChanged = output(); - maxScore = computed(() => this.QuestionService.calculateWeightedMaxPoints(this.options())); + maxScore = computed(() => this.QuestionScore.calculateWeightedMaxPoints(this.options())); constructor( private TranslateService: TranslateService, private ToastrService: ToastrService, - private QuestionService: QuestionService, + private QuestionScore: QuestionScoringService, ) {} updateScore = (score: number, index: number) => { diff --git a/ui/src/app/question/library/library.service.ts b/ui/src/app/question/library/library.service.ts index ce1ec013b..04c18d674 100644 --- a/ui/src/app/question/library/library.service.ts +++ b/ui/src/app/question/library/library.service.ts @@ -8,8 +8,8 @@ import { SESSION_STORAGE, WebStorageService } from 'ngx-webstorage-service'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import type { Course, Exam, ExamSection } from 'src/app/exam/exam.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { LibraryQuestion, Tag } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { User } from 'src/app/session/session.model'; import { UserService } from 'src/app/shared/user/user.service'; @@ -18,7 +18,7 @@ export class LibraryService { constructor( private http: HttpClient, @Inject(SESSION_STORAGE) private webStorageService: WebStorageService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private User: UserService, ) {} @@ -208,9 +208,9 @@ export class LibraryService { } else if (q.defaultEvaluationType === 'Selection') { return 'i18n_evaluation_select'; } else if (q.type === 'WeightedMultipleChoiceQuestion') { - return this.Question.calculateDefaultMaxPoints(q); + return this.QuestionScore.calculateDefaultMaxPoints(q); } else if (q.type === 'ClaimChoiceQuestion') { - return this.Question.getCorrectClaimChoiceOptionDefaultScore(q); + return this.QuestionScore.getCorrectClaimChoiceOptionDefaultScore(q); } return ''; }; diff --git a/ui/src/app/question/question-scoring.service.ts b/ui/src/app/question/question-scoring.service.ts new file mode 100644 index 000000000..ba02e05df --- /dev/null +++ b/ui/src/app/question/question-scoring.service.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Exam, ExamSection } from 'src/app/exam/exam.model'; +import { isNumber } from 'src/app/shared/miscellaneous/helpers'; +import { ExamSectionQuestion, ExamSectionQuestionOption, Question, QuestionAmounts } from './question.model'; + +@Injectable({ providedIn: 'root' }) +export class QuestionScoringService { + constructor(private httpClient: HttpClient) {} + + getQuestionAmounts = (exam: Exam): QuestionAmounts => { + const essays = exam.examSections + .flatMap((es) => es.sectionQuestions) + .filter((esq) => esq.question.type === 'EssayQuestion'); + const scores = essays + .filter((e) => e.evaluationType === 'Selection' && e.essayAnswer) + .map((e) => e.essayAnswer?.evaluatedScore); + const accepted = scores.filter((s) => s === 1).length; + const rejected = scores.filter((s) => s === 0).length; + return { accepted: accepted, rejected: rejected, hasEssays: essays.length > 0 }; + }; + + getEssayQuestionAmountsBySection = (section: ExamSection) => { + const scores = section.sectionQuestions + .filter((sq) => sq.question.type === 'EssayQuestion' && sq.evaluationType === 'Selection' && sq.essayAnswer) + .map((sq) => sq.essayAnswer?.evaluatedScore); + return { + accepted: scores.filter((s) => s === 1).length, + rejected: scores.filter((s) => s === 0).length, + total: scores.length, + }; + }; + + calculateDefaultMaxPoints = (question: Question) => + question.options.filter((o) => o.defaultScore > 0).reduce((a, b) => a + b.defaultScore, 0); + + getMinimumOptionScore = (sectionQuestion: ExamSectionQuestion): number => { + const optionScores = sectionQuestion.options.map((o) => o.score); + const scores = [0, ...optionScores]; // Make sure 0 is included + return sectionQuestion.question.type === 'WeightedMultipleChoiceQuestion' + ? Math.max(0, Math.min(...scores)) // Weighted mcq mustn't have a negative min score + : Math.min(...scores); + }; + + getCorrectClaimChoiceOptionDefaultScore = (question: Question): number => { + if (!question.options) { + return 0; + } + const correctOption = question.options.filter((o) => o.correctOption && o.claimChoiceType === 'CorrectOption'); + return correctOption.length === 1 ? correctOption[0].defaultScore : 0; + }; + + scoreClozeTestAnswer = (sectionQuestion: ExamSectionQuestion): number => { + if (!sectionQuestion.clozeTestAnswer) { + return 0; + } + if (isNumber(sectionQuestion.forcedScore)) { + return sectionQuestion.forcedScore; + } + const score = sectionQuestion.clozeTestAnswer.score; + if (!score) return 0; + const proportion = + (score.correctAnswers * sectionQuestion.maxScore) / (score.correctAnswers + score.incorrectAnswers); + return parseFloat(proportion.toFixed(2)); + }; + + scoreWeightedMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const score = sectionQuestion.options.filter((o) => o.answered).reduce((a, b) => a + b.score, 0); + return Math.max(0, score); + }; + + // For non-weighted mcq + scoreMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const answered = sectionQuestion.options.filter((o) => o.answered); + if (answered.length === 0) { + // No answer + return 0; + } + if (answered.length !== 1) { + console.error('multiple options selected for a MultiChoice answer!'); + } + + return answered[0].option.correctOption ? sectionQuestion.maxScore : 0; + }; + + scoreClaimChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const selected = sectionQuestion.options.filter((o) => o.answered); + + // Use the score from the skip option if no option was chosen + const skipOption = sectionQuestion.options.filter((o) => o.option.claimChoiceType === 'SkipOption'); + const skipScore = skipOption.length === 1 ? skipOption[0].score : 0; + + if (selected.length === 0) { + return skipScore; + } + if (selected.length !== 1) { + console.error('multiple options selected for a ClaimChoice answer!'); + } + if (selected[0].score && isNumber(selected[0].score)) { + return selected[0].score; + } + return 0; + }; + + calculateAnswerScore = (sq: ExamSectionQuestion) => { + switch (sq.question.type) { + case 'MultipleChoiceQuestion': + return { score: this.scoreMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; + case 'WeightedMultipleChoiceQuestion': + return { score: this.scoreWeightedMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; + case 'ClozeTestQuestion': + return { score: this.scoreClozeTestAnswer(sq), rejected: false, approved: false }; + case 'EssayQuestion': + if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Points') { + return { score: sq.essayAnswer.evaluatedScore, rejected: false, approved: false }; + } else if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Selection') { + const score = sq.essayAnswer.evaluatedScore; + return { score: score, rejected: score === 0, approved: score === 1 }; + } + return { score: 0, rejected: false, approved: false }; + case 'ClaimChoiceQuestion': + return { score: this.scoreClaimChoiceAnswer(sq, false), rejected: false, approved: false }; + default: + throw Error('unknown question type'); + } + }; + + calculateWeightedMaxPoints = (options: ExamSectionQuestionOption[]): number => { + const points = options.filter((o) => o.score > 0).reduce((a, b) => a + b.score, 0); + return parseFloat(points.toFixed(2)); + }; + + getCorrectClaimChoiceOptionScore = (sectionQuestion: ExamSectionQuestion): number => { + if (!sectionQuestion.options) { + return 0; + } + const optionScores = sectionQuestion.options.map((o) => o.score); + return Math.max(0, ...optionScores); + }; + + calculateMaxScore = (question: ExamSectionQuestion) => { + const evaluationType = question.evaluationType; + const type = question.question.type; + if (evaluationType === 'Points' || type === 'MultipleChoiceQuestion' || type === 'ClozeTestQuestion') { + return question.maxScore; + } + if (type === 'WeightedMultipleChoiceQuestion') { + return this.calculateWeightedMaxPoints(question.options); + } + if (type === 'ClaimChoiceQuestion') { + return this.getCorrectClaimChoiceOptionScore(question); + } + return 0; + }; +} diff --git a/ui/src/app/question/question.service.ts b/ui/src/app/question/question.service.ts index 4cc3ec483..588e8ce34 100644 --- a/ui/src/app/question/question.service.ts +++ b/ui/src/app/question/question.service.ts @@ -8,17 +8,14 @@ import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import type { Exam, ExamSection } from 'src/app/exam/exam.model'; import { SessionService } from 'src/app/session/session.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; import { FileService } from 'src/app/shared/file/file.service'; -import { isNumber } from 'src/app/shared/miscellaneous/helpers'; import { ExamSectionQuestion, ExamSectionQuestionOption, MultipleChoiceOption, Question, - QuestionAmounts, QuestionDraft, ReverseQuestion, } from './question.model'; @@ -73,160 +70,6 @@ export class QuestionService { getQuestion = (id: number): Observable => this.http.get(this.questionsApi(id)); - getQuestionAmounts = (exam: Exam): QuestionAmounts => { - const essays = exam.examSections - .flatMap((es) => es.sectionQuestions) - .filter((esq) => esq.question.type === 'EssayQuestion'); - const scores = essays - .filter((e) => e.evaluationType === 'Selection' && e.essayAnswer) - .map((e) => e.essayAnswer?.evaluatedScore); - const accepted = scores.filter((s) => s === 1).length; - const rejected = scores.filter((s) => s === 0).length; - return { accepted: accepted, rejected: rejected, hasEssays: essays.length > 0 }; - }; - - getEssayQuestionAmountsBySection = (section: ExamSection) => { - const scores = section.sectionQuestions - .filter((sq) => sq.question.type === 'EssayQuestion' && sq.evaluationType === 'Selection' && sq.essayAnswer) - .map((sq) => sq.essayAnswer?.evaluatedScore); - return { - accepted: scores.filter((s) => s === 1).length, - rejected: scores.filter((s) => s === 0).length, - total: scores.length, - }; - }; - - calculateDefaultMaxPoints = (question: Question) => - question.options.filter((o) => o.defaultScore > 0).reduce((a, b) => a + b.defaultScore, 0); - - getMinimumOptionScore = (sectionQuestion: ExamSectionQuestion): number => { - const optionScores = sectionQuestion.options.map((o) => o.score); - const scores = [0, ...optionScores]; // Make sure 0 is included - return sectionQuestion.question.type === 'WeightedMultipleChoiceQuestion' - ? Math.max(0, Math.min(...scores)) // Weighted mcq mustn't have a negative min score - : Math.min(...scores); - }; - - getCorrectClaimChoiceOptionDefaultScore = (question: Question): number => { - if (!question.options) { - return 0; - } - const correctOption = question.options.filter((o) => o.correctOption && o.claimChoiceType === 'CorrectOption'); - return correctOption.length === 1 ? correctOption[0].defaultScore : 0; - }; - - scoreClozeTestAnswer = (sectionQuestion: ExamSectionQuestion): number => { - if (!sectionQuestion.clozeTestAnswer) { - return 0; - } - if (isNumber(sectionQuestion.forcedScore)) { - return sectionQuestion.forcedScore; - } - const score = sectionQuestion.clozeTestAnswer.score; - if (!score) return 0; - const proportion = - (score.correctAnswers * sectionQuestion.maxScore) / (score.correctAnswers + score.incorrectAnswers); - return parseFloat(proportion.toFixed(2)); - }; - - scoreWeightedMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const score = sectionQuestion.options.filter((o) => o.answered).reduce((a, b) => a + b.score, 0); - return Math.max(0, score); - }; - - // For non-weighted mcq - scoreMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const answered = sectionQuestion.options.filter((o) => o.answered); - if (answered.length === 0) { - // No answer - return 0; - } - if (answered.length !== 1) { - console.error('multiple options selected for a MultiChoice answer!'); - } - - return answered[0].option.correctOption ? sectionQuestion.maxScore : 0; - }; - - scoreClaimChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const selected = sectionQuestion.options.filter((o) => o.answered); - - // Use the score from the skip option if no option was chosen - const skipOption = sectionQuestion.options.filter((o) => o.option.claimChoiceType === 'SkipOption'); - const skipScore = skipOption.length === 1 ? skipOption[0].score : 0; - - if (selected.length === 0) { - return skipScore; - } - if (selected.length !== 1) { - console.error('multiple options selected for a ClaimChoice answer!'); - } - if (selected[0].score && isNumber(selected[0].score)) { - return selected[0].score; - } - return 0; - }; - - calculateAnswerScore = (sq: ExamSectionQuestion) => { - switch (sq.question.type) { - case 'MultipleChoiceQuestion': - return { score: this.scoreMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; - case 'WeightedMultipleChoiceQuestion': - return { score: this.scoreWeightedMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; - case 'ClozeTestQuestion': - return { score: this.scoreClozeTestAnswer(sq), rejected: false, approved: false }; - case 'EssayQuestion': - if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Points') { - return { score: sq.essayAnswer.evaluatedScore, rejected: false, approved: false }; - } else if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Selection') { - const score = sq.essayAnswer.evaluatedScore; - return { score: score, rejected: score === 0, approved: score === 1 }; - } - return { score: 0, rejected: false, approved: false }; - case 'ClaimChoiceQuestion': - return { score: this.scoreClaimChoiceAnswer(sq, false), rejected: false, approved: false }; - default: - throw Error('unknown question type'); - } - }; - - calculateWeightedMaxPoints = (options: ExamSectionQuestionOption[]): number => { - const points = options.filter((o) => o.score > 0).reduce((a, b) => a + b.score, 0); - return parseFloat(points.toFixed(2)); - }; - - getCorrectClaimChoiceOptionScore = (sectionQuestion: ExamSectionQuestion): number => { - if (!sectionQuestion.options) { - return 0; - } - const optionScores = sectionQuestion.options.map((o) => o.score); - return Math.max(0, ...optionScores); - }; - - calculateMaxScore = (question: ExamSectionQuestion) => { - const evaluationType = question.evaluationType; - const type = question.question.type; - if (evaluationType === 'Points' || type === 'MultipleChoiceQuestion' || type === 'ClozeTestQuestion') { - return question.maxScore; - } - if (type === 'WeightedMultipleChoiceQuestion') { - return this.calculateWeightedMaxPoints(question.options); - } - if (type === 'ClaimChoiceQuestion') { - return this.getCorrectClaimChoiceOptionScore(question); - } - return 0; - }; - createQuestion = (question: QuestionDraft): Promise => { const body = this.getQuestionData(question); // TODO: make this a pipe diff --git a/ui/src/app/reservation/reservation.model.ts b/ui/src/app/reservation/reservation.model.ts index 990ec9072..e02ec4e7f 100644 --- a/ui/src/app/reservation/reservation.model.ts +++ b/ui/src/app/reservation/reservation.model.ts @@ -3,8 +3,10 @@ // SPDX-License-Identifier: EUPL-1.2 import type { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; +import { CollaborativeExam, Implementation } from 'src/app/exam/exam.model'; import { Address, WorkingHour } from 'src/app/facility/facility.model'; import type { User } from 'src/app/session/session.model'; +import { isObject } from 'src/app/shared/miscellaneous/helpers'; export type DefaultWorkingHours = { id?: number; @@ -100,3 +102,47 @@ export interface Reservation { endAt: string; user: User; } + +// All of this is needed to put all our reservations in one basket :D +type ExamEnrolmentDisplay = ExamEnrolment & { teacherAggregate: string }; +type MachineDisplay = Omit & { room: Partial }; +type ReservationDisplay = Omit & { + machine: Partial; + userAggregate: string; + stateOrd: number; + enrolment: ExamEnrolmentDisplay; +}; +export type LocalTransferExamEnrolment = Omit & { + exam: { id: number; external: true; examOwners: User[]; state: string; parent: null }; +}; +type CollaborativeExamEnrolment = Omit & { + exam: CollaborativeExam & { examOwners: User[]; parent: null; implementation: Implementation }; +}; +export type LocalTransferExamReservation = Omit & { + enrolment: LocalTransferExamEnrolment; +}; +export type RemoteTransferExamReservation = Omit & { + enrolment: ExamEnrolmentDisplay; + org: { name: string; code: string }; +}; +type CollaborativeExamReservation = Omit & { + enrolment: CollaborativeExamEnrolment; +}; + +export type AnyReservation = + | ReservationDisplay + | LocalTransferExamReservation + | RemoteTransferExamReservation + | CollaborativeExamReservation; + +// Transfer examination taking place here +export function isLocalTransfer(reservation: AnyReservation): reservation is LocalTransferExamReservation { + return !reservation.enrolment || isObject(reservation.enrolment.externalExam); +} +// Transfer examination taking place elsewhere +export function isRemoteTransfer(reservation: AnyReservation): reservation is RemoteTransferExamReservation { + return isObject(reservation.externalReservation); +} +export function isCollaborative(reservation: AnyReservation): reservation is CollaborativeExamReservation { + return isObject(reservation.enrolment?.collaborativeExam); +} diff --git a/ui/src/app/reservation/reservation.service.ts b/ui/src/app/reservation/reservation.service.ts index 8eff83cda..cb6cc0f7b 100644 --- a/ui/src/app/reservation/reservation.service.ts +++ b/ui/src/app/reservation/reservation.service.ts @@ -2,18 +2,37 @@ // // SPDX-License-Identifier: EUPL-1.2 +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { parseISO } from 'date-fns'; -import { noop } from 'rxjs'; -import type { Exam } from 'src/app/exam/exam.model'; +import { addMinutes, parseISO } from 'date-fns'; +import { debounceTime, distinctUntilChanged, exhaustMap, forkJoin, from, map, noop, Observable, of } from 'rxjs'; +import { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; +import type { CollaborativeExam, Exam } from 'src/app/exam/exam.model'; +import { User } from 'src/app/session/session.model'; import { ChangeMachineDialogComponent } from './admin/change-machine-dialog.component'; import { RemoveReservationDialogComponent } from './admin/remove-reservation-dialog.component'; -import type { ExamMachine, Reservation } from './reservation.model'; +import { + isCollaborative, + isLocalTransfer, + isRemoteTransfer, + RemoteTransferExamReservation, + type AnyReservation, + type ExamMachine, + type LocalTransferExamEnrolment, + type LocalTransferExamReservation, + type Reservation, +} from './reservation.model'; +export interface Selection { + [data: string]: string; +} @Injectable({ providedIn: 'root' }) export class ReservationService { - constructor(private modal: NgbModal) {} + constructor( + private http: HttpClient, + private modal: NgbModal, + ) {} printExamState = (reservation: { enrolment: { exam: { state: string }; collaborativeExam: { state: string }; noShow: boolean }; @@ -55,4 +74,161 @@ export class ReservationService { modalRef.componentInstance.reservation = reservation; return modalRef.result; }; + + listReservations$ = (params: Selection) => { + // Do not fetch byod exams if machine id, room id or external ref in the query params. + // Also applies if searching for external reservations + const eventRequest = + params.roomId || params.machineId || params.externalRef || params.state?.startsWith('EXTERNAL_') + ? of([]) + : this.http.get('/app/events', { params: params }); + return forkJoin([this.http.get('/app/reservations', { params: params }), eventRequest]).pipe( + map(([reservations, enrolments]) => { + const events: Partial[] = enrolments.map((ee) => { + return { + user: ee.user, + enrolment: ee, + startAt: ee.examinationEventConfiguration?.examinationEvent.start, + endAt: addMinutes( + parseISO(ee.examinationEventConfiguration?.examinationEvent.start as string), + ee.exam.duration, + ).toISOString(), + }; + }); + const allEvents: Partial[] = reservations; + return allEvents.concat(events) as Reservation[]; // FIXME: this is wrong(?) <- don't know how to model anymore with strict checking + }), + map((reservations: Reservation[]) => + reservations.map((r) => ({ + ...r, + userAggregate: r.user + ? `${r.user.lastName} ${r.user.firstName}` + : r.externalUserRef + ? r.externalUserRef + : r.enrolment?.exam + ? r.enrolment.exam.id.toString() + : '', + org: '', + stateOrd: 0, + enrolment: r.enrolment ? { ...r.enrolment, teacherAggregate: '' } : r.enrolment, + })), + ), + map((reservations: AnyReservation[]) => { + // Transfer exams taken here + reservations.filter(isLocalTransfer).forEach((r: LocalTransferExamReservation) => { + const state = r.enrolment?.externalExam?.finished ? 'EXTERNAL_FINISHED' : 'EXTERNAL_UNFINISHED'; + const enrolment: LocalTransferExamEnrolment = { + ...r.enrolment, + exam: { + id: r.enrolment?.externalExam?.id as number, + external: true, + examOwners: [], + state: state, + parent: null, + }, + }; + r.enrolment = enrolment; + }); + // Transfer exams taken elsewhere + reservations.filter(isRemoteTransfer).forEach((r: RemoteTransferExamReservation) => { + if (r.externalReservation) { + r.org = { name: r.externalReservation.orgName, code: r.externalReservation.orgCode }; + r.machine = { + name: r.externalReservation.machineName, + room: { name: r.externalReservation.roomName }, + }; + } + }); + // Collaborative exams + reservations.filter(isCollaborative).forEach((r) => { + if (!r.enrolment.exam) { + r.enrolment.exam = { + ...r.enrolment.collaborativeExam, + examOwners: [], + parent: null, + implementation: 'AQUARIUM', + }; + } else { + r.enrolment.exam.examOwners = []; + } + }); + + return reservations; + }), + map((reservations: AnyReservation[]) => reservations.filter((r) => r.enrolment?.exam)), + map((reservations: AnyReservation[]) => { + reservations.forEach((r) => { + const exam = (r.enrolment?.exam.parent || r.enrolment?.exam) as Exam; + r.enrolment = { + ...(r.enrolment as ExamEnrolment), + teacherAggregate: exam.examOwners.map((o) => o.lastName + o.firstName).join(), + }; + const state = this.printExamState(r) as string; + r.stateOrd = [ + 'PUBLISHED', + 'NO_SHOW', + 'STUDENT_STARTED', + 'ABORTED', + 'REVIEW', + 'REVIEW_STARTED', + 'GRADED', + 'GRADED_LOGGED', + 'REJECTED', + 'ARCHIVED', + 'EXTERNAL_UNFINISHED', + 'EXTERNAL_FINISHED', + ].indexOf(state); + }); + return reservations; + }), + ); + }; + + searchStudents$ = (text$: Observable) => + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => + term.length < 2 + ? from([]) + : this.http.get<(User & { name: string })[]>('/app/reservations/students', { + params: { filter: term }, + }), + ), + map((ss) => ss.sort((a, b) => a.firstName.localeCompare(b.firstName)).slice(0, 100)), + ); + + searchOwners$ = (text$: Observable) => + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => + term.length < 2 + ? from([]) + : this.http.get<(User & { name: string })[]>('/app/reservations/teachers', { + params: { filter: term }, + }), + ), + map((ss) => ss.sort((a, b) => a.lastName.localeCompare(b.lastName)).slice(0, 100)), + ); + + searchExams$ = (text$: Observable, includeCollaboratives = false) => { + const listExams$ = (text: string) => { + const examObservables: Observable[] = [ + this.http.get('/app/reservations/exams', { params: { filter: text } }), + ]; + if (includeCollaboratives) { + examObservables.push( + this.http.get('/app/iop/exams', { params: { filter: text } }), + ); + } + return forkJoin(examObservables).pipe(map((exams) => exams.flat())); + }; + return text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => (term.length < 2 ? from([]) : listExams$(term))), + map((es) => es.sort((a, b) => (a.name as string).localeCompare(b.name as string)).slice(0, 100)), + ); + }; } diff --git a/ui/src/app/reservation/reservations.component.ts b/ui/src/app/reservation/reservations.component.ts index b35b48401..2662aa9c1 100644 --- a/ui/src/app/reservation/reservations.component.ts +++ b/ui/src/app/reservation/reservations.component.ts @@ -8,59 +8,21 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { NgbTypeaheadModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { addMinutes, endOfDay, parseISO, startOfDay } from 'date-fns'; +import { endOfDay, startOfDay } from 'date-fns'; import { ToastrService } from 'ngx-toastr'; -import { Observable, forkJoin, from, of } from 'rxjs'; -import { debounceTime, distinctUntilChanged, exhaustMap, map } from 'rxjs/operators'; -import type { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; -import type { CollaborativeExam, Exam, ExamImpl, Implementation } from 'src/app/exam/exam.model'; +import { Observable } from 'rxjs'; +import type { CollaborativeExam, Exam, ExamImpl } from 'src/app/exam/exam.model'; import type { User } from 'src/app/session/session.model'; import { SessionService } from 'src/app/session/session.service'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; import { PageHeaderComponent } from 'src/app/shared/components/page-header.component'; import { DatePickerComponent } from 'src/app/shared/date/date-picker.component'; -import { isObject } from 'src/app/shared/miscellaneous/helpers'; import { DropdownSelectComponent } from 'src/app/shared/select/dropdown-select.component'; import { Option } from 'src/app/shared/select/select.model'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; import { ReservationDetailsComponent } from './reservation-details.component'; -import type { ExamMachine, ExamRoom, Reservation } from './reservation.model'; -import { ReservationService } from './reservation.service'; - -interface Selection { - [data: string]: string; -} - -// All of this is needed to put all our reservations in one basket :D -type ExamEnrolmentDisplay = ExamEnrolment & { teacherAggregate: string }; -type MachineDisplay = Omit & { room: Partial }; -type ReservationDisplay = Omit & { - machine: Partial; - userAggregate: string; - stateOrd: number; - enrolment: ExamEnrolmentDisplay; -}; -type LocalTransferExamEnrolment = Omit & { - exam: { id: number; external: true; examOwners: User[]; state: string; parent: null }; -}; -type CollaborativeExamEnrolment = Omit & { - exam: CollaborativeExam & { examOwners: User[]; parent: null; implementation: Implementation }; -}; -type LocalTransferExamReservation = Omit & { - enrolment: LocalTransferExamEnrolment; -}; -type RemoteTransferExamReservation = Omit & { - enrolment: ExamEnrolmentDisplay; - org: { name: string; code: string }; -}; -type CollaborativeExamReservation = Omit & { - enrolment: CollaborativeExamEnrolment; -}; -export type AnyReservation = - | ReservationDisplay - | LocalTransferExamReservation - | RemoteTransferExamReservation - | CollaborativeExamReservation; +import type { AnyReservation, ExamMachine, ExamRoom } from './reservation.model'; +import { ReservationService, Selection } from './reservation.service'; @Component({ selector: 'xm-reservations', @@ -140,128 +102,18 @@ export class ReservationsComponent implements OnInit { query() { if (this.somethingSelected(this.selection)) { const params = this.createParams(this.selection); - // Do not fetch byod exams if machine id, room id or external ref in the query params. - // Also applies if searching for external reservations - const eventRequest = - params.roomId || params.machineId || params.externalRef || params.state?.startsWith('EXTERNAL_') - ? of([]) - : this.http.get('/app/events', { params: params }); - forkJoin([this.http.get('/app/reservations', { params: params }), eventRequest]) - .pipe( - map(([reservations, enrolments]) => { - const events: Partial[] = enrolments.map((ee) => { - return { - user: ee.user, - enrolment: ee, - startAt: ee.examinationEventConfiguration?.examinationEvent.start, - endAt: addMinutes( - parseISO(ee.examinationEventConfiguration?.examinationEvent.start as string), - ee.exam.duration, - ).toISOString(), - }; - }); - const allEvents: Partial[] = reservations; - return allEvents.concat(events) as Reservation[]; // FIXME: this is wrong(?) <- don't know how to model anymore with strict checking - }), - map((reservations: Reservation[]) => - reservations.map((r) => ({ - ...r, - userAggregate: r.user - ? `${r.user.lastName} ${r.user.firstName}` - : r.externalUserRef - ? r.externalUserRef - : r.enrolment?.exam - ? r.enrolment.exam.id.toString() - : '', - org: '', - stateOrd: 0, - enrolment: r.enrolment ? { ...r.enrolment, teacherAggregate: '' } : r.enrolment, - })), - ), - map((reservations: AnyReservation[]) => { - // Transfer exams taken here - reservations.filter(this.isLocalTransfer).forEach((r: LocalTransferExamReservation) => { - const state = r.enrolment?.externalExam?.finished - ? 'EXTERNAL_FINISHED' - : 'EXTERNAL_UNFINISHED'; - const enrolment: LocalTransferExamEnrolment = { - ...r.enrolment, - exam: { - id: r.enrolment?.externalExam?.id as number, - external: true, - examOwners: [], - state: state, - parent: null, - }, - }; - r.enrolment = enrolment; - }); - // Transfer exams taken elsewhere - reservations.filter(this.isRemoteTransfer).forEach((r: RemoteTransferExamReservation) => { - if (r.externalReservation) { - r.org = { name: r.externalReservation.orgName, code: r.externalReservation.orgCode }; - r.machine = { - name: r.externalReservation.machineName, - room: { name: r.externalReservation.roomName }, - }; - } - }); - // Collaborative exams - reservations.filter(this.isCollaborative).forEach((r) => { - if (!r.enrolment.exam) { - r.enrolment.exam = { - ...r.enrolment.collaborativeExam, - examOwners: [], - parent: null, - implementation: 'AQUARIUM', - }; - } else { - r.enrolment.exam.examOwners = []; - } - }); - - return reservations; - }), - map((reservations: AnyReservation[]) => reservations.filter((r) => r.enrolment?.exam)), - map((reservations: AnyReservation[]) => { - reservations.forEach((r) => { - const exam = (r.enrolment?.exam.parent || r.enrolment?.exam) as Exam; - r.enrolment = { - ...(r.enrolment as ExamEnrolment), - teacherAggregate: exam.examOwners.map((o) => o.lastName + o.firstName).join(), - }; - const state = this.Reservation.printExamState(r) as string; - r.stateOrd = [ - 'PUBLISHED', - 'NO_SHOW', - 'STUDENT_STARTED', - 'ABORTED', - 'REVIEW', - 'REVIEW_STARTED', - 'GRADED', - 'GRADED_LOGGED', - 'REJECTED', - 'ARCHIVED', - 'EXTERNAL_UNFINISHED', - 'EXTERNAL_FINISHED', - ].indexOf(state); - }); - return reservations; - }), - ) - .subscribe({ - next: (reservations) => { - this.reservations = reservations - .filter((r) => r.externalReservation || !this.externalReservationsOnly) - .filter( - (r) => - (!r.externalUserRef && - (r.enrolment.exam as ExamImpl).implementation !== 'AQUARIUM') || - !this.byodExamsOnly, - ); - }, - error: (err) => this.toast.error(err), - }); + this.Reservation.listReservations$(params).subscribe({ + next: (reservations) => { + this.reservations = reservations + .filter((r) => r.externalReservation || !this.externalReservationsOnly) + .filter( + (r) => + (!r.externalUserRef && (r.enrolment.exam as ExamImpl).implementation !== 'AQUARIUM') || + !this.byodExamsOnly, + ); + }, + error: (err) => this.toast.error(err), + }); } } @@ -346,53 +198,12 @@ export class ReservationsComponent implements OnInit { this.query(); } - protected searchStudents$ = (text$: Observable) => - text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => - term.length < 2 - ? from([]) - : this.http.get<(User & { name: string })[]>('/app/reservations/students', { - params: { filter: term }, - }), - ), - map((ss) => ss.sort((a, b) => a.firstName.localeCompare(b.firstName)).slice(0, 100)), - ); + protected searchStudents$ = (text$: Observable) => this.Reservation.searchStudents$(text$); - protected searchOwners$ = (text$: Observable) => - text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => - term.length < 2 - ? from([]) - : this.http.get<(User & { name: string })[]>('/app/reservations/teachers', { - params: { filter: term }, - }), - ), - map((ss) => ss.sort((a, b) => a.lastName.localeCompare(b.lastName)).slice(0, 100)), - ); + protected searchOwners$ = (text$: Observable) => this.Reservation.searchOwners$(text$); - protected searchExams$ = (text$: Observable) => { - const listExams$ = (text: string) => { - const examObservables: Observable[] = [ - this.http.get('/app/reservations/exams', { params: { filter: text } }), - ]; - if (this.isInteroperable && this.isAdminView()) { - examObservables.push( - this.http.get('/app/iop/exams', { params: { filter: text } }), - ); - } - return forkJoin(examObservables).pipe(map((exams) => exams.flat())); - }; - return text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => (term.length < 2 ? from([]) : listExams$(term))), - map((es) => es.sort((a, b) => (a.name as string).localeCompare(b.name as string)).slice(0, 100)), - ); - }; + protected searchExams$ = (text$: Observable) => + this.Reservation.searchExams$(text$, this.isInteroperable && this.isAdminView()); protected nameFormatter = (item: { name: string }) => item.name; @@ -413,15 +224,6 @@ export class ReservationsComponent implements OnInit { return params; }; - // Transfer examination taking place here - private isLocalTransfer = (reservation: AnyReservation): reservation is LocalTransferExamReservation => - !reservation.enrolment || isObject(reservation.enrolment.externalExam); - // Transfer examination taking place elsewhere - private isRemoteTransfer = (reservation: AnyReservation): reservation is RemoteTransferExamReservation => - isObject(reservation.externalReservation); - private isCollaborative = (reservation: AnyReservation): reservation is CollaborativeExamReservation => - isObject(reservation.enrolment?.collaborativeExam); - private initOptions() { this.http.get<{ isExamVisitSupported: boolean }>('/app/settings/iop/examVisit').subscribe((resp) => { this.isInteroperable = resp.isExamVisitSupported; diff --git a/ui/src/app/review/assessment/assessment.component.ts b/ui/src/app/review/assessment/assessment.component.ts index e27836207..fd4ff4d99 100644 --- a/ui/src/app/review/assessment/assessment.component.ts +++ b/ui/src/app/review/assessment/assessment.component.ts @@ -11,9 +11,9 @@ import { ToastrService } from 'ngx-toastr'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { ExamService } from 'src/app/exam/exam.service'; import type { Examination } from 'src/app/examination/examination.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import type { QuestionAmounts } from 'src/app/question/question.model'; import { ClozeTestAnswer } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import type { User } from 'src/app/session/session.model'; import { SessionService } from 'src/app/session/session.service'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; @@ -66,7 +66,7 @@ export class AssessmentComponent implements OnInit { private toast: ToastrService, private Assessment: AssessmentService, private CollaborativeAssessment: CollaborativeAssesmentService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Exam: ExamService, private Session: SessionService, ) { @@ -98,7 +98,7 @@ export class AssessmentComponent implements OnInit { if (exam.languageInspection && !exam.languageInspection.statement) { exam.languageInspection.statement = { comment: '' }; } - this.questionSummary = this.Question.getQuestionAmounts(exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(exam); this.exam = exam; this.participation = participation; }, @@ -122,7 +122,7 @@ export class AssessmentComponent implements OnInit { scoreSet = (revision: string) => { this.participation._rev = revision; - this.questionSummary = this.Question.getQuestionAmounts(this.exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(this.exam); this.startReview(); }; diff --git a/ui/src/app/review/assessment/print/printed-assessment.component.ts b/ui/src/app/review/assessment/print/printed-assessment.component.ts index d9f4d69bd..838a700e8 100644 --- a/ui/src/app/review/assessment/print/printed-assessment.component.ts +++ b/ui/src/app/review/assessment/print/printed-assessment.component.ts @@ -11,9 +11,9 @@ import { parseISO, roundToNearestMinutes } from 'date-fns'; import type { ExamEnrolment, ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { Exam } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import type { QuestionAmounts } from 'src/app/question/question.model'; import { ClozeTestAnswer } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import type { Reservation } from 'src/app/reservation/reservation.model'; import { AssessmentService } from 'src/app/review/assessment/assessment.service'; import type { User } from 'src/app/session/session.model'; @@ -60,7 +60,7 @@ export class PrintedAssessmentComponent implements OnInit, AfterViewInit { constructor( private route: ActivatedRoute, private http: HttpClient, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Exam: ExamService, private CommonExam: CommonExamService, private Assessment: AssessmentService, @@ -94,7 +94,7 @@ export class PrintedAssessmentComponent implements OnInit, AfterViewInit { ), ); - this.questionSummary = this.Question.getQuestionAmounts(exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(exam); this.exam = exam; this.user = this.Session.getUser(); this.participation = participation; diff --git a/ui/src/app/review/assessment/print/printed-multi-choice.component.ts b/ui/src/app/review/assessment/print/printed-multi-choice.component.ts index 1fbe57bbf..f12bbbba3 100644 --- a/ui/src/app/review/assessment/print/printed-multi-choice.component.ts +++ b/ui/src/app/review/assessment/print/printed-multi-choice.component.ts @@ -5,8 +5,8 @@ import { NgClass, NgStyle } from '@angular/common'; import { Component, Input } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { MathJaxDirective } from 'src/app/shared/math/math-jax.directive'; import { isNumber } from 'src/app/shared/miscellaneous/helpers'; @@ -20,37 +20,37 @@ import { isNumber } from 'src/app/shared/miscellaneous/helpers'; export class PrintedMultiChoiceComponent { @Input() sectionQuestion!: ExamSectionQuestion; - constructor(private Question: QuestionService) {} + constructor(private QuestionScore: QuestionScoringService) {} scoreWeightedMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'WeightedMultipleChoiceQuestion') { return 0; } - return this.Question.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'MultipleChoiceQuestion') { return 0; } - return this.Question.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreClaimChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'ClaimChoiceQuestion') { return 0; } - return this.Question.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); calculateMultiChoiceMaxPoints = () => Number.isInteger(this.sectionQuestion.maxScore) ? this.sectionQuestion.maxScore : this.sectionQuestion.maxScore.toFixed(2); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); hasForcedScore = () => isNumber(this.sectionQuestion.forcedScore); } diff --git a/ui/src/app/review/assessment/questions/multi-choice-question.component.ts b/ui/src/app/review/assessment/questions/multi-choice-question.component.ts index 487c94f65..6b2134558 100644 --- a/ui/src/app/review/assessment/questions/multi-choice-question.component.ts +++ b/ui/src/app/review/assessment/questions/multi-choice-question.component.ts @@ -9,8 +9,8 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { AssessmentService } from 'src/app/review/assessment/assessment.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; import { MathJaxDirective } from 'src/app/shared/math/math-jax.directive'; @@ -56,7 +56,7 @@ export class MultiChoiceQuestionComponent implements OnInit { private toast: ToastrService, private Assessment: AssessmentService, private Attachment: AttachmentService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, ) {} get scoreValue(): number | null { @@ -84,21 +84,21 @@ export class MultiChoiceQuestionComponent implements OnInit { if (this.sectionQuestion.question.type !== 'WeightedMultipleChoiceQuestion') { return 0; } - return this.Question.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'MultipleChoiceQuestion') { return 0; } - return this.Question.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreClaimChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'ClaimChoiceQuestion') { return 0; } - return this.Question.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; displayMaxScore = () => @@ -106,11 +106,11 @@ export class MultiChoiceQuestionComponent implements OnInit { ? this.sectionQuestion.maxScore : this.sectionQuestion.maxScore.toFixed(2); - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); - getMinimumOptionScore = () => this.Question.getMinimumOptionScore(this.sectionQuestion); + getMinimumOptionScore = () => this.QuestionScore.getMinimumOptionScore(this.sectionQuestion); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); insertForcedScore = () => { if (this.collaborative && this.participation._rev) { diff --git a/ui/src/app/review/assessment/sections/section.component.ts b/ui/src/app/review/assessment/sections/section.component.ts index 9ca7b9216..48621270b 100644 --- a/ui/src/app/review/assessment/sections/section.component.ts +++ b/ui/src/app/review/assessment/sections/section.component.ts @@ -7,8 +7,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; import type { Exam, ExamSection } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { ClozeTestComponent } from 'src/app/review/assessment/questions/cloze-test.component'; import { EssayQuestionComponent } from 'src/app/review/assessment/questions/essay-question.component'; import { MultiChoiceQuestionComponent } from 'src/app/review/assessment/questions/multi-choice-question.component'; @@ -34,12 +34,12 @@ export class ExamSectionComponent implements OnInit, AfterViewInit { constructor( private Exam: ExamService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private cdr: ChangeDetectorRef, ) {} ngOnInit() { - this.essayQuestionAmounts = this.Question.getEssayQuestionAmountsBySection(this.section); + this.essayQuestionAmounts = this.QuestionScore.getEssayQuestionAmountsBySection(this.section); } ngAfterViewInit() { @@ -48,7 +48,7 @@ export class ExamSectionComponent implements OnInit, AfterViewInit { scoreSet = (revision: string) => { this.scored.emit(revision); - this.essayQuestionAmounts = this.Question.getEssayQuestionAmountsBySection(this.section); + this.essayQuestionAmounts = this.QuestionScore.getEssayQuestionAmountsBySection(this.section); }; // getReviewProgress gathers the questions that have been reviewed by calculating essay answers that have been evaluated plus the rest of the questions. diff --git a/ui/src/app/review/listing/summary/exam-summary.service.ts b/ui/src/app/review/listing/summary/exam-summary.service.ts index 041311fa2..7d1f598a6 100644 --- a/ui/src/app/review/listing/summary/exam-summary.service.ts +++ b/ui/src/app/review/listing/summary/exam-summary.service.ts @@ -26,8 +26,8 @@ import { of } from 'rxjs'; import { ExamEnrolment, ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { Exam } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { Question } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { ReviewListService } from 'src/app/review/listing/review-list.service'; import { CommonExamService } from 'src/app/shared/miscellaneous/common-exam.service'; import { groupBy } from 'src/app/shared/miscellaneous/helpers'; @@ -37,7 +37,7 @@ export class ExamSummaryService { constructor( private http: HttpClient, private translate: TranslateService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Exam: ExamService, private CommonExam: CommonExamService, private ReviewList: ReviewListService, @@ -359,8 +359,8 @@ export class ExamSummaryService { return Object.entries(mapped) .map((e) => ({ question: e[0], - max: this.Question.calculateMaxScore(e[1][0]), // hope this is ok - scores: e[1].map((sq) => this.Question.calculateAnswerScore(sq)).filter((s) => s != null), + max: this.QuestionScore.calculateMaxScore(e[1][0]), // hope this is ok + scores: e[1].map((sq) => this.QuestionScore.calculateAnswerScore(sq)).filter((s) => s != null), })) .map((e) => ({ question: e.question,