diff --git a/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts b/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts index 1b218cb534f5..760d8e6567fb 100644 --- a/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts +++ b/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts @@ -6,6 +6,7 @@ import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/Plagiarism import { Exercise, getIcon } from 'app/entities/exercise.model'; import { downloadFile } from 'app/shared/util/download.util'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-plagiarism-cases-instructor-view', @@ -24,6 +25,7 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { constructor( private plagiarismCasesService: PlagiarismCasesService, private route: ActivatedRoute, + private alertService: AlertService, ) {} ngOnInit(): void { @@ -37,23 +39,31 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { plagiarismCasesForInstructor$.subscribe({ next: (res: HttpResponse) => { this.plagiarismCases = res.body!; - this.groupedPlagiarismCases = this.plagiarismCases.reduce((acc: { [exerciseId: number]: PlagiarismCase[] }, plagiarismCase) => { - const caseExerciseId = plagiarismCase.exercise?.id; - if (caseExerciseId === undefined) { - return acc; - } - - // Group initialization - if (!acc[caseExerciseId]) { - acc[caseExerciseId] = []; - this.exercisesWithPlagiarismCases.push(plagiarismCase.exercise!); - } - - // Grouping - acc[caseExerciseId].push(plagiarismCase); + this.groupedPlagiarismCases = this.plagiarismCases.reduce( + ( + acc: { + [exerciseId: number]: PlagiarismCase[]; + }, + plagiarismCase, + ) => { + const caseExerciseId = plagiarismCase.exercise?.id; + if (caseExerciseId === undefined) { + return acc; + } + + // Group initialization + if (!acc[caseExerciseId]) { + acc[caseExerciseId] = []; + this.exercisesWithPlagiarismCases.push(plagiarismCase.exercise!); + } + + // Grouping + acc[caseExerciseId].push(plagiarismCase); - return acc; - }, {}); + return acc; + }, + {}, + ); }, }); } @@ -131,20 +141,48 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { } /** - * export the plagiarism cases in CSV format + * set placeholder for undefined values and sanitize the operators away + * @param value to be sanitized or replaced with - + * @private + */ + private sanitizeCSVField(value: any): string { + if (value === null || value === undefined) { + // used as placeholder for null or if the passed value does not exist + return '-'; + } + // sanitize the operators away in case they appear in the values + return String(value).replace(/;/g, '";"'); + } + + /** + * export the cases in CSV format */ exportPlagiarismCases(): void { - const blobParts: string[] = ['Student Login,Exercise,Verdict, Verdict Date\n']; - this.plagiarismCases.forEach((plagiarismCase) => { - const exerciseTitleCSVSanitized = plagiarismCase.exercise?.title?.replace(',', '","'); + const headers = ['Student Login', 'Matr. Nr.', 'Exercise', 'Verdict', 'Verdict Date', 'Verdict By']; + const blobParts: string[] = [headers.join(';') + '\n']; + this.plagiarismCases.reduce((acc, plagiarismCase) => { + const fields = [ + this.sanitizeCSVField(plagiarismCase.student?.login), + this.sanitizeCSVField(plagiarismCase.student?.visibleRegistrationNumber), + this.sanitizeCSVField(plagiarismCase.exercise?.title), + ]; if (plagiarismCase.verdict) { - blobParts.push( - `${plagiarismCase.student?.login},${exerciseTitleCSVSanitized},${plagiarismCase.verdict},${plagiarismCase.verdictDate},${plagiarismCase.verdictBy!.name}\n`, + fields.push( + this.sanitizeCSVField(plagiarismCase.verdict), + this.sanitizeCSVField(plagiarismCase.verdictDate), + this.sanitizeCSVField(plagiarismCase.verdictBy?.name), ); } else { - blobParts.push(`${plagiarismCase.student?.login},${exerciseTitleCSVSanitized}, No verdict yet, -, -\n`); + fields.push('No verdict yet', '-', '-'); } - }); - downloadFile(new Blob(blobParts, { type: 'text/csv' }), 'plagiarism-cases.csv'); + acc.push(fields.join(';') + '\n'); + return acc; + }, blobParts); + + try { + downloadFile(new Blob(blobParts, { type: 'text/csv' }), 'plagiarism-cases.csv'); + } catch (error) { + this.alertService.error('artemisApp.plagiarism.plagiarismCases.export.error'); + } } } diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts index 1b54bd14ff8a..70f4cfe76710 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts +++ b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { StudentExam } from 'app/entities/student-exam.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -8,7 +8,7 @@ import { ExamNavigationBarComponent } from 'app/exam/participate/exam-navigation import { SubmissionService } from 'app/exercises/shared/submission/submission.service'; import dayjs from 'dayjs/esm'; import { SubmissionVersion } from 'app/entities/submission-version.model'; -import { Observable, Subscription, forkJoin, map, mergeMap, toArray } from 'rxjs'; +import { Observable, Subscription, forkJoin, map, mergeMap, tap, toArray } from 'rxjs'; import { ProgrammingSubmission } from 'app/entities/programming/programming-submission.model'; import { Submission } from 'app/entities/submission.model'; import { FileUploadSubmission } from 'app/entities/file-upload-submission.model'; @@ -57,6 +57,7 @@ export class StudentExamTimelineComponent implements OnInit, AfterViewInit, OnDe private submissionService: SubmissionService, private submissionVersionService: SubmissionVersionService, private programmingExerciseParticipationService: ProgrammingExerciseParticipationService, + private cdr: ChangeDetectorRef, ) {} ngOnInit(): void { @@ -203,7 +204,8 @@ export class StudentExamTimelineComponent implements OnInit, AfterViewInit, OnDe ); } }); - return forkJoin([...submissionObservables]); + + return forkJoin([...submissionObservables]).pipe(tap(() => this.cdr.detectChanges())); } /** diff --git a/src/main/webapp/i18n/de/plagiarism.json b/src/main/webapp/i18n/de/plagiarism.json index 2e8f1d30798f..becba41634fc 100644 --- a/src/main/webapp/i18n/de/plagiarism.json +++ b/src/main/webapp/i18n/de/plagiarism.json @@ -130,6 +130,9 @@ "exportCsv": "CSV exportieren", "confirm": "Fall bestätigen", "discard": "Fall verwerfen" + }, + "export": { + "error": "Fehler beim Exportieren der CSV-Datei." } } } diff --git a/src/main/webapp/i18n/en/plagiarism.json b/src/main/webapp/i18n/en/plagiarism.json index 8f071d94b798..ba73b67b9f37 100644 --- a/src/main/webapp/i18n/en/plagiarism.json +++ b/src/main/webapp/i18n/en/plagiarism.json @@ -130,6 +130,9 @@ "exportCsv": "Export CSV", "confirm": "Confirm case", "discard": "Discard case" + }, + "export": { + "error": "Error exporting CSV." } } } diff --git a/src/test/javascript/spec/component/plagiarism/plagiarism-cases-instructor-view.component.spec.ts b/src/test/javascript/spec/component/plagiarism/plagiarism-cases-instructor-view.component.spec.ts index 4a6bda12288c..45749bcbdf3a 100644 --- a/src/test/javascript/spec/component/plagiarism/plagiarism-cases-instructor-view.component.spec.ts +++ b/src/test/javascript/spec/component/plagiarism/plagiarism-cases-instructor-view.component.spec.ts @@ -190,9 +190,9 @@ describe('Plagiarism Cases Instructor View Component', () => { const downloadSpy = jest.spyOn(DownloadUtil, 'downloadFile'); component.plagiarismCases = [plagiarismCase1, plagiarismCase4]; const expectedBlob = [ - 'Student Login,Exercise,Verdict, Verdict Date\n', - `Student 1, Test Exercise 1, PLAGIARISM, ${date}, Test Instructor 1\n`, - 'Student 2, Test Exercise 2, No verdict yet, -, -\n', + 'Student Login; Matr. Nr.; Exercise;Verdict; Verdict Date\n', + `Student 1; -; Test Exercise 1; PLAGIARISM; ${date}; Test Instructor 1\n`, + 'Student 2; -; Test Exercise 2; No verdict yet; -; -\n', ]; component.exportPlagiarismCases(); expect(downloadSpy).toHaveBeenCalledOnce();