From 11dd65f3da77f8252e37aded2ac35fd264d0b254 Mon Sep 17 00:00:00 2001 From: Paul Schwind Date: Sun, 3 Sep 2023 09:20:52 +0200 Subject: [PATCH 01/47] Development: Make text blocks more robust (#7134) --- .../webapp/app/entities/text-block.model.ts | 59 +++++++++++++++---- .../manual-textblock-selection.component.ts | 1 - .../assess/text-assessment-base.component.ts | 1 - .../text/assess/text-assessment.service.ts | 16 +++-- .../text-submission-assessment.component.ts | 3 +- .../example-text-submission.component.spec.ts | 8 --- ...nual-textblock-selection.component.spec.ts | 21 ++++--- ...xt-submission-assessment.component.spec.ts | 12 ++-- .../manual-text-selection.component.spec.ts | 4 +- 9 files changed, 79 insertions(+), 46 deletions(-) diff --git a/src/main/webapp/app/entities/text-block.model.ts b/src/main/webapp/app/entities/text-block.model.ts index 36c496f80c1d..4267639950e8 100644 --- a/src/main/webapp/app/entities/text-block.model.ts +++ b/src/main/webapp/app/entities/text-block.model.ts @@ -8,25 +8,64 @@ export enum TextBlockType { export class TextBlock { id?: string; - text?: string; - startIndex?: number; - endIndex?: number; - submission?: TextSubmission; type?: TextBlockType; + // The ID of the text block is computed as a hash of the submission ID, the start and end index and the text. + // We need to keep it up to date with the latest values of these properties. Therefore, we use setter properties to never forget to update the ID. + private _submissionId?: number; + private _startIndex?: number; + private _endIndex?: number; + private _text?: string; + + set submissionId(value: number | undefined) { + this._submissionId = value; + this.computeId(); + } + + get submissionId(): number | undefined { + return this._submissionId; + } + + set startIndex(value: number | undefined) { + this._startIndex = value; + this.computeId(); + } + + get startIndex(): number | undefined { + return this._startIndex; + } + + set endIndex(value: number | undefined) { + this._endIndex = value; + this.computeId(); + } + + get endIndex(): number | undefined { + return this._endIndex; + } + + set text(value: string | undefined) { + this._text = value; + this.computeId(); + } + + get text(): string | undefined { + return this._text; + } + /** - * Identical with de.tum.in.www1.artemis.domain.TextBlock:computeId + * Computes the ID of the text block. The ID is a hash of the submission ID, the start and end index and the text. */ - computeId(): void { - const submissionId = this.submission ? this.submission.id : 0; + private computeId(): void { + const submissionId = this.submissionId ?? 0; const idString = `${submissionId};${this.startIndex}-${this.endIndex};${this.text}`; this.id = sha1Hex(idString); } setTextFromSubmission(submission?: TextSubmission): void { - this.submission = submission || this.submission; - if (this.submission && !(this.startIndex === undefined || this.startIndex === null)) { - this.text = this.submission.text?.substring(this.startIndex, this.endIndex) || ''; + this.submissionId ??= submission?.id; + if (submission && this.startIndex != undefined) { + this.text = submission.text?.substring(this.startIndex, this.endIndex) || ''; } } } diff --git a/src/main/webapp/app/exercises/text/assess/manual-textblock-selection/manual-textblock-selection.component.ts b/src/main/webapp/app/exercises/text/assess/manual-textblock-selection/manual-textblock-selection.component.ts index 4f5267ec9349..094acf5431b8 100644 --- a/src/main/webapp/app/exercises/text/assess/manual-textblock-selection/manual-textblock-selection.component.ts +++ b/src/main/webapp/app/exercises/text/assess/manual-textblock-selection/manual-textblock-selection.component.ts @@ -44,7 +44,6 @@ export class ManualTextblockSelectionComponent { textBlock.startIndex = selectedWords[0].index; textBlock.endIndex = selectedWords[1].index + selectedWords[1].word.length; textBlock.setTextFromSubmission(this.submission); - textBlock.computeId(); const existingRef = this.textBlockRefs.find((ref) => ref.block?.id === textBlock.id); if (existingRef) { diff --git a/src/main/webapp/app/exercises/text/assess/text-assessment-base.component.ts b/src/main/webapp/app/exercises/text/assess/text-assessment-base.component.ts index c8b094dbab08..f5ced7a4f45d 100644 --- a/src/main/webapp/app/exercises/text/assess/text-assessment-base.component.ts +++ b/src/main/webapp/app/exercises/text/assess/text-assessment-base.component.ts @@ -143,7 +143,6 @@ export abstract class TextAssessmentBaseComponent implements OnInit { newRef.block.startIndex = startIndex; newRef.block.endIndex = endIndex; newRef.block.setTextFromSubmission(submission!); - newRef.block.computeId(); } textBlockRefs.push(newRef); } diff --git a/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts b/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts index 736d0bbd0184..e73862793215 100644 --- a/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts +++ b/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts @@ -185,13 +185,19 @@ export class TextAssessmentService { delete feedback.result; return feedback; }); - textBlocks = textBlocks.map((textBlock) => { - textBlock = Object.assign({}, textBlock); - textBlock.submission = undefined; - return textBlock; + const textBlocksRequestObjects = textBlocks.map((textBlock) => { + // We convert the text block to a request object, so that we can send it to the server. + // This way, we omit the submissionId and avoid serializing it with private properties. + return { + id: textBlock.id, + type: textBlock.type, + startIndex: textBlock.startIndex, + endIndex: textBlock.endIndex, + text: textBlock.text, + }; }); - return { feedbacks, textBlocks }; + return { feedbacks, textBlocks: textBlocksRequestObjects } as TextAssessmentDTO; } private convertResultEntityResponseTypeFromServer(res: EntityResponseType): EntityResponseType { diff --git a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts index 31bf1f96ef0c..a93861fd89d3 100644 --- a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts +++ b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts @@ -277,8 +277,7 @@ export class TextSubmissionAssessmentComponent extends TextAssessmentBaseCompone const newBlockRef = new TextBlockRef(new TextBlock(), undefined); newBlockRef.block!.startIndex = end; newBlockRef.block!.endIndex = exEnd; - newBlockRef.block!.submission = this.submission; - newBlockRef.block!.computeId(); + newBlockRef.block!.submissionId = this.submission?.id; existingBlockRef.block!.endIndex = start; newTextBlockRefs.push(existingBlockRef); diff --git a/src/test/javascript/spec/component/text-exercise/example-text-submission.component.spec.ts b/src/test/javascript/spec/component/text-exercise/example-text-submission.component.spec.ts index e287af1fafca..b3189e54388e 100644 --- a/src/test/javascript/spec/component/text-exercise/example-text-submission.component.spec.ts +++ b/src/test/javascript/spec/component/text-exercise/example-text-submission.component.spec.ts @@ -136,12 +136,10 @@ describe('ExampleTextSubmissionComponent', () => { textBlock1.startIndex = 0; textBlock1.endIndex = 4; textBlock1.setTextFromSubmission(submission); - textBlock1.computeId(); const textBlock2 = new TextBlock(); textBlock2.startIndex = 5; textBlock2.endIndex = 9; textBlock2.setTextFromSubmission(submission); - textBlock2.computeId(); submission.blocks = [textBlock1, textBlock2]; submission.text = '123456789'; const feedback = Feedback.forText(textBlock2, 3, 'Test'); @@ -240,12 +238,10 @@ describe('ExampleTextSubmissionComponent', () => { textBlock1.startIndex = 0; textBlock1.endIndex = 4; textBlock1.setTextFromSubmission(submission); - textBlock1.computeId(); const textBlock2 = new TextBlock(); textBlock2.startIndex = 5; textBlock2.endIndex = 9; textBlock2.setTextFromSubmission(submission); - textBlock2.computeId(); submission.blocks = [textBlock1, textBlock2]; submission.text = '123456789'; comp.result = result; @@ -282,12 +278,10 @@ describe('ExampleTextSubmissionComponent', () => { textBlock1.startIndex = 0; textBlock1.endIndex = 4; textBlock1.setTextFromSubmission(submission); - textBlock1.computeId(); const textBlock2 = new TextBlock(); textBlock2.startIndex = 5; textBlock2.endIndex = 9; textBlock2.setTextFromSubmission(submission); - textBlock2.computeId(); submission.blocks = [textBlock1, textBlock2]; submission.text = '123456789'; comp.result = result; @@ -327,12 +321,10 @@ describe('ExampleTextSubmissionComponent', () => { textBlock1.startIndex = 0; textBlock1.endIndex = 4; textBlock1.setTextFromSubmission(submission); - textBlock1.computeId(); const textBlock2 = new TextBlock(); textBlock2.startIndex = 5; textBlock2.endIndex = 9; textBlock2.setTextFromSubmission(submission); - textBlock2.computeId(); submission.blocks = [textBlock1, textBlock2]; submission.text = '123456789'; jest.spyOn(assessmentsService, 'getExampleResult').mockReturnValue(of(result)); diff --git a/src/test/javascript/spec/component/text-submission-assessment/manual-textblock-selection.component.spec.ts b/src/test/javascript/spec/component/text-submission-assessment/manual-textblock-selection.component.spec.ts index 77974de78ce3..bf1801eb0362 100644 --- a/src/test/javascript/spec/component/text-submission-assessment/manual-textblock-selection.component.spec.ts +++ b/src/test/javascript/spec/component/text-submission-assessment/manual-textblock-selection.component.spec.ts @@ -27,32 +27,32 @@ describe('ManualTextblockSelectionComponent', () => { text: 'First text.', startIndex: 0, endIndex: 11, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, { text: 'Second text.', startIndex: 12, endIndex: 24, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, { text: 'Third text.', startIndex: 25, endIndex: 36, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, { text: 'Fourth text.', startIndex: 37, endIndex: 49, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, { text: 'Fifth sixth text.', startIndex: 50, endIndex: 67, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, ]; const textBlockRefs = [new TextBlockRef(blocks[0]), new TextBlockRef(blocks[1]), new TextBlockRef(blocks[2]), new TextBlockRef(blocks[3]), new TextBlockRef(blocks[4])]; @@ -99,7 +99,6 @@ describe('ManualTextblockSelectionComponent', () => { textBlockRef.block!.startIndex = 50; textBlockRef.block!.endIndex = 61; textBlockRef.block!.setTextFromSubmission(submission); - textBlockRef.block!.computeId(); textBlockRef.initFeedback(); expect(component.textBlockRefAdded.emit).toHaveBeenCalledWith(textBlockRef); }); diff --git a/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts b/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts index 54bd8f29f48a..b809ddb3d066 100644 --- a/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts +++ b/src/test/javascript/spec/component/text-submission-assessment/text-submission-assessment.component.spec.ts @@ -121,16 +121,16 @@ describe('TextSubmissionAssessmentComponent', () => { text: 'First text.', startIndex: 0, endIndex: 11, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, { id: 'second text id', text: 'Second text.', startIndex: 12, endIndex: 24, type: TextBlockType.MANUAL, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, ]; submission.participation!.submissions = [submission]; submission.participation!.results = [getLatestSubmissionResult(submission)!]; @@ -453,8 +453,8 @@ describe('TextSubmissionAssessmentComponent', () => { startIndex: 19, endIndex: 24, type: TextBlockType.MANUAL, - submission, - } as TextBlock); + submissionId: submission.id, + } as any as TextBlock); getLatestSubmissionResult(submission)?.feedbacks?.push({ id: 3, diff --git a/src/test/javascript/spec/component/text/manual-text-selection.component.spec.ts b/src/test/javascript/spec/component/text/manual-text-selection.component.spec.ts index 61b0f3dc44b7..db879a19b482 100644 --- a/src/test/javascript/spec/component/text/manual-text-selection.component.spec.ts +++ b/src/test/javascript/spec/component/text/manual-text-selection.component.spec.ts @@ -29,8 +29,8 @@ describe('ManualTextSelectionComponent', () => { text: 'First last text. Second text.', startIndex: 0, endIndex: 16, - submission, - } as TextBlock, + submissionId: submission.id, + } as any as TextBlock, ]; const textBlockRefs = new TextBlockRef(blocks[0]); From c6cf9988412d20ba504433814dcbb1aafc25220c Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Sun, 3 Sep 2023 09:23:27 +0200 Subject: [PATCH 02/47] Assessment: Improve feedback deletion confirmation alert (#6925) --- .../assessment-detail.component.html | 15 ++++++++++++- .../assessment-detail.component.ts | 22 +++++++++---------- ...-assessment-inline-feedback.component.html | 14 ++++++++++-- ...or-assessment-inline-feedback.component.ts | 14 +++++++----- .../delete-dialog/delete-button.directive.ts | 2 ++ .../delete-dialog.component.html | 2 +- .../delete-dialog/delete-dialog.component.ts | 1 + .../delete-dialog/delete-dialog.model.ts | 3 +++ .../delete-dialog/delete-dialog.service.ts | 1 + src/main/webapp/i18n/de/feedback.json | 2 +- src/main/webapp/i18n/en/feedback.json | 2 +- .../assessment-detail.component.spec.ts | 4 ++-- ...sessment-inline-feedback.component.spec.ts | 5 ----- .../service/delete-dialog.service.spec.ts | 1 + 14 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.html b/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.html index 857cf82c0f80..1aa0e32de2f5 100644 --- a/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.html +++ b/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.html @@ -1,7 +1,20 @@
- + Automatic diff --git a/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.ts b/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.ts index 4e2e02c5bfac..a30c4b1ce861 100644 --- a/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.ts +++ b/src/main/webapp/app/assessment/assessment-detail/assessment-detail.component.ts @@ -1,8 +1,9 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { faExclamation, faExclamationTriangle, faQuestionCircle, faRobot, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; -import { TranslateService } from '@ngx-translate/core'; import { Feedback, FeedbackType } from 'app/entities/feedback.model'; import { StructuredGradingCriterionService } from 'app/exercises/shared/structured-grading-criterion/structured-grading-criterion.service'; +import { ButtonSize } from 'app/shared/components/button.component'; +import { Subject } from 'rxjs'; @Component({ selector: 'jhi-assessment-detail', @@ -16,7 +17,9 @@ export class AssessmentDetailComponent { @Input() public readOnly: boolean; @Input() highlightDifferences: boolean; - public FeedbackType_AUTOMATIC = FeedbackType.AUTOMATIC; + readonly FeedbackType_AUTOMATIC = FeedbackType.AUTOMATIC; + readonly ButtonSize = ButtonSize; + // Icons faTrashAlt = faTrashAlt; faRobot = faRobot; @@ -24,10 +27,10 @@ export class AssessmentDetailComponent { faExclamation = faExclamation; faExclamationTriangle = faExclamationTriangle; - constructor( - private translateService: TranslateService, - public structuredGradingCriterionService: StructuredGradingCriterionService, - ) {} + private dialogErrorSource = new Subject(); + dialogError$ = this.dialogErrorSource.asObservable(); + + constructor(public structuredGradingCriterionService: StructuredGradingCriterionService) {} /** * Emits assessment changes to parent component @@ -42,11 +45,8 @@ export class AssessmentDetailComponent { * Emits the deletion of an assessment */ public delete() { - const text: string = this.translateService.instant('artemisApp.feedback.delete.question', { id: this.assessment.id ?? '' }); - const confirmation = confirm(text); - if (confirmation) { - this.deleteAssessment.emit(this.assessment); - } + this.deleteAssessment.emit(this.assessment); + this.dialogErrorSource.next(''); } updateAssessmentOnDrop(event: Event) { diff --git a/src/main/webapp/app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component.html b/src/main/webapp/app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component.html index 45f640c74752..74bd0b5c4d91 100644 --- a/src/main/webapp/app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component.html +++ b/src/main/webapp/app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component.html @@ -54,8 +54,18 @@ -
-
+
+ +
+
+ Online Code Editor
-

+

{{ exercise?.exerciseGroup?.title ?? '-' }}  [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}]

-
+
+ + + + {{ + 'artemisApp.programmingExercise.submissionPolicy.submissionsAllowed' + | artemisTranslate: { submissionCount: submissionCount, totalSubmissions: exercise.submissionPolicy.submissionLimit } + }} + + + {{ + 'artemisApp.programmingExercise.submissionPolicy.submissionPenalty.penaltyInfoLabel' | artemisTranslate: { points: exercise.submissionPolicy.exceedingPenalty } + }} + +
+ `, +}) +export class ProgrammingSubmissionPolicyStatusComponent { + @Input() + exercise: ProgrammingExercise; + @Input() + submissionCount?: number; + readonly SubmissionPolicyType = SubmissionPolicyType; +} From 4cba27c3d6c8f64b767c1d735c30341ddf572054 Mon Sep 17 00:00:00 2001 From: Lennart Keller <44754405+lennart-keller@users.noreply.github.com> Date: Fri, 8 Sep 2023 10:18:01 +0200 Subject: [PATCH 19/47] Communication: Disable notifications for hidden conversations (#7098) --- ...ConversationWebSocketRecipientSummary.java | 12 ++++ .../artemis/repository/UserRepository.java | 30 +++++----- .../ConversationParticipantRepository.java | 14 ++++- .../metis/ConversationMessagingService.java | 58 +++++++++++++++---- .../artemis/service/metis/PostingService.java | 11 +++- .../conversation/ConversationService.java | 36 ++++++++---- .../artemis/metis/MessageIntegrationTest.java | 26 +++++++++ .../artemis/post/ConversationUtilService.java | 1 + .../ConversationNotificationServiceTest.java | 6 +- 9 files changed, 147 insertions(+), 47 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java diff --git a/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java b/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java new file mode 100644 index 000000000000..a687b0642094 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.domain; + +/** + * Stores the user of a conversation participant, who is supposed to receive a websocket message and stores whether + * the corresponding conversation is hidden by the user. + * + * @param user the user who is a member of the conversation + * @param isConversationHidden true if the user has hidden the conversation + * @param isAtLeastTutorInCourse true if the user is at least a tutor in the course + */ +public record ConversationWebSocketRecipientSummary(User user, boolean isConversationHidden, boolean isAtLeastTutorInCourse) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index 26dadf4bb574..07922f76de6f 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -9,10 +9,7 @@ import javax.validation.constraints.NotNull; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.*; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; @@ -20,9 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.Organization; -import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.SortingOrder; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.SecurityUtils; @@ -121,18 +116,23 @@ OR lower(user.login) = lower(:#{#searchInput})) Set findAllInGroup(@Param("groupName") String groupName); @Query(""" - SELECT DISTINCT user + SELECT NEW de.tum.in.www1.artemis.domain.ConversationWebSocketRecipientSummary ( + user, + CASE WHEN cp.isHidden = true THEN true ELSE false END, + CASE WHEN atLeastTutors.id IS NOT null THEN true ELSE false END + ) FROM User user - JOIN UserGroup ug ON user.id = ug.userId - JOIN Course course ON (course.studentGroupName = ug.group - OR course.teachingAssistantGroupName = ug.group - OR course.editorGroupName = ug.group - OR course.instructorGroupName = ug.group + JOIN UserGroup ug ON ug.userId = user.id + LEFT JOIN Course students ON ug.group = students.studentGroupName + LEFT JOIN Course atLeastTutors ON (atLeastTutors.teachingAssistantGroupName = ug.group + OR atLeastTutors.editorGroupName = ug.group + OR atLeastTutors.instructorGroupName = ug.group ) + LEFT JOIN ConversationParticipant cp ON cp.user.id = user.id AND cp.conversation.id = :conversationId WHERE user.isDeleted = false - AND course.id = :courseId + AND (students.id = :courseId OR atLeastTutors.id = :courseId) """) - Set findAllInCourse(@Param("courseId") Long courseId); + Set findAllWebSocketRecipientsInCourseForConversation(@Param("courseId") Long courseId, @Param("conversationId") Long conversationId); /** * Searches for users in a group by their login or full name. diff --git a/src/main/java/de/tum/in/www1/artemis/repository/metis/ConversationParticipantRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/metis/ConversationParticipantRepository.java index 7a32848bc976..149816242938 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/metis/ConversationParticipantRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/metis/ConversationParticipantRepository.java @@ -1,14 +1,14 @@ package de.tum.in.www1.artemis.repository.metis; +import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; + import java.time.ZonedDateTime; import java.util.Optional; import java.util.Set; import javax.transaction.Transactional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Repository; @@ -37,6 +37,14 @@ public interface ConversationParticipantRepository extends JpaRepository findConversationParticipantByConversationId(@Param("conversationId") Long conversationId); + @EntityGraph(type = LOAD, attributePaths = { "user.groups", "user.authorities" }) + @Query(""" + SELECT DISTINCT conversationParticipant + FROM ConversationParticipant conversationParticipant + WHERE conversationParticipant.conversation.id = :conversationId + """) + Set findConversationParticipantWithUserGroupsByConversationId(@Param("conversationId") Long conversationId); + @Async @Transactional // ok because of modifying query @Modifying diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java index d0820273cc42..4b3e9b66cee3 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java @@ -3,6 +3,7 @@ import java.time.ZonedDateTime; import java.util.Objects; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import javax.validation.Valid; @@ -13,9 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.Exercise; -import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.DisplayPriority; import de.tum.in.www1.artemis.domain.metis.ConversationParticipant; import de.tum.in.www1.artemis.domain.metis.Post; @@ -86,8 +85,7 @@ public Post createMessage(Long courseId, Post newMessage) { conversationService.isMemberElseThrow(newMessage.getConversation().getId(), author.getId()); - var conversation = conversationRepository.findWithConversationParticipantsByIdElseThrow(newMessage.getConversation().getId()); - var notificationRecipients = getRecipientsForConversation(conversation).collect(Collectors.toSet()); + var conversation = conversationRepository.findByIdElseThrow(newMessage.getConversation().getId()); // IMPORTANT we don't need it in the conversation any more, so we reduce the amount of data sent to clients conversation.setConversationParticipants(Set.of()); var course = preCheckUserAndCourseForMessaging(author, courseId); @@ -100,7 +98,7 @@ public Post createMessage(Long courseId, Post newMessage) { // update last message date of conversation conversation.setLastMessageDate(ZonedDateTime.now()); conversation.setCourse(course); - conversation = conversationService.updateConversation(conversation); + Conversation savedConversation = conversationService.updateConversation(conversation); // update last read date and unread message count of author // invoke async due to db write access to avoid that the client has to wait @@ -115,37 +113,73 @@ public Post createMessage(Long courseId, Post newMessage) { } // TODO: we should consider invoking the following method async to avoid that authors wait for the message creation if many notifications are sent - notifyAboutMessageCreation(author, conversation, notificationRecipients, course, createdMessage); + notifyAboutMessageCreation(author, savedConversation, course, createdMessage); return createdMessage; } - private void notifyAboutMessageCreation(User author, Conversation conversation, Set notificationRecipients, Course course, Post createdMessage) { + private void notifyAboutMessageCreation(User author, Conversation conversation, Course course, Post createdMessage) { + Set webSocketRecipients = getWebSocketRecipients(conversation).collect(Collectors.toSet()); + Set broadcastRecipients = webSocketRecipients.stream().map(ConversationWebSocketRecipientSummary::user).collect(Collectors.toSet()); // Websocket notification 1: this notifies everyone including the author that there is a new message - broadcastForPost(new PostDTO(createdMessage, MetisCrudAction.CREATE), course, notificationRecipients); + broadcastForPost(new PostDTO(createdMessage, MetisCrudAction.CREATE), course, broadcastRecipients); if (conversation instanceof OneToOneChat) { var getNumberOfPosts = conversationMessageRepository.countByConversationId(conversation.getId()); if (getNumberOfPosts == 1) { // first message in one to one chat --> notify all participants that a conversation with them has been created // Another websocket notification - conversationService.broadcastOnConversationMembershipChannel(course, MetisCrudAction.CREATE, conversation, notificationRecipients); + conversationService.broadcastOnConversationMembershipChannel(course, MetisCrudAction.CREATE, conversation, broadcastRecipients); } } conversationParticipantRepository.incrementUnreadMessagesCountOfParticipants(conversation.getId(), author.getId()); // ToDo: Optimization Idea: Maybe we can save this websocket call and instead get the last message date from the conversation object in the post somehow? // send conversation with updated last message date to participants. This is necessary to show the unread messages badge in the client - notificationRecipients = notificationRecipients.stream().filter(user -> !Objects.equals(user.getId(), author.getId())).collect(Collectors.toSet()); // TODO: why do we need notification 2 and 3? we should definitely re-work this! // Websocket notification 2 - conversationService.notifyAllConversationMembersAboutNewMessage(course, conversation, notificationRecipients); + conversationService.notifyAllConversationMembersAboutNewMessage(course, conversation, broadcastRecipients); // creation of message posts should not trigger entity creation alert // Websocket notification 3 + var notificationRecipients = filterNotificationRecipients(author, conversation, webSocketRecipients); conversationNotificationService.notifyAboutNewMessage(createdMessage, notificationRecipients, course); } + /** + * Filters the given list of recipients for users that should receive a notification about a new message. + *

+ * In all cases, the author will be filtered out. + * If the conversation is not an announcement channel, the method filters out participants, that have hidden the conversation. + * If the conversation is not visible to students, the method also filters out students from the provided list of recipients. + * + * @param author the author of the message + * @param conversation the conversation the new message has been written in + * @param webSocketRecipients the list of users that should be filtered + * @return filtered list of users that are supposed to receive a notification + */ + private Set filterNotificationRecipients(User author, Conversation conversation, Set webSocketRecipients) { + // Initialize filter with check for author + Predicate filter = recipientSummary -> !Objects.equals(recipientSummary.user().getId(), author.getId()); + + if (conversation instanceof Channel channel) { + // If a channel is not an announcement channel, filter out users, that hid the conversation + if (!channel.getIsAnnouncementChannel()) { + filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden()); + } + + // If a channel is not visible to students, filter out participants that are only students + if (!conversationService.isChannelVisibleToStudents(channel)) { + filter = filter.and(ConversationWebSocketRecipientSummary::isAtLeastTutorInCourse); + } + } + else { + filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden()); + } + + return webSocketRecipients.stream().filter(filter).map(ConversationWebSocketRecipientSummary::user).collect(Collectors.toSet()); + } + /** * fetch posts from database by conversationId * diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java index 349afeaa0a62..675b23b7ee39 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java @@ -119,9 +119,14 @@ else if (postConversation != null) { * @param conversation conversation the participants are supposed be retrieved * @return users that should receive the new message */ - protected Stream getRecipientsForConversation(Conversation conversation) { - return conversation instanceof Channel channel && channel.getIsCourseWide() ? userRepository.findAllInCourse(channel.getCourse().getId()).stream() - : conversationParticipantRepository.findConversationParticipantByConversationId(conversation.getId()).stream().map(ConversationParticipant::getUser); + protected Stream getWebSocketRecipients(Conversation conversation) { + if (conversation instanceof Channel channel && channel.getIsCourseWide()) { + return userRepository.findAllWebSocketRecipientsInCourseForConversation(conversation.getCourse().getId(), conversation.getId()).stream(); + } + + return conversationParticipantRepository.findConversationParticipantWithUserGroupsByConversationId(conversation.getId()).stream() + .map(participant -> new ConversationWebSocketRecipientSummary(participant.getUser(), participant.getIsHidden() != null && participant.getIsHidden(), + authorizationCheckService.isAtLeastTeachingAssistantInCourse(conversation.getCourse(), participant.getUser()))); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java index 00018af0ea2e..a8336697033d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java @@ -447,18 +447,30 @@ public Set findUsersInDatabase(@RequestBody List userLogins) { * @return a stream of channels without channels belonging to unreleased lectures/exercises/exams */ public Stream filterVisibleChannelsForStudents(Stream channels) { - return channels.filter(channel -> { - if (channel.getLecture() != null) { - return channel.getLecture().isVisibleToStudents(); - } - else if (channel.getExercise() != null) { - return channel.getExercise().isVisibleToStudents(); - } - else if (channel.getExam() != null) { - return channel.getExam().isVisibleToStudents(); - } - return true; - }); + return channels.filter(this::isChannelVisibleToStudents); + } + + /** + * Determines whether the provided channel is visible to students. + *

+ * If the channel is not associated with a lecture/exam/exercise, then this method returns true. + * If it is connected to a lecture/exam/exercise, then the + * channel visibility depends on the visible date of the lecture/exam/exercise. + * + * @param channel the channel under consideration + * @return true if the channel is visible to students + */ + public boolean isChannelVisibleToStudents(@NotNull Channel channel) { + if (channel.getLecture() != null) { + return channel.getLecture().isVisibleToStudents(); + } + else if (channel.getExercise() != null) { + return channel.getExercise().isVisibleToStudents(); + } + else if (channel.getExam() != null) { + return channel.getExam().isVisibleToStudents(); + } + return true; } private ConversationParticipant getOrCreateConversationParticipant(Long conversationId, User requestingUser) { diff --git a/src/test/java/de/tum/in/www1/artemis/metis/MessageIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/metis/MessageIntegrationTest.java index a471ac42d6ca..b20df00ae957 100644 --- a/src/test/java/de/tum/in/www1/artemis/metis/MessageIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/metis/MessageIntegrationTest.java @@ -43,6 +43,7 @@ import de.tum.in.www1.artemis.domain.metis.Post; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.metis.conversation.OneToOneChat; +import de.tum.in.www1.artemis.domain.notification.Notification; import de.tum.in.www1.artemis.post.ConversationUtilService; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.UserRepository; @@ -183,6 +184,31 @@ void testCreateConversationPostInCourseWideChannel() throws Exception { verify(websocketMessagingService, timeout(2000).times(1)).sendMessage(eq("/topic/metis/courses/" + courseId + "/conversations/" + channel.getId()), any(PostDTO.class)); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testNoNotificationIfConversationHidden() throws Exception { + Channel channel = conversationUtilService.createCourseWideChannel(course, "test"); + ConversationParticipant recipientWithHiddenTrue = conversationUtilService.addParticipantToConversation(channel, TEST_PREFIX + "student2"); + recipientWithHiddenTrue.setIsHidden(true); + conversationParticipantRepository.save(recipientWithHiddenTrue); + ConversationParticipant recipientWithHiddenFalse = conversationUtilService.addParticipantToConversation(channel, TEST_PREFIX + "tutor1"); + ConversationParticipant author = conversationUtilService.addParticipantToConversation(channel, TEST_PREFIX + "student1"); + + Post postToSave = new Post(); + postToSave.setAuthor(author.getUser()); + postToSave.setConversation(channel); + + Post createdPost = request.postWithResponseBody("/api/courses/" + courseId + "/messages", postToSave, Post.class, HttpStatus.CREATED); + checkCreatedMessagePost(postToSave, createdPost); + + // participants who hid the conversation should not be notified + verify(websocketMessagingService, never()).sendMessage(eq("/topic/user/" + recipientWithHiddenTrue.getUser().getId() + "/notifications/conversations"), + any(Notification.class)); + // participants who have not hidden the conversation should be notified + verify(websocketMessagingService, timeout(2000).times(1)).sendMessage(eq("/topic/user/" + recipientWithHiddenFalse.getUser().getId() + "/notifications/conversations"), + any(Notification.class)); + } + @ParameterizedTest @ValueSource(ints = { HIGHER_PAGE_SIZE, LOWER_PAGE_SIZE, EQUAL_PAGE_SIZE }) @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") diff --git a/src/test/java/de/tum/in/www1/artemis/post/ConversationUtilService.java b/src/test/java/de/tum/in/www1/artemis/post/ConversationUtilService.java index 963a9d8c6e70..97fc28c4a978 100644 --- a/src/test/java/de/tum/in/www1/artemis/post/ConversationUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/post/ConversationUtilService.java @@ -341,6 +341,7 @@ private ConversationParticipant createConversationParticipant(Conversation conve conversationParticipant.setConversation(conversation); conversationParticipant.setLastRead(conversation.getLastMessageDate()); conversationParticipant.setUser(userUtilService.getUserByLogin(userName)); + conversationParticipant.setIsHidden(false); return conversationParticipantRepository.save(conversationParticipant); } diff --git a/src/test/java/de/tum/in/www1/artemis/service/notifications/ConversationNotificationServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/notifications/ConversationNotificationServiceTest.java index 86d19bbb4fff..614c41470b7e 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/notifications/ConversationNotificationServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/notifications/ConversationNotificationServiceTest.java @@ -1,12 +1,14 @@ package de.tum.in.www1.artemis.service.notifications; -import static de.tum.in.www1.artemis.domain.notification.NotificationConstants.*; +import static de.tum.in.www1.artemis.domain.notification.NotificationConstants.NEW_MESSAGE_TITLE; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.time.ZonedDateTime; -import java.util.*; +import java.util.Comparator; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; From 51b4e0e65f073edcd9c57fb7f2a118f8f1a51bc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 18:18:58 +0200 Subject: [PATCH 20/47] Development: Update gitpython from 3.1.32 to 3.1.34 for supporting scripts (#7147) --- supporting_scripts/generate_code_cov_table/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supporting_scripts/generate_code_cov_table/requirements.txt b/supporting_scripts/generate_code_cov_table/requirements.txt index b43eb646f277..261bd85d406b 100644 --- a/supporting_scripts/generate_code_cov_table/requirements.txt +++ b/supporting_scripts/generate_code_cov_table/requirements.txt @@ -1,5 +1,5 @@ requests==2.31.0 -gitpython==3.1.32 +gitpython==3.1.34 beautifulsoup4==4.12.2 pyperclip==1.8.2 python-dotenv==1.0.0 From d610801114bbbdd9f18011b362b3e2d648f462dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 18:20:09 +0200 Subject: [PATCH 21/47] Development: Update gitpython from 3.1.34 to 3.1.35 in supporting scripts (#7161) --- supporting_scripts/generate_code_cov_table/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supporting_scripts/generate_code_cov_table/requirements.txt b/supporting_scripts/generate_code_cov_table/requirements.txt index 261bd85d406b..c4651ef8d432 100644 --- a/supporting_scripts/generate_code_cov_table/requirements.txt +++ b/supporting_scripts/generate_code_cov_table/requirements.txt @@ -1,5 +1,5 @@ requests==2.31.0 -gitpython==3.1.34 +gitpython==3.1.35 beautifulsoup4==4.12.2 pyperclip==1.8.2 python-dotenv==1.0.0 From aa2b327dac96f3c0f1cdac68fdd4221c53789f7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 19:57:28 +0200 Subject: [PATCH 22/47] Development: Update cypress e2e dependencies (#7117) --- src/test/cypress/package-lock.json | 86 +++++++++++++++--------------- src/test/cypress/package.json | 13 +++-- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/test/cypress/package-lock.json b/src/test/cypress/package-lock.json index 4d4d602707c8..f96cb4c9180c 100644 --- a/src/test/cypress/package-lock.json +++ b/src/test/cypress/package-lock.json @@ -8,12 +8,12 @@ "license": "MIT", "devDependencies": { "@4tw/cypress-drag-drop": "2.2.4", - "@types/node": "20.5.6", - "cypress": "12.17.4", - "cypress-cloud": "1.9.3", + "@types/node": "20.5.9", + "cypress": "13.1.0", + "cypress-cloud": "1.9.4", "cypress-file-upload": "5.0.8", "cypress-wait-until": "2.0.1", - "typescript": "5.1.6", + "typescript": "5.2.2", "uuid": "9.0.0", "wait-on": "7.0.1" } @@ -28,9 +28,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz", - "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz", + "integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==", "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -182,9 +182,9 @@ } }, "node_modules/@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -200,7 +200,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -301,9 +301,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.5.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.6.tgz", - "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==", + "version": "20.5.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", + "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==", "dev": true }, "node_modules/@types/sinonjs__fake-timers": { @@ -516,9 +516,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", + "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", "dev": true, "dependencies": { "follow-redirects": "^1.15.0", @@ -527,9 +527,9 @@ } }, "node_modules/axios-retry": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.6.0.tgz", - "integrity": "sha512-jtH4qWTKZ2a17dH6tjq52Y1ssNV0lKge6/Z9Lw67s9Wt01nGTg4hg7/LJBGYfDci44NTANJQlCPHPOT/TSFm9w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.7.0.tgz", + "integrity": "sha512-ZTnCkJbRtfScvwiRnoVskFAfvU0UG3xNcsjwTR0mawSbIJoothxn67gKsMaNAFHRXJ1RmuLhmZBzvyXi3+9WyQ==", "dev": true, "dependencies": { "@babel/runtime": "^7.15.4", @@ -904,13 +904,13 @@ } }, "node_modules/cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.1.0.tgz", + "integrity": "sha512-LUKxCYlB973QBFls1Up4FAE9QIYobT+2I8NvvAwMfQS2YwsWbr6yx7y9hmsk97iqbHkKwZW3MRjoK1RToBFVdQ==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", @@ -958,13 +958,13 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^14.0.0 || ^16.0.0 || >=18.0.0" + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, "node_modules/cypress-cloud": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/cypress-cloud/-/cypress-cloud-1.9.3.tgz", - "integrity": "sha512-SSS7olSrtX3UQ7OwmLSf2zcapyUD33l1i8gKbRxRLSybUK3Mi4NlEFK8i3lvJXBbo6T5UVQLMM+UTg5KbbflOA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/cypress-cloud/-/cypress-cloud-1.9.4.tgz", + "integrity": "sha512-zItu3zTtSOFMfKExlqOrWXA8A3aI2fhKLGuVDDVYFtW+uthrSlImVfIl+Yj+KB8lnJmLR/+z94Q3GvCPHKQzxg==", "dev": true, "dependencies": { "@cypress/commit-info": "^2.2.0", @@ -1072,9 +1072,9 @@ "dev": true }, "node_modules/cypress/node_modules/@types/node": { - "version": "16.18.40", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.40.tgz", - "integrity": "sha512-+yno3ItTEwGxXiS/75Q/aHaa5srkpnJaH+kdkTVJ3DtJEwv92itpKbxU+FjPoh2m/5G9zmUQfrL4A4C13c+iGA==", + "version": "16.18.48", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz", + "integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==", "dev": true }, "node_modules/dashdash": { @@ -1874,9 +1874,9 @@ "dev": true }, "node_modules/joi": { - "version": "17.9.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", - "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", + "version": "17.10.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.10.1.tgz", + "integrity": "sha512-vIiDxQKmRidUVp8KngT8MZSOcmRVm2zV7jbMjNYWuHcJWI0bUck3nRTGQjhpPlQenIQIBC5Vp9AhcnHbWQqafw==", "dev": true, "dependencies": { "@hapi/hoek": "^9.0.0", @@ -2941,9 +2941,9 @@ "dev": true }, "node_modules/tslib": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", - "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, "node_modules/tunnel-agent": { @@ -2977,9 +2977,9 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -3140,9 +3140,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.1.tgz", + "integrity": "sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/src/test/cypress/package.json b/src/test/cypress/package.json index 919043f2f741..53fb0f5c540d 100644 --- a/src/test/cypress/package.json +++ b/src/test/cypress/package.json @@ -8,12 +8,12 @@ ], "devDependencies": { "@4tw/cypress-drag-drop": "2.2.4", - "@types/node": "20.5.6", - "cypress": "12.17.4", - "cypress-cloud": "1.9.3", + "@types/node": "20.5.9", + "cypress": "13.1.0", + "cypress-cloud": "1.9.4", "cypress-file-upload": "5.0.8", "cypress-wait-until": "2.0.1", - "typescript": "5.1.6", + "typescript": "5.2.2", "uuid": "9.0.0", "wait-on": "7.0.1" }, @@ -21,7 +21,10 @@ "semver": "7.5.3", "word-wrap": "1.2.3", "debug": "4.3.4", - "tough-cookie": "4.1.3" + "tough-cookie": "4.1.3", + "@4tw/cypress-drag-drop": { + "cypress": "13.1.0" + } }, "scripts": { "cypress:open": "cypress open", From 9c44b222a3f928883fae0db54c39b7034d39d515 Mon Sep 17 00:00:00 2001 From: Andreas Resch Date: Fri, 8 Sep 2023 20:24:57 +0200 Subject: [PATCH 23/47] Development: Delete duplicated Hermes service documentation (#7155) --- docs/dev/setup.rst | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/docs/dev/setup.rst b/docs/dev/setup.rst index 323e6e3e963f..5ccd6cc2d1c5 100644 --- a/docs/dev/setup.rst +++ b/docs/dev/setup.rst @@ -645,51 +645,6 @@ HTTPS. We need to extend the Artemis configuration in the file ------------------------------------------------------------------------------------------------------------------------ -Hermes Service --------------- - -Push notifications for the mobile Android and iOS clients rely on the Hermes_ service. -To enable push notifications the Hermes service needs to be started separately and the configuration of the Artemis instance must be extended. - -Configure and start Hermes -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -To run Hermes, you need to clone the `repository `_ and replace the placeholders within the ``docker-compose`` file. - -The following environment variables need to be updated for push notifications to Apple devices: - -* ``APNS_CERTIFICATE_PATH``: String - Path to the APNs certificate .p12 file as described `here `_ -* ``APNS_CERTIFICATE_PWD``: String - The APNS certificate password -* ``APNS_PROD_ENVIRONMENT``: Bool - True if it should use the Production APNS Server (Default false) - -Furthermore, the .p12 needs to be mounted into the Docker under the above specified path. - -To run the services for Android support the following environment variable is required: - -* ``GOOGLE_APPLICATION_CREDENTIALS``: String - Path to the firebase.json - -Furthermore, the Firebase.json needs to be mounted into the Docker under the above specified path. - -To run both APNS and Firebase, configure the environment variables for both. - -To start Hermes, run the ``docker compose up`` command in the folder where the ``docker-compose`` file is located. - -Artemis Configuration -^^^^^^^^^^^^^^^^^^^^^ - -The Hermes service is running on a dedicated machine and is addressed via -HTTPS. We need to extend the Artemis configuration in the file -``src/main/resources/config/application-artemis.yml`` like: - -.. code:: yaml - - artemis: - # ... - push-notification-relay: - -.. _Hermes: https://github.com/ls1intum/Hermes - ------------------------------------------------------------------------------------------------------------------------- Athena Service -------------- From 9ed8587ea5dd72890020256fbe709c1a5f6a1496 Mon Sep 17 00:00:00 2001 From: Tobias Lippert <84102468+tobias-lippert@users.noreply.github.com> Date: Fri, 8 Sep 2023 20:38:00 +0200 Subject: [PATCH 24/47] Exam mode: Show suspicious behavior (#6973) --- .../tum/in/www1/artemis/domain/Exercise.java | 2 + .../www1/artemis/domain/exam/ExamSession.java | 31 ++++ .../www1/artemis/domain/exam/StudentExam.java | 13 ++ .../domain/exam/SuspiciousExamSessions.java | 13 ++ .../domain/exam/SuspiciousSessionReason.java | 8 + .../repository/ExamSessionRepository.java | 40 +++++ .../repository/ExerciseRepository.java | 17 ++ .../plagiarism/PlagiarismCaseRepository.java | 14 ++ .../PlagiarismComparisonRepository.java | 2 + .../service/exam/ExamSessionService.java | 165 +++++++++++++++++- .../service/plagiarism/PlagiarismService.java | 69 +++++++- .../www1/artemis/web/rest/ExamResource.java | 58 +++++- .../artemis/web/rest/dto/CourseWithIdDTO.java | 4 + .../artemis/web/rest/dto/ExamSessionDTO.java | 10 ++ .../web/rest/dto/ExamWithIdAndCourseDTO.java | 4 + ...ExerciseForPlagiarismCasesOverviewDTO.java | 4 + .../dto/ExerciseGroupWithIdAndExamDTO.java | 4 + .../StudentExamWithIdAndExamAndUserDTO.java | 4 + .../rest/dto/SuspiciousExamSessionsDTO.java | 9 + .../web/rest/dto/UserWithIdAndLoginDTO.java | 4 + .../plagiarism/PlagiarismCaseResource.java | 18 ++ .../rest/plagiarism/PlagiarismResource.java | 19 +- .../shared/plagiarism-cases.service.ts | 11 ++ .../shared/plagiarism-results.service.ts | 14 ++ .../webapp/app/entities/exam-session.model.ts | 12 +- .../app/exam/manage/exam-management.module.ts | 8 + .../app/exam/manage/exam-management.route.ts | 20 +++ .../exam/manage/exam-management.service.ts | 5 + .../exam-checklist.component.html | 28 ++- .../plagiarism-cases-overview.component.html | 31 ++++ .../plagiarism-cases-overview.component.ts | 36 ++++ .../suspicious-behavior.component.html | 31 ++++ .../suspicious-behavior.component.ts | 61 +++++++ ...uspicious-sessions-overview.component.html | 9 + .../suspicious-sessions-overview.component.ts | 15 ++ .../suspicious-sessions.service.ts | 15 ++ .../suspicious-sessions.component.html | 39 +++++ .../suspicious-sessions.component.scss | 3 + .../suspicious-sessions.component.ts | 38 ++++ .../shared/layouts/navbar/navbar.component.ts | 8 + src/main/webapp/i18n/de/exam.json | 35 +++- src/main/webapp/i18n/en/exam.json | 35 +++- .../artemis/exam/ExamIntegrationTest.java | 96 ++++++++++ .../in/www1/artemis/exam/ExamUtilService.java | 29 +++ .../PlagiarismCaseIntegrationTest.java | 25 +++ .../plagiarism/PlagiarismIntegrationTest.java | 39 +++++ .../www1/artemis/util/RequestUtilService.java | 23 ++- .../manage/exam-management.service.spec.ts | 16 ++ ...lagiarism-cases-overview.component.spec.ts | 91 ++++++++++ .../suspicious-behavior.component.spec.ts | 144 +++++++++++++++ ...icious-sessions-overview.component.spec.ts | 39 +++++ .../suspicious-sessions.component.spec.ts | 56 ++++++ .../suspicious-sessions.service.spec.ts | 40 +++++ .../service/plagiarism-cases.service.spec.ts | 19 +- .../plagiarism-results.service.spec.ts | 22 +++ 55 files changed, 1579 insertions(+), 26 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/CourseWithIdDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamSessionDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamWithIdAndCourseDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseForPlagiarismCasesOverviewDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseGroupWithIdAndExamDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/StudentExamWithIdAndExamAndUserDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/SuspiciousExamSessionsDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/UserWithIdAndLoginDTO.java create mode 100644 src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-results.service.ts create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.ts create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.html create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.ts create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.html create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.ts create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions.service.ts create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.html create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.scss create mode 100644 src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.ts create mode 100644 src/test/javascript/spec/component/exam/manage/suspicious-behavior/plagiarism-cases-overview.component.spec.ts create mode 100644 src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-behavior.component.spec.ts create mode 100644 src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions-overview.component.spec.ts create mode 100644 src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.component.spec.ts create mode 100644 src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.service.spec.ts create mode 100644 src/test/javascript/spec/service/plagiarism-results.service.spec.ts diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java index 5f8f10b636dc..f4bd0ce6fe18 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java @@ -985,6 +985,8 @@ public String getMappedColumnName() { public abstract ExerciseType getExerciseType(); + public abstract String getType(); + /** * Disconnects child entities from the exercise. *

diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamSession.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamSession.java index d9756a4c02bc..1523b1ed5e56 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamSession.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/ExamSession.java @@ -1,5 +1,8 @@ package de.tum.in.www1.artemis.domain.exam; +import java.util.HashSet; +import java.util.Set; + import javax.persistence.*; import org.hibernate.annotations.Cache; @@ -40,6 +43,9 @@ public class ExamSession extends AbstractAuditingEntity { @Transient private boolean isInitialSessionTransient; + @Transient + private Set suspiciousSessionReasons = new HashSet<>(); + public StudentExam getStudentExam() { return studentExam; } @@ -106,10 +112,35 @@ public void setInitialSession(boolean isInitialSessionTransient) { this.isInitialSessionTransient = isInitialSessionTransient; } + public Set getSuspiciousReasons() { + return suspiciousSessionReasons; + } + + public void setSuspiciousReasons(Set suspiciousSessionReasons) { + this.suspiciousSessionReasons = suspiciousSessionReasons; + } + + public void addSuspiciousReason(SuspiciousSessionReason suspiciousSessionReason) { + this.suspiciousSessionReasons.add(suspiciousSessionReason); + } + public void hideDetails() { setUserAgent(null); setBrowserFingerprintHash(null); setInstanceId(null); setIpAddress(null); } + + @JsonIgnore + public boolean hasSameIpAddress(ExamSession other) { + + return other != null && getIpAddressAsIpAddress() != null && getIpAddressAsIpAddress().equals(other.getIpAddressAsIpAddress()); + } + + @JsonIgnore + public boolean hasSameBrowserFingerprint(ExamSession other) { + + return other != null && getBrowserFingerprintHash() != null && getBrowserFingerprintHash().equals(other.getBrowserFingerprintHash()); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/StudentExam.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/StudentExam.java index 9b622bc3e025..5bff813357fe 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/exam/StudentExam.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/StudentExam.java @@ -160,6 +160,18 @@ public void setExamSessions(Set examSessions) { this.examSessions = examSessions; } + /** + * Adds the given exam session to the student exam + * + * @param examSession the exam session to add + * @return the student exam with the added exam session + */ + public StudentExam addExamSession(ExamSession examSession) { + this.examSessions.add(examSession); + examSession.setStudentExam(this); + return this; + } + /** * check if the individual student exam has ended (based on the working time) * For test exams, we cannot use exam.startTime, but need to use the student.startedDate. If this is not yet set, @@ -230,4 +242,5 @@ public boolean areResultsPublishedYet() { return exam.resultsPublished(); } } + } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java new file mode 100644 index 000000000000..cd23985ea56f --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousExamSessions.java @@ -0,0 +1,13 @@ +package de.tum.in.www1.artemis.domain.exam; + +import java.util.Set; + +/** + * A set of related exam sessions that are suspicious. + * An exam session is suspicious if it shares the same browser fingerprint hash or user agent or IP address with another + * exam session that attempts a different student exam. + * + * @param examSessions the set of exam sessions that are suspicious + */ +public record SuspiciousExamSessions(Set examSessions) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java new file mode 100644 index 000000000000..be87111202ce --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/SuspiciousSessionReason.java @@ -0,0 +1,8 @@ +package de.tum.in.www1.artemis.domain.exam; + +/** + * Enum representing reasons why a session is considered suspicious. + */ +public enum SuspiciousSessionReason { + SAME_IP_ADDRESS, SAME_BROWSER_FINGERPRINT +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java index 969419f33260..20ce341b7bc9 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExamSessionRepository.java @@ -1,5 +1,7 @@ package de.tum.in.www1.artemis.repository; +import java.util.Set; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,4 +22,42 @@ SELECT count(es.id) """) long findExamSessionCountByStudentExamId(@Param("studentExamId") Long studentExamId); + @Query(""" + SELECT es + FROM ExamSession es + WHERE es.studentExam.exam.id = :examId + AND es.id <> :#{#examSession.id} + AND es.studentExam.id <> :#{#examSession.studentExam.id} + AND es.ipAddress = :#{#examSession.ipAddress} + AND es.browserFingerprintHash = :#{#examSession.browserFingerprintHash} + """) + Set findAllExamSessionsWithTheSameIpAddressAndBrowserFingerprintByExamIdAndExamSession(long examId, @Param("examSession") ExamSession examSession); + + @Query(""" + SELECT es + FROM ExamSession es + WHERE es.studentExam.exam.id = :examId + """) + Set findAllExamSessionsByExamId(long examId); + + @Query(""" + SELECT es + FROM ExamSession es + WHERE es.studentExam.exam.id = :examId + AND es.id <> :#{#examSession.id} + AND es.studentExam.id <> :#{#examSession.studentExam.id} + AND es.browserFingerprintHash = :#{#examSession.browserFingerprintHash} + """) + Set findAllExamSessionsWithTheSameBrowserFingerprintByExamIdAndExamSession(long examId, @Param("examSession") ExamSession examSession); + + @Query(""" + SELECT es + FROM ExamSession es + WHERE es.studentExam.exam.id = :examId + AND es.id <> :#{#examSession.id} + AND es.studentExam.id <> :#{#examSession.studentExam.id} + AND es.ipAddress = :#{#examSession.ipAddress} + """) + Set findAllExamSessionsWithTheSameIpAddressByExamIdAndExamSession(long examId, @Param("examSession") ExamSession examSession); + } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java index d7d0e8b93bf3..612dc346f01f 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java @@ -353,4 +353,21 @@ default boolean toggleSecondCorrection(Exercise exercise) { OR students.id = :userId """) Set getAllExercisesUserParticipatedInWithEagerParticipationsSubmissionsResultsFeedbacksByUserId(long userId); + + /** + * For an explanation, see {@link de.tum.in.www1.artemis.web.rest.ExamResource#getAllExercisesWithPotentialPlagiarismForExam(long,long)} + * + * @param examId the id of the exam for which we want to get all exercises with potential plagiarism + * @return a list of exercises with potential plagiarism + */ + @Query(""" + SELECT e + FROM Exercise e + LEFT JOIN e.exerciseGroup eg + WHERE eg IS NOT NULL + AND eg.exam.id = :examId + AND TYPE (e) IN (ModelingExercise, TextExercise, ProgrammingExercise) + + """) + Set findAllExercisesWithPotentialPlagiarismByExamId(long examId); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismCaseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismCaseRepository.java index 7953751e7948..3621deacd50c 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismCaseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismCaseRepository.java @@ -134,4 +134,18 @@ default PlagiarismCase findByIdWithPlagiarismSubmissionsElseThrow(long plagiaris default PlagiarismCase findByIdElseThrow(long plagiarismCaseId) { return findById(plagiarismCaseId).orElseThrow(() -> new EntityNotFoundException("PlagiarismCase", plagiarismCaseId)); } + + /** + * Count the number of plagiarism cases for a given exercise id excluding deleted users. + * + * @param exerciseId the id of the exercise + * @return the number of plagiarism cases + */ + @Query(""" + SELECT COUNT(plagiarismCase) + FROM PlagiarismCase plagiarismCase + WHERE plagiarismCase.student.isDeleted = false + AND plagiarismCase.exercise.id = :exerciseId + """) + long countByExerciseId(long exerciseId); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismComparisonRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismComparisonRepository.java index 9f0aa2cfe838..bafd56a3196b 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismComparisonRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/plagiarism/PlagiarismComparisonRepository.java @@ -81,4 +81,6 @@ default PlagiarismComparison findByIdWithSubmissionsStudentsAndElementsBElseT @Transactional // ok because of modifying query @Query("UPDATE PlagiarismComparison plagiarismComparison set plagiarismComparison.status = :status where plagiarismComparison.id = :plagiarismComparisonId") void updatePlagiarismComparisonStatus(@Param("plagiarismComparisonId") Long plagiarismComparisonId, @Param("status") PlagiarismStatus status); + + Set> findAllByPlagiarismResultExerciseId(long exerciseId); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java index 47eda814d1a7..76b184ee0b02 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamSessionService.java @@ -1,7 +1,8 @@ package de.tum.in.www1.artemis.service.exam; import java.security.SecureRandom; -import java.util.Base64; +import java.util.*; +import java.util.function.BiFunction; import javax.annotation.Nullable; @@ -9,9 +10,10 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import de.tum.in.www1.artemis.domain.exam.ExamSession; -import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.domain.exam.*; import de.tum.in.www1.artemis.repository.ExamSessionRepository; +import de.tum.in.www1.artemis.repository.StudentExamRepository; +import de.tum.in.www1.artemis.web.rest.dto.*; import inet.ipaddr.IPAddress; /** @@ -24,8 +26,11 @@ public class ExamSessionService { private final ExamSessionRepository examSessionRepository; - public ExamSessionService(ExamSessionRepository examSessionRepository) { + private final StudentExamRepository studentExamRepository; + + public ExamSessionService(ExamSessionRepository examSessionRepository, StudentExamRepository studentExamRepository) { this.examSessionRepository = examSessionRepository; + this.studentExamRepository = studentExamRepository; } /** @@ -72,4 +77,156 @@ public boolean checkExamSessionIsInitial(Long studentExamId) { long examSessionCount = examSessionRepository.findExamSessionCountByStudentExamId(studentExamId); return (examSessionCount == 1); } + + /** + * Retrieves all suspicious exam sessions for given exam id + * An exam session is suspicious if it has the same browser fingerprint or ip address and belongs to a different student exam + * + * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @return set of suspicious exam sessions + */ + public Set retrieveAllSuspiciousExamSessionsByExamId(long examId) { + Set suspiciousExamSessions = new HashSet<>(); + Set examSessions = examSessionRepository.findAllExamSessionsByExamId(examId); + examSessions = filterEqualExamSessionsForSameStudentExam(examSessions); + // first step find all sessions that have matching browser fingerprint and ip address + findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameIpAddressAndBrowserFingerprintByExamIdAndExamSession, + suspiciousExamSessions); + // second step find all sessions that have only matching browser fingerprint + findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameBrowserFingerprintByExamIdAndExamSession, + suspiciousExamSessions); + // third step find all sessions that have only matching ip address + findSuspiciousSessionsForGivenCriteria(examSessions, examId, examSessionRepository::findAllExamSessionsWithTheSameIpAddressByExamIdAndExamSession, suspiciousExamSessions); + + return convertSuspiciousSessionsToDTO(suspiciousExamSessions); + } + + /** + * Finds suspicious exam sessions according to the criteria given and adds them to the set of suspicious exam sessions + * + * @param examSessions set of exam sessions to be processed + * @param examId id of the exam for which suspicious exam sessions shall be retrieved + * @param criteriaFilter function that returns a set of exam sessions that match the given criteria + * @param suspiciousExamSessions set of suspicious exam sessions to which the found suspicious exam sessions shall be added + */ + private void findSuspiciousSessionsForGivenCriteria(Set examSessions, long examId, BiFunction> criteriaFilter, + Set suspiciousExamSessions) { + + for (var examSession : examSessions) { + Set relatedExamSessions = criteriaFilter.apply(examId, examSession); + relatedExamSessions = filterEqualRelatedExamSessionsOfSameStudentExam(relatedExamSessions); + + if (!relatedExamSessions.isEmpty() && !isSubsetOfFoundSuspiciousSessions(relatedExamSessions, suspiciousExamSessions)) { + addSuspiciousReasons(examSession, relatedExamSessions); + relatedExamSessions.add(examSession); + suspiciousExamSessions.add(new SuspiciousExamSessions(relatedExamSessions)); + } + } + } + + /** + * Checks if the given set of exam sessions is a subset of suspicious exam sessions that have already been found. + * This is necessary as we want to avoid duplicate results. + * E.g. if we have exam session A,B,C and they are suspicious because of the same browser fingerprint AND the same IP address, + * we do not want to include the same tuple of sessions again with only the reason same browser fingerprint or same IP address. + * + * @param relatedExamSessions a set of exam sessions that are suspicious + * @param suspiciousExamSessions a set of suspicious exam sessions that have already been found + * @return true if the given set of exam sessions is a subset of suspicious exam sessions that have already been found, otherwise false. + */ + private boolean isSubsetOfFoundSuspiciousSessions(Set relatedExamSessions, Set suspiciousExamSessions) { + for (var suspiciousExamSession : suspiciousExamSessions) { + if (suspiciousExamSession.examSessions().containsAll(relatedExamSessions)) { + return true; + } + } + return false; + } + + /** + * Filters out exam sessions that have the same student exam id and the same browser fingerprint, ip address and user agent + * This is necessary as the same student exam can have multiple exam sessions (e.g. if the student has to re-enter the exam) + * As they are the same for parameters we compare, they only need to be included once and lead to duplicate results otherwise + * + * @param examSessions exam sessions to filter + * @return filtered exam sessions + */ + private Set filterEqualExamSessionsForSameStudentExam(Set examSessions) { + Set filteredSessions = new HashSet<>(); + Set processedSessionKeys = new HashSet<>(); + + for (ExamSession session : examSessions) { + // calculating this key avoids using a second loop. We cannot rely on equals as the standard equals method inherited from DomainObject just takes the id into account + // and overriding the equals method to only use the fields we are interested in leads to an unintuitive equals method we want to avoid + String sessionKey = session.getBrowserFingerprintHash() + "_" + session.getIpAddress() + "_" + session.getUserAgent() + "_" + session.getStudentExam().getId(); + + if (!processedSessionKeys.contains(sessionKey)) { + filteredSessions.add(session); + processedSessionKeys.add(sessionKey); + } + } + return filteredSessions; + } + + /** + * Filters out exam sessions that have the same student exam id, only used if they are flagged as suspicious in comparison to another exam session + * + * @param examSessions exam sessions to filter + * @return filtered exam sessions + */ + private Set filterEqualRelatedExamSessionsOfSameStudentExam(Set examSessions) { + Set filteredSessions = new HashSet<>(); + Set processedSessionsStudentExamIds = new HashSet<>(); + + for (ExamSession session : examSessions) { + // calculating this key avoids using a second loop. We cannot rely on equals as the standard equals method inherited from DomainObject just takes the id into account + // and overriding the equals method to only use the fields we are interested in leads to an unintuitive equals method we want to avoid + long sessionKey = session.getStudentExam().getId(); + + if (!processedSessionsStudentExamIds.contains(sessionKey)) { + filteredSessions.add(session); + processedSessionsStudentExamIds.add(sessionKey); + } + } + return filteredSessions; + } + + private Set convertSuspiciousSessionsToDTO(Set suspiciousExamSessions) { + Set suspiciousExamSessionsDTO = new HashSet<>(); + for (var suspiciousExamSession : suspiciousExamSessions) { + Set examSessionDTOs = new HashSet<>(); + for (var examSession : suspiciousExamSession.examSessions()) { + var userDTO = new UserWithIdAndLoginDTO(examSession.getStudentExam().getUser().getId(), examSession.getStudentExam().getUser().getLogin()); + var courseDTO = new CourseWithIdDTO(examSession.getStudentExam().getExam().getCourse().getId()); + var examDTO = new ExamWithIdAndCourseDTO(examSession.getStudentExam().getExam().getId(), courseDTO); + var studentExamDTO = new StudentExamWithIdAndExamAndUserDTO(examSession.getStudentExam().getId(), examDTO, userDTO); + examSessionDTOs.add(new ExamSessionDTO(examSession.getId(), examSession.getBrowserFingerprintHash(), examSession.getIpAddress(), examSession.getSuspiciousReasons(), + examSession.getCreatedDate(), studentExamDTO)); + } + suspiciousExamSessionsDTO.add(new SuspiciousExamSessionsDTO(examSessionDTOs)); + } + return suspiciousExamSessionsDTO; + } + + /** + * Adds suspicious reasons to exam session we compare with and the related exam sessions. + * We already know that the exam sessions are suspicious, but we still have to determine what's the reason for that. + * + * @param session exam session we compare with + * @param relatedExamSessions related exam sessions + */ + private void addSuspiciousReasons(ExamSession session, Set relatedExamSessions) { + for (var relatedExamSession : relatedExamSessions) { + if (relatedExamSession.hasSameBrowserFingerprint(session)) { + relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT); + session.addSuspiciousReason(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT); + } + if (relatedExamSession.hasSameIpAddress(session)) { + relatedExamSession.addSuspiciousReason(SuspiciousSessionReason.SAME_IP_ADDRESS); + session.addSuspiciousReason(SuspiciousSessionReason.SAME_IP_ADDRESS); + } + + } + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismService.java index 8689f41fdbb0..f4a5ea141cae 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/PlagiarismService.java @@ -1,9 +1,17 @@ package de.tum.in.www1.artemis.service.plagiarism; +import java.util.HashSet; +import java.util.Set; + import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.Submission; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismComparison; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismStatus; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismSubmission; +import de.tum.in.www1.artemis.repository.SubmissionRepository; +import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismComparisonRepository; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; @@ -14,9 +22,16 @@ public class PlagiarismService { private final PlagiarismCaseService plagiarismCaseService; - public PlagiarismService(PlagiarismComparisonRepository plagiarismComparisonRepository, PlagiarismCaseService plagiarismCaseService) { + private final SubmissionRepository submissionRepository; + + private final UserRepository userRepository; + + public PlagiarismService(PlagiarismComparisonRepository plagiarismComparisonRepository, PlagiarismCaseService plagiarismCaseService, SubmissionRepository submissionRepository, + UserRepository userRepository) { this.plagiarismComparisonRepository = plagiarismComparisonRepository; this.plagiarismCaseService = plagiarismCaseService; + this.submissionRepository = submissionRepository; + this.userRepository = userRepository; } /** @@ -75,4 +90,56 @@ else if (plagiarismStatus.equals(PlagiarismStatus.DENIED)) { plagiarismCaseService.removeSubmissionsInPlagiarismCasesForComparison(plagiarismComparisonId); } } + + /** + * Retrieves the number of potential plagiarism cases by considering the plagiarism submissions for the exercise + * Additionally, it filters out cases for deleted user --> isDeleted = true because we do not delete the user entity entirely. + * + * @param exerciseId the exercise id for which the potential plagiarism cases should be retrieved + * @return the number of potential plagiarism cases + */ + public long getNumberOfPotentialPlagiarismCasesForExercise(long exerciseId) { + var comparisons = plagiarismComparisonRepository.findAllByPlagiarismResultExerciseId(exerciseId); + Set> submissionsWithoutDeletedUsers = new HashSet<>(); + for (var comparison : comparisons) { + addSubmissionsIfUserHasNotBeenDeleted(comparison, submissionsWithoutDeletedUsers); + } + return submissionsWithoutDeletedUsers.size(); + } + + /** + * Add each submission of the plagiarism comparison if the corresponding user has not been deleted + * + * @param comparison the comparison for which we want check if the user of the submission has been deleted. + * @param submissionsWithoutDeletedUsers a set of plagiarism submissions for which the user still exists. + */ + private void addSubmissionsIfUserHasNotBeenDeleted(PlagiarismComparison comparison, Set> submissionsWithoutDeletedUsers) { + var plagiarismSubmissionA = comparison.getSubmissionA(); + var plagiarismSubmissionB = comparison.getSubmissionB(); + var submissionA = submissionRepository.findById(plagiarismSubmissionA.getSubmissionId()).orElseThrow(); + var submissionB = submissionRepository.findById(plagiarismSubmissionB.getSubmissionId()).orElseThrow(); + if (!userForSubmissionDeleted(submissionA)) { + submissionsWithoutDeletedUsers.add(plagiarismSubmissionA); + } + if (!userForSubmissionDeleted(submissionB)) { + submissionsWithoutDeletedUsers.add(plagiarismSubmissionB); + + } + } + + /** + * Checks if the user the submission belongs to, has not the isDeleted flag set to true + * + * @param submission the submission to check + * @return true if the user is NOT deleted, false otherwise + */ + private boolean userForSubmissionDeleted(Submission submission) { + if (submission.getParticipation() instanceof StudentParticipation studentParticipation) { + var user = userRepository.findOneByLogin(studentParticipation.getParticipant().getParticipantIdentifier()); + if (user.isPresent()) { + return user.get().isDeleted(); + } + } + return true; // if the user is not found, we assume that the user has been deleted + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 7da62fc9bce7..00b3bfe6a122 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -33,9 +33,7 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.exam.Exam; -import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; -import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.domain.exam.*; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.participation.TutorParticipation; import de.tum.in.www1.artemis.repository.*; @@ -113,12 +111,16 @@ public class ExamResource { private final ChannelService channelService; + private final ExerciseRepository exerciseRepository; + + private final ExamSessionService examSessionService; + public ExamResource(ProfileService profileService, UserRepository userRepository, CourseRepository courseRepository, ExamService examService, ExamDeletionService examDeletionService, ExamAccessService examAccessService, InstanceMessageSendService instanceMessageSendService, ExamRepository examRepository, SubmissionService submissionService, AuthorizationCheckService authCheckService, ExamDateService examDateService, TutorParticipationRepository tutorParticipationRepository, AssessmentDashboardService assessmentDashboardService, ExamRegistrationService examRegistrationService, StudentExamRepository studentExamRepository, ExamImportService examImportService, CustomAuditEventRepository auditEventRepository, ChannelService channelService, - ChannelRepository channelRepository) { + ChannelRepository channelRepository, ExerciseRepository exerciseRepository, ExamSessionService examSessionRepository) { this.profileService = profileService; this.userRepository = userRepository; this.courseRepository = courseRepository; @@ -138,6 +140,8 @@ public ExamResource(ProfileService profileService, UserRepository userRepository this.auditEventRepository = auditEventRepository; this.channelService = channelService; this.channelRepository = channelRepository; + this.exerciseRepository = exerciseRepository; + this.examSessionService = examSessionRepository; } /** @@ -1151,4 +1155,50 @@ public ResponseEntity downloadExamArchive(@PathVariable Long courseId, return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()).body(resource); } + /** + * GET /courses/{courseId}/exams/{examId}/exercises-with-potential-plagiarism : Get all exercises with potential plagiarism for exam. + * An exercise has potential plagiarism if Artemis supports plagiarism detection for it. + * This applies to the exercise types TEXT, MODELING and PROGRAMMING. + * + * @param courseId the id of the course the exam belongs to + * @param examId the id of the exam for which to find exercises with potential plagiarism + * @return the list of exercises with potential plagiarism + */ + @GetMapping("courses/{courseId}/exams/{examId}/exercises-with-potential-plagiarism") + @EnforceAtLeastInstructor + public List getAllExercisesWithPotentialPlagiarismForExam(@PathVariable long courseId, @PathVariable long examId) { + log.debug("REST request to get all exercises with potential plagiarism cases for exam : {}", examId); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + Set exercises = exerciseRepository.findAllExercisesWithPotentialPlagiarismByExamId(examId); + List exerciseForPlagiarismCasesOverviewDTOS = new ArrayList<>(); + for (Exercise exercise : exercises) { + var courseDTO = new CourseWithIdDTO(exercise.getExerciseGroup().getExam().getCourse().getId()); + var examDTO = new ExamWithIdAndCourseDTO(exercise.getExerciseGroup().getExam().getId(), courseDTO); + var exerciseGroupDTO = new ExerciseGroupWithIdAndExamDTO(exercise.getExerciseGroup().getId(), examDTO); + ExerciseForPlagiarismCasesOverviewDTO exerciseForPlagiarismCasesOverviewDTO = new ExerciseForPlagiarismCasesOverviewDTO(exercise.getId(), exercise.getTitle(), + exercise.getType(), exerciseGroupDTO); + exerciseForPlagiarismCasesOverviewDTOS.add(exerciseForPlagiarismCasesOverviewDTO); + } + return exerciseForPlagiarismCasesOverviewDTOS; + + } + + /** + * GET /courses/{courseId}/exams/{examId}/suspicious-sessions : Get all exam sessions that are suspicious for exam. + * For an explanation when a session is suspicious, see {@link ExamSessionService#retrieveAllSuspiciousExamSessionsByExamId(long)} + * + * @param courseId the id of the course + * @param examId the id of the exam + * @return a set containing all tuples of exam sessions that are suspicious. + */ + @GetMapping("courses/{courseId}/exams/{examId}/suspicious-sessions") + @EnforceAtLeastInstructor + public Set getAllSuspiciousExamSessions(@PathVariable long courseId, @PathVariable long examId) { + log.debug("REST request to get all exam sessions that are suspicious for exam : {}", examId); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + return examSessionService.retrieveAllSuspiciousExamSessionsByExamId(examId); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/CourseWithIdDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/CourseWithIdDTO.java new file mode 100644 index 000000000000..9d3b53293f19 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/CourseWithIdDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record CourseWithIdDTO(long id) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamSessionDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamSessionDTO.java new file mode 100644 index 000000000000..9c53328aad69 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamSessionDTO.java @@ -0,0 +1,10 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +import java.time.Instant; +import java.util.Set; + +import de.tum.in.www1.artemis.domain.exam.SuspiciousSessionReason; + +public record ExamSessionDTO(long id, String browserFingerprintHash, String ipAddress, Set suspiciousReasons, Instant createdDate, + StudentExamWithIdAndExamAndUserDTO studentExam) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamWithIdAndCourseDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamWithIdAndCourseDTO.java new file mode 100644 index 000000000000..0f42c9c705e1 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExamWithIdAndCourseDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record ExamWithIdAndCourseDTO(long id, CourseWithIdDTO course) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseForPlagiarismCasesOverviewDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseForPlagiarismCasesOverviewDTO.java new file mode 100644 index 000000000000..62e788a43887 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseForPlagiarismCasesOverviewDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record ExerciseForPlagiarismCasesOverviewDTO(long id, String title, String type, ExerciseGroupWithIdAndExamDTO exerciseGroup) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseGroupWithIdAndExamDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseGroupWithIdAndExamDTO.java new file mode 100644 index 000000000000..ca92f8790c92 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/ExerciseGroupWithIdAndExamDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record ExerciseGroupWithIdAndExamDTO(long id, ExamWithIdAndCourseDTO exam) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/StudentExamWithIdAndExamAndUserDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/StudentExamWithIdAndExamAndUserDTO.java new file mode 100644 index 000000000000..4af47baa1b49 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/StudentExamWithIdAndExamAndUserDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record StudentExamWithIdAndExamAndUserDTO(long id, ExamWithIdAndCourseDTO exam, UserWithIdAndLoginDTO user) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/SuspiciousExamSessionsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/SuspiciousExamSessionsDTO.java new file mode 100644 index 000000000000..659654a2bf4e --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/SuspiciousExamSessionsDTO.java @@ -0,0 +1,9 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record SuspiciousExamSessionsDTO(Set examSessions) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/UserWithIdAndLoginDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/UserWithIdAndLoginDTO.java new file mode 100644 index 000000000000..8d0f99b86eb3 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/UserWithIdAndLoginDTO.java @@ -0,0 +1,4 @@ +package de.tum.in.www1.artemis.web.rest.dto; + +public record UserWithIdAndLoginDTO(long id, String login) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java index 2e663a1b00cc..1e5168a7fa21 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismCaseResource.java @@ -126,6 +126,24 @@ private ResponseEntity getPlagiarismCaseResponseEntity(Plagiaris return ResponseEntity.ok(plagiarismCase); } + /** + * GET /courses/{courseId}/exercises/{exerciseId}/plagiarism-cases-count : Counts the number of plagiarism cases for the given exercise. + * + * @param courseId the id of the course + * @param exerciseId the id of the exercise + * @return the number of plagiarism cases for the given exercise + */ + @GetMapping("courses/{courseId}/exercises/{exerciseId}/plagiarism-cases-count") + @EnforceAtLeastInstructor + public long getNumberOfPlagiarismCasesForExercise(@PathVariable long courseId, @PathVariable long exerciseId) { + log.debug("REST request to get number of plagiarism cases for exercise with id: {}", exerciseId); + Course course = courseRepository.findByIdElseThrow(courseId); + if (!authenticationCheckService.isAtLeastInstructorInCourse(course, null)) { + throw new AccessForbiddenException("Only instructors of this course have access to its plagiarism cases."); + } + return plagiarismCaseRepository.countByExerciseId(exerciseId); + } + /** * Update the verdict of the plagiarism case with the given ID. * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismResource.java index e5c36b296d2f..600449018a07 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/plagiarism/PlagiarismResource.java @@ -12,7 +12,9 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismComparison; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismStatus; -import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismComparisonRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismResultRepository; import de.tum.in.www1.artemis.security.Role; @@ -184,4 +186,19 @@ public ResponseEntity deletePlagiarismComparisons(@PathVariable("exerciseI } return ResponseEntity.ok().build(); } + + /** + * GET /exercises/:exerciseId/potential-plagiarism-count : get the number of potential plagiarism cases for the given exercise + * This endpoint returns the number of plagiarism submissions for the given exercise excluding submissions of deleted users. + * + * @param exerciseId the id of the exercise + * @return the number of plagiarism results + */ + @GetMapping("exercises/{exerciseId}/potential-plagiarism-count") + @EnforceAtLeastInstructor + public long getNumberOfPotentialPlagiarismCasesForExercise(@PathVariable("exerciseId") long exerciseId) { + var exercise = exerciseRepository.findByIdElseThrow(exerciseId); + authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, exercise, null); + return plagiarismService.getNumberOfPotentialPlagiarismCasesForExercise(exerciseId); + } } diff --git a/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-cases.service.ts b/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-cases.service.ts index d7c9a4b8333a..e4041a996cf4 100644 --- a/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-cases.service.ts +++ b/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-cases.service.ts @@ -7,6 +7,7 @@ import { PlagiarismComparison } from 'app/exercises/shared/plagiarism/types/Plag import { PlagiarismSubmissionElement } from 'app/exercises/shared/plagiarism/types/PlagiarismSubmissionElement'; import { PlagiarismVerdict } from 'app/exercises/shared/plagiarism/types/PlagiarismVerdict'; import { PlagiarismCaseInfo } from 'app/exercises/shared/plagiarism/types/PlagiarismCaseInfo'; +import { Exercise } from 'app/entities/exercise.model'; export type EntityResponseType = HttpResponse; export type EntityArrayResponseType = HttpResponse; @@ -131,4 +132,14 @@ export class PlagiarismCasesService { observe: 'response', }); } + public getNumberOfPlagiarismCasesForExercise(exercise: Exercise): Observable { + let courseId: number; + if (exercise.exerciseGroup) { + courseId = exercise.exerciseGroup.exam!.course!.id!; + } else { + courseId = exercise.course!.id!; + } + const exerciseId = exercise!.id; + return this.http.get(`${this.resourceUrl}/${courseId}/exercises/${exerciseId}/plagiarism-cases-count`); + } } diff --git a/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-results.service.ts b/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-results.service.ts new file mode 100644 index 000000000000..28652db616eb --- /dev/null +++ b/src/main/webapp/app/course/plagiarism-cases/shared/plagiarism-results.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class PlagiarismResultsService { + private resourceUrlExercises = 'api/exercises'; + + constructor(private http: HttpClient) {} + + getNumberOfPlagiarismResultsForExercise(exerciseId: number): Observable { + return this.http.get(`${this.resourceUrlExercises}/${exerciseId}/potential-plagiarism-count`); + } +} diff --git a/src/main/webapp/app/entities/exam-session.model.ts b/src/main/webapp/app/entities/exam-session.model.ts index b4019a4e18e0..a528a4b0a45d 100644 --- a/src/main/webapp/app/entities/exam-session.model.ts +++ b/src/main/webapp/app/entities/exam-session.model.ts @@ -1,6 +1,11 @@ import { BaseEntity } from 'app/shared/model/base-entity'; +import dayjs from 'dayjs/esm'; import { StudentExam } from './student-exam.model'; +export enum SuspiciousSessionReason { + SAME_IP_ADDRESS = 'SAME_IP_ADDRESS', + SAME_BROWSER_FINGERPRINT = 'SAME_BROWSER_FINGERPRINT', +} export class ExamSession implements BaseEntity { public id?: number; public studentExam?: StudentExam; @@ -12,6 +17,11 @@ export class ExamSession implements BaseEntity { public initialSession?: boolean; public createdBy?: string; public lastModifiedBy?: string; - public createdDate?: Date; + public createdDate?: dayjs.Dayjs; public lastModifiedDate?: Date; + public suspiciousReasons: SuspiciousSessionReason[] = []; +} + +export class SuspiciousExamSessions { + examSessions: ExamSession[] = []; } diff --git a/src/main/webapp/app/exam/manage/exam-management.module.ts b/src/main/webapp/app/exam/manage/exam-management.module.ts index 5d606f30618e..0e951ffc62d4 100644 --- a/src/main/webapp/app/exam/manage/exam-management.module.ts +++ b/src/main/webapp/app/exam/manage/exam-management.module.ts @@ -53,6 +53,10 @@ import { FeatureToggleModule } from 'app/shared/feature-toggle/feature-toggle.mo import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; import { ArtemisModePickerModule } from 'app/exercises/shared/mode-picker/mode-picker.module'; import { TitleChannelNameModule } from 'app/shared/form/title-channel-name/title-channel-name.module'; +import { SuspiciousBehaviorComponent } from './suspicious-behavior/suspicious-behavior.component'; +import { SuspiciousSessionsOverviewComponent } from './suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; +import { PlagiarismCasesOverviewComponent } from './suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component'; +import { SuspiciousSessionsComponent } from './suspicious-behavior/suspicious-sessions/suspicious-sessions.component'; const ENTITY_STATES = [...examManagementState]; @@ -115,6 +119,10 @@ const ENTITY_STATES = [...examManagementState]; ExamImportComponent, ExamExerciseImportComponent, BonusComponent, + SuspiciousBehaviorComponent, + SuspiciousSessionsOverviewComponent, + PlagiarismCasesOverviewComponent, + SuspiciousSessionsComponent, ], }) export class ArtemisExamManagementModule {} diff --git a/src/main/webapp/app/exam/manage/exam-management.route.ts b/src/main/webapp/app/exam/manage/exam-management.route.ts index 539b27008d14..cf7b396060f0 100644 --- a/src/main/webapp/app/exam/manage/exam-management.route.ts +++ b/src/main/webapp/app/exam/manage/exam-management.route.ts @@ -55,6 +55,8 @@ import { FileUploadExerciseManagementResolve } from 'app/exercises/file-upload/m import { ModelingExerciseResolver } from 'app/exercises/modeling/manage/modeling-exercise-resolver.service'; import { ExamResolve, ExerciseGroupResolve, StudentExamResolve } from 'app/exam/manage/exam-management-resolve.service'; import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; +import { SuspiciousBehaviorComponent } from 'app/exam/manage/suspicious-behavior/suspicious-behavior.component'; +import { SuspiciousSessionsOverviewComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; export const examManagementRoute: Routes = [ { @@ -215,6 +217,24 @@ export const examManagementRoute: Routes = [ }, canActivate: [UserRouteAccessService], }, + { + path: ':examId/suspicious-behavior', + component: SuspiciousBehaviorComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.examManagement.suspiciousBehavior.title', + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':examId/suspicious-behavior/suspicious-sessions', + component: SuspiciousSessionsOverviewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.title', + }, + canActivate: [UserRouteAccessService], + }, { path: ':examId/test-runs', component: TestRunManagementComponent, diff --git a/src/main/webapp/app/exam/manage/exam-management.service.ts b/src/main/webapp/app/exam/manage/exam-management.service.ts index f740513acabd..fd05d95cb618 100644 --- a/src/main/webapp/app/exam/manage/exam-management.service.ts +++ b/src/main/webapp/app/exam/manage/exam-management.service.ts @@ -20,6 +20,7 @@ import { AccountService } from 'app/core/auth/account.service'; import { convertDateFromClient, convertDateFromServer } from 'app/utils/date.utils'; import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity-title.service'; import { ExamExerciseStartPreparationStatus } from 'app/exam/manage/student-exams/student-exams.component'; +import { Exercise } from 'app/entities/exercise.model'; type EntityResponseType = HttpResponse; type EntityArrayResponseType = HttpResponse; @@ -486,4 +487,8 @@ export class ExamManagementService { private sendTitlesToEntityTitleService(exam: Exam | undefined | null) { this.entityTitleService.setTitle(EntityType.EXAM, [exam?.id], exam?.title); } + + getExercisesWithPotentialPlagiarismForExam(courseId: number, examId: number): Observable { + return this.http.get(`${this.resourceUrl}/${courseId}/exams/${examId}/exercises-with-potential-plagiarism`); + } } diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html index b8ecc4819a9f..41dbace41247 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html @@ -353,6 +353,26 @@

{{ 'artemisApp.examStatus.correction.examCorrection' | artemisTranslate }} 1 + + {{ 'artemisApp.examManagement.checklist.tableItem.suspiciousBehavior' | artemisTranslate }} + + + {{ 'artemisApp.examManagement.checklist.descriptionItem.suspiciousBehavior' | artemisTranslate }} + +
+ +
+ + +
+ + {{ 'artemisApp.examManagement.suspiciousBehavior.title' | artemisTranslate }} + + + + + + 2 {{ 'artemisApp.examManagement.checklist.tableItem.assessAllSubmissions' | artemisTranslate }} @@ -389,7 +409,7 @@

{{ 'artemisApp.examStatus.correction.examCorrection' | artemisTranslate }} - 2 + 3 {{ 'artemisApp.examManagement.checklist.tableItem.publishResults' | artemisTranslate }} @@ -413,7 +433,7 @@

{{ 'artemisApp.examStatus.correction.examCorrection' | artemisTranslate }} - 3 + 4 {{ 'artemisApp.examManagement.checklist.tableItem.examReview' | artemisTranslate }} @@ -443,7 +463,7 @@

{{ 'artemisApp.examStatus.correction.examCorrection' | artemisTranslate }} - 4 + 5 {{ 'artemisApp.examManagement.checklist.tableItem.resolveComplaints' | artemisTranslate }} @@ -473,7 +493,7 @@

{{ 'artemisApp.examStatus.correction.examCorrection' | artemisTranslate }} - 5 + 6 {{ 'artemisApp.examManagement.checklist.tableItem.exportResults' | artemisTranslate }} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html new file mode 100644 index 000000000000..5af7ec06e93d --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html @@ -0,0 +1,31 @@ +

{{ 'artemisApp.examManagement.plagiarismCasesOverview.title' | artemisTranslate }}
+ + + + + + + + + + + + + + + + + + + + + + +
#{{ 'artemisApp.examManagement.plagiarismCasesOverview.exerciseName' | artemisTranslate }}{{ 'artemisApp.examManagement.plagiarismCasesOverview.numberOfResults' | artemisTranslate }}{{ 'artemisApp.examManagement.plagiarismCasesOverview.numberOfCases' | artemisTranslate }}{{ 'artemisApp.examManagement.plagiarismCasesOverview.actions' | artemisTranslate }}
{{ i + 1 }}{{ exercise.title }}{{ plagiarismResultsPerExercise.get(exercise) }}{{ plagiarismCasesPerExercise.get(exercise) }} + +
+ diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.ts b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.ts new file mode 100644 index 000000000000..bd59846cf57c --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.ts @@ -0,0 +1,36 @@ +import { Component, Input } from '@angular/core'; +import { Exercise, getExerciseUrlSegment } from 'app/entities/exercise.model'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'jhi-plagiarism-cases-overview', + templateUrl: './plagiarism-cases-overview.component.html', +}) +export class PlagiarismCasesOverviewComponent { + @Input() exercises: Exercise[]; + @Input() plagiarismCasesPerExercise: Map; + @Input() plagiarismResultsPerExercise: Map = new Map(); + @Input() anyPlagiarismCases = false; + @Input() courseId: number; + @Input() examId: number; + constructor(private router: Router) {} + + goToPlagiarismDetection(exercise: Exercise) { + const exerciseGroupId = exercise.exerciseGroup?.id; + const exerciseType = exercise.type; + this.router.navigate([ + '/course-management', + this.courseId, + 'exams', + this.examId, + 'exercise-groups', + exerciseGroupId, + getExerciseUrlSegment(exerciseType), + exercise.id, + 'plagiarism', + ]); + } + goToPlagiarismCases() { + this.router.navigate(['/course-management', this.courseId, 'exams', this.examId, 'plagiarism-cases']); + } +} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.html b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.html new file mode 100644 index 000000000000..c23fce1d8f53 --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.html @@ -0,0 +1,31 @@ +
{{ 'artemisApp.examManagement.suspiciousBehavior.title' | artemisTranslate }}
+ +

+ {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessionDescription' | artemisTranslate }} +

+ +
    +
  • + {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessionCriterionIpAddress' | artemisTranslate }} +
  • +
  • + {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessionCriterionBrowserFingerprint' | artemisTranslate }} +
  • +
+ +

+ {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.number' | artemisTranslate: { numberOfSuspiciousSessions: suspiciousSessions.length } }} +

+ + + + diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.ts b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.ts new file mode 100644 index 000000000000..28bab9654674 --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-behavior.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit } from '@angular/core'; +import { Exercise } from 'app/entities/exercise.model'; +import { SuspiciousExamSessions } from 'app/entities/exam-session.model'; +import { SuspiciousSessionsService } from 'app/exam/manage/suspicious-behavior/suspicious-sessions.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagiarism-cases.service'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { PlagiarismResultsService } from 'app/course/plagiarism-cases/shared/plagiarism-results.service'; + +@Component({ + selector: 'jhi-suspicious-behavior', + templateUrl: './suspicious-behavior.component.html', +}) +export class SuspiciousBehaviorComponent implements OnInit { + exercises: Exercise[] = []; + plagiarismCasesPerExercise: Map = new Map(); + plagiarismResultsPerExercise: Map = new Map(); + anyPlagiarismCases = false; + suspiciousSessions: SuspiciousExamSessions[] = []; + examId: number; + courseId: number; + + constructor( + private suspiciousSessionsService: SuspiciousSessionsService, + private activatedRoute: ActivatedRoute, + private plagiarismCasesService: PlagiarismCasesService, + private examService: ExamManagementService, + private plagiarismResultsService: PlagiarismResultsService, + private router: Router, + ) {} + + ngOnInit(): void { + this.examId = Number(this.activatedRoute.snapshot.paramMap.get('examId')); + this.courseId = Number(this.activatedRoute.snapshot.paramMap.get('courseId')); + this.suspiciousSessionsService.getSuspiciousSessions(this.courseId, this.examId).subscribe((res) => { + this.suspiciousSessions = res; + }); + this.examService.getExercisesWithPotentialPlagiarismForExam(this.courseId, this.examId).subscribe((res) => { + this.exercises = res; + this.retrievePlagiarismCases(); + }); + } + + private retrievePlagiarismCases = () => { + this.exercises.forEach((exercise) => { + this.plagiarismCasesService.getNumberOfPlagiarismCasesForExercise(exercise).subscribe((res) => { + this.plagiarismCasesPerExercise.computeIfAbsent(exercise, () => res); + if (res > 0) this.anyPlagiarismCases = true; + }); + this.plagiarismResultsService.getNumberOfPlagiarismResultsForExercise(exercise.id!).subscribe((res) => { + this.plagiarismResultsPerExercise.computeIfAbsent(exercise, () => res); + }); + }); + }; + + goToSuspiciousSessions() { + this.router.navigate(['/course-management', this.courseId, 'exams', this.examId, 'suspicious-behavior', 'suspicious-sessions'], { + state: { suspiciousSessions: this.suspiciousSessions }, + }); + } +} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.html b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.html new file mode 100644 index 000000000000..6fa2b911883a --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.html @@ -0,0 +1,9 @@ +
{{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.title' | artemisTranslate }}
+ + +

+ {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.case' | artemisTranslate }} + {{ i + 1 }} +

+ +
diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.ts b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.ts new file mode 100644 index 000000000000..e9d177ae2a7d --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; +import { SuspiciousExamSessions } from 'app/entities/exam-session.model'; +import { cloneDeep } from 'lodash-es'; + +@Component({ + selector: 'jhi-suspicious-sessions-overview', + templateUrl: './suspicious-sessions-overview.component.html', +}) +export class SuspiciousSessionsOverviewComponent implements OnInit { + suspiciousSessions: SuspiciousExamSessions[] = []; + + ngOnInit(): void { + this.suspiciousSessions = cloneDeep(history.state.suspiciousSessions); + } +} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions.service.ts b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions.service.ts new file mode 100644 index 000000000000..d80b20395eeb --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions.service.ts @@ -0,0 +1,15 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { SuspiciousExamSessions } from 'app/entities/exam-session.model'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class SuspiciousSessionsService { + constructor(private http: HttpClient) {} + + getSuspiciousSessions(courseId: number, examId: number): Observable { + return this.http.get(`api/courses/${courseId}/exams/${examId}/suspicious-sessions`); + } +} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.html b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.html new file mode 100644 index 000000000000..f233b06da2cd --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.html @@ -0,0 +1,39 @@ +

+ {{ 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.reasons' | artemisTranslate }} + + + {{ mapEnumToTranslationString(reason) | artemisTranslate }} + , + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ 'artemisApp.examSession.id' | artemisTranslate }}{{ 'artemisApp.examSession.browserFingerprintHash' | artemisTranslate }}{{ 'artemisApp.examSession.ipAddress' | artemisTranslate }}{{ 'artemisApp.examSession.createdDate' | artemisTranslate }}{{ 'artemisApp.examSession.studentExamId' | artemisTranslate }}{{ 'artemisApp.examSession.userLogin' | artemisTranslate }}
{{ session.id }}{{ session.browserFingerprintHash }}{{ session.ipAddress }}{{ session.createdDate | artemisDate }} + + {{ session.studentExam!.id }} + + {{ session.studentExam?.user?.login }}
+
diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.scss b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.scss new file mode 100644 index 000000000000..53e4ffb8b56b --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.scss @@ -0,0 +1,3 @@ +.suspicious { + color: var(--bs-warning); +} diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.ts b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.ts new file mode 100644 index 000000000000..a901c0b65150 --- /dev/null +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component.ts @@ -0,0 +1,38 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { SuspiciousExamSessions, SuspiciousSessionReason } from 'app/entities/exam-session.model'; +import { StudentExam } from 'app/entities/student-exam.model'; + +@Component({ + selector: 'jhi-suspicious-sessions', + templateUrl: './suspicious-sessions.component.html', + styleUrls: ['./suspicious-sessions.component.scss'], +}) +export class SuspiciousSessionsComponent implements OnInit { + @Input() suspiciousSessions: SuspiciousExamSessions; + suspiciousFingerprint = false; + suspiciousIpAddress = false; + ngOnInit(): void { + this.suspiciousFingerprint = this.isSuspiciousFor(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT); + this.suspiciousIpAddress = this.isSuspiciousFor(SuspiciousSessionReason.SAME_IP_ADDRESS); + } + + getStudentExamLink(studentExam: StudentExam) { + const studentExamId = studentExam.id; + const courseId = studentExam.exam?.course?.id; + const examId = studentExam.exam?.id; + return `/course-management/${courseId}/exams/${examId}/student-exams/${studentExamId}`; + } + + private isSuspiciousFor(reason: SuspiciousSessionReason) { + return this.suspiciousSessions.examSessions.some((session) => session.suspiciousReasons.includes(reason)); + } + + mapEnumToTranslationString(reason: SuspiciousSessionReason) { + switch (reason) { + case SuspiciousSessionReason.SAME_IP_ADDRESS: + return 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.sameIpAddress'; + case SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT: + return 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.sameBrowserFingerprint'; + } + } +} diff --git a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts index c7b53bc242ce..7b6b46f82929 100644 --- a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts +++ b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts @@ -327,6 +327,8 @@ export class NavbarComponent implements OnInit, OnDestroy { privacy_statement: 'artemisApp.legal.privacyStatement.title', imprint: 'artemisApp.legal.imprint.title', edit_build_plan: 'artemisApp.programmingExercise.buildPlanEditor', + suspicious_behavior: 'artemisApp.examManagement.suspiciousBehavior.title', + suspicious_sessions: 'artemisApp.examManagement.suspiciousBehavior.suspiciousSessions.title', }; studentPathBreadcrumbTranslations = { @@ -477,6 +479,12 @@ export class NavbarComponent implements OnInit, OnDestroy { case 'import-from-file': this.addTranslationAsCrumb(currentPath, 'import-from-file'); break; + case 'suspicious-behavior': + this.addTranslationAsCrumb(currentPath, 'suspicious-behavior'); + break; + case 'suspicious-sessions': + this.addTranslationAsCrumb(currentPath, 'suspicious-sessions'); + break; case 'example-submissions': // Special case: Don't display the ID here but the name directly (clicking the ID wouldn't work) this.addTranslationAsCrumb(currentPath, 'example-submission-editor'); diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index ad359dee8ff0..b6dd4a97e237 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -287,7 +287,9 @@ "browserFingerprintHash": "Browser-Fingerabdruck", "instanceId": "Browserinstanz-ID", "ipAddress": "IP-Adresse", - "createdDate": "Erstellungsdatum" + "createdDate": "Erstellungsdatum", + "studentExamId": "Klausur-ID", + "userLogin": "Benutzername" }, "studentExams": { "title": "Klausuren", @@ -566,7 +568,8 @@ "examReview": "Planung der Klausureinsicht ", "resolveComplaints": "Auflösung aller Beschwerden", "testRun": "Abhalten eines Testdurchlaufs", - "exportResults": "Exportieren der Klausurergebnisse" + "exportResults": "Exportieren der Klausurergebnisse", + "suspiciousBehavior": "Überprüfung von verdächtigem Verhalten" }, "descriptionItem": { "register": "Studierende können auf drei verschiedene Arten registriert werden: einzeln, per .csv Datei oder es können alle zum Kurs der Klausur angemeldeten Studierenden auf einmal angemeldet werden.", @@ -580,7 +583,8 @@ "examReview": "Alle relevanten Daten müssen in der Klausur spezifiziert sein.", "resolveComplaints": "Beschwerden können im Bewertungs-Dashboard bearbeitet werden.", "testRun": "Probiere verschiedene Kombinationen von Übungsvarianten aus Sicht der Studierenden aus.", - "exportResults": "Die Klausurergebnisse können in Form einer .csv Datei exportiert werden." + "exportResults": "Die Klausurergebnisse können in Form einer .csv Datei exportiert werden.", + "suspiciousBehavior": "Verdächtiges Verhalten und Plagiatsfälle können in diesem Dashboard eingesehen werden." }, "textitems": { "leastoneexercisegroup": "Mindestens eine Aufgabengruppe", @@ -613,6 +617,31 @@ "numberParticipants": "Teilnehmende", "variants": "Varianten" } + }, + "suspiciousBehavior": { + "title": "Verdächtiges Verhalten", + "examSessionDescription": "Bei jedem Eintritt in eine Klausur wird für den Studierenden eine Sitzung erstellt. Diese Sitzung ist die Kombination aus IP-Adresse, Sitzungsschlüssel, User Agent, Browser-Fingerabdruck, Browserinstanz-ID und einem dazugehörigen Zeitstempel.", + "suspiciousSessionDescription": "Eine Sitzung wird als verdächtig eingestuft, wenn sie eine der folgenden Kriterien erfüllt:", + "suspiciousSessionCriterionIpAddress": "Es gibt mehrere Sitzungen mit derselben IP-Adresse, die nicht derselben Studierendenklausur zugeordnet sind", + "suspiciousSessionCriterionBrowserFingerprint": "Es gibt mehrere Sitzungen mit demselben Browser-Fingerabdruck, die nicht derselben Studierendenklausur zugeordnet sind", + "suspiciousSessions": { + "title": "Verdächtige Sitzungen", + "view": "Verdächtige Sitzungen anzeigen", + "number": "Diese Klausur hat {{ numberOfSuspiciousSessions }} verdächtige Fälle.", + "reasons": "Verdächtig aufgrund von:", + "case": "Fall", + "sameIpAddress": "Gleiche IP-Adresse", + "sameBrowserFingerprint": "Gleicher Browser-Fingerabdruck" + } + }, + "plagiarismCasesOverview": { + "title": "Plagiatsfälle Übersicht", + "exerciseName": "Aufgabe", + "numberOfResults": "Anzahl potentieller Plagiate", + "numberOfCases": "Anzahl Plagiatsfälle", + "actions": "Aktionen", + "viewResultsOrRunDetection": "Ergebnisse anzeigen oder Plagiatsprüfung starten", + "viewCases": "Fälle anzeigen" } }, "studentExamDetail": { diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 1826194d0747..68107a2d924c 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -290,7 +290,9 @@ "browserFingerprintHash": "Browser Fingerprint", "instanceId": "Browser Instance ID", "ipAddress": "IP Address", - "createdDate": "Created Date" + "createdDate": "Created Date", + "studentExamId": "Exam ID", + "userLogin": "Login" }, "studentExams": { "title": "Student exams", @@ -568,7 +570,8 @@ "examReview": "Plan the exam review", "resolveComplaints": "Resolve all complaints", "testRun": "Conduct a test run", - "exportResults": "Export the exam results" + "exportResults": "Export the exam results", + "suspiciousBehavior": "Check for suspicious behaviour" }, "descriptionItem": { "register": "You can register students for the exam in three different ways: via a .csv file, manually, or by importing all students registered in the course.", @@ -582,7 +585,8 @@ "examReview": "Make sure to have all dates specified correctly.", "resolveComplaints": "Complaints can be resolved from the Assessment Dashboard.", "testRun": "Try out different combinations of exercise variants from the student perspective.", - "exportResults": "Exports the exam results as a .csv file." + "exportResults": "Exports the exam results as a .csv file.", + "suspiciousBehavior": "Check for suspicious behaviour and plagiarism in the suspicious behavior dashboard." }, "textitems": { "leastoneexercisegroup": "At least one exercise group", @@ -615,6 +619,31 @@ "numberParticipants": "Participants", "variants": "Variants" } + }, + "suspiciousBehavior": { + "title": "Suspicious Behavior", + "examSessionDescription": "Whenever a student enters a exam, a session is created for the student. This session is the combination of IP address, session token, user agent, browser fingerprint, browser instance ID and a timestamp.", + "suspiciousSessionDescription": "A session will be flagged as suspicious if it fulfills one of the following criteria:", + "suspiciousSessionCriterionIpAddress": "Multiple sessions with the same IP address belong to different students' exams", + "suspiciousSessionCriterionBrowserFingerprint": "Multiple sessions with the same browser fingerprint belong to different students' exams", + "suspiciousSessions": { + "title": "Suspicious Sessions", + "view": "View Suspicious Sessions", + "number": "This exam has {{ numberOfSuspiciousSessions }} cases.", + "reasons": "Suspicious because of:", + "case": "Case", + "sameIpAddress": "Same IP address", + "sameBrowserFingerprint": "Same browser fingerprint" + } + }, + "plagiarismCasesOverview": { + "title": "Plagiarism Cases Overview", + "exerciseName": "Exercise", + "numberOfResults": "Number of potential plagiarism cases", + "numberOfCases": "Number of plagiarism cases", + "actions": "Actions", + "viewResultsOrRunDetection": "View Results or Run Detection", + "viewCases": "View Cases" } }, "studentExamDetail": { diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index 0ec30ac8cda0..bd1b3d4ee8f3 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -3375,6 +3375,102 @@ void testImportExamWithExercises_preCheckFailed() throws Exception { .andExpect(result -> assertThat(result.getResolvedException()).hasMessage("Exam contains programming exercise(s) with invalid short name.")); } + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetExercisesWithPotentialPlagiarismAsTutor_forbidden() throws Exception { + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/exercises-with-potential-plagiarism", HttpStatus.FORBIDDEN, List.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetSuspiciousSessionsAsTutor_forbidden() throws Exception { + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/suspicious-sessions", HttpStatus.FORBIDDEN, Set.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetExercisesWithPotentialPlagiarismAsInstructorNotInCourse_forbidden() throws Exception { + courseUtilService.updateCourseGroups("abc", course1, ""); + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/exercises-with-potential-plagiarism", HttpStatus.FORBIDDEN, List.class); + courseUtilService.updateCourseGroups(TEST_PREFIX, course1, ""); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetSuspiciousSessionsAsInstructorNotInCourse_forbidden() throws Exception { + courseUtilService.updateCourseGroups("abc", course1, ""); + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/suspicious-sessions", HttpStatus.FORBIDDEN, Set.class); + courseUtilService.updateCourseGroups(TEST_PREFIX, course1, ""); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetExercisesWithPotentialPlagiarismAsInstructor() throws Exception { + Exam exam = examUtilService.addExam(course1); + List expectedExercises = new ArrayList<>(); + exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, true, true); + exam.getExerciseGroups().forEach(exerciseGroup -> exerciseGroup.getExercises().forEach(exercise -> { + if (exercise.getExerciseType() != ExerciseType.QUIZ && exercise.getExerciseType() != ExerciseType.FILE_UPLOAD) { + var courseDTO = new CourseWithIdDTO(course1.getId()); + var examDTO = new ExamWithIdAndCourseDTO(exercise.getExerciseGroup().getExam().getId(), courseDTO); + var exerciseGroupDTO = new ExerciseGroupWithIdAndExamDTO(exercise.getExerciseGroup().getId(), examDTO); + expectedExercises.add(new ExerciseForPlagiarismCasesOverviewDTO(exercise.getId(), exercise.getTitle(), exercise.getType(), exerciseGroupDTO)); + } + })); + + List exercises = request.getList( + "/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercises-with-potential-plagiarism", HttpStatus.OK, ExerciseForPlagiarismCasesOverviewDTO.class); + assertThat(exercises).hasSize(5); + assertThat(exercises).containsExactlyInAnyOrderElementsOf(expectedExercises); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetSuspiciousSessionsAsInstructor() throws Exception { + final String userAgent1 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15"; + final String ipAddress1 = "192.0.2.235"; + final String browserFingerprint1 = "5b2cc274f6eaf3a71647e1f85358ce32"; + final String sessionToken1 = "abc"; + final String userAgent2 = "Mozilla/5.0 (Linux; Android 10; SM-G960F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36"; + final String ipAddress2 = "172.168.0.0"; + final String browserFingerprint2 = "5b2cc274f6eaf3a71647e1f85358ce31"; + final String sessionToken2 = "def"; + Exam exam = examUtilService.addExam(course1); + StudentExam studentExam = examUtilService.addStudentExamWithUser(exam, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + StudentExam studentExam2 = examUtilService.addStudentExamWithUser(exam, userUtilService.getUserByLogin(TEST_PREFIX + "student2")); + ExamSession firstExamSessionStudent1 = examUtilService.addExamSessionToStudentExam(studentExam, sessionToken1, ipAddress1, browserFingerprint1, "instanceId", userAgent1); + examUtilService.addExamSessionToStudentExam(studentExam2, sessionToken2, ipAddress2, browserFingerprint2, "instance2Id", userAgent2); + ExamSession secondExamSessionStudent1 = examUtilService.addExamSessionToStudentExam(studentExam2, sessionToken1, ipAddress1, browserFingerprint1, "instanceId", userAgent1); + Set suspiciousReasons = Set.of(SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT, SuspiciousSessionReason.SAME_IP_ADDRESS); + firstExamSessionStudent1.setSuspiciousReasons(suspiciousReasons); + secondExamSessionStudent1.setSuspiciousReasons(suspiciousReasons); + Set suspiciousSessionTuples = request.getSet("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/suspicious-sessions", + HttpStatus.OK, SuspiciousExamSessionsDTO.class); + assertThat(suspiciousSessionTuples).hasSize(1); + var suspiciousSessions = suspiciousSessionTuples.stream().findFirst().get(); + assertThat(suspiciousSessions.examSessions()).hasSize(2); + assertThat(suspiciousSessions.examSessions()).usingRecursiveFieldByFieldElementComparatorIgnoringFields("createdDate") + .containsExactlyInAnyOrderElementsOf(createExpectedDTOs(firstExamSessionStudent1, secondExamSessionStudent1)); + } + + private Set createExpectedDTOs(ExamSession session1, ExamSession session2) { + var expectedDTOs = new HashSet(); + var firstStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session1.getStudentExam().getId(), + new ExamWithIdAndCourseDTO(session1.getStudentExam().getExam().getId(), new CourseWithIdDTO(session1.getStudentExam().getExam().getCourse().getId())), + new UserWithIdAndLoginDTO(session1.getStudentExam().getUser().getId(), session1.getStudentExam().getUser().getLogin())); + var secondStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session2.getStudentExam().getId(), + new ExamWithIdAndCourseDTO(session2.getStudentExam().getExam().getId(), new CourseWithIdDTO(session2.getStudentExam().getExam().getCourse().getId())), + new UserWithIdAndLoginDTO(session2.getStudentExam().getUser().getId(), session2.getStudentExam().getUser().getLogin())); + var firstExamSessionDTO = new ExamSessionDTO(session1.getId(), session1.getBrowserFingerprintHash(), session1.getIpAddress(), session1.getSuspiciousReasons(), + session1.getCreatedDate(), firstStudentExamDTO); + var secondExamSessionDTO = new ExamSessionDTO(session2.getId(), session2.getBrowserFingerprintHash(), session2.getIpAddress(), session2.getSuspiciousReasons(), + session2.getCreatedDate(), secondStudentExamDTO); + expectedDTOs.add(firstExamSessionDTO); + expectedDTOs.add(secondExamSessionDTO); + return expectedDTOs; + + } + private int prepareExerciseStart(Exam exam) throws Exception { return ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java index 2258a6e32f17..10932a3ba61e 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java @@ -100,6 +100,9 @@ public class ExamUtilService { @Autowired private ParticipationUtilService participationUtilService; + @Autowired + private ExamSessionRepository examSessionRepository; + public Course createCourseWithExamAndExerciseGroupAndExercises(User user, ZonedDateTime visible, ZonedDateTime start, ZonedDateTime end) { Course course = courseUtilService.createCourse(); Exam exam = addExamWithUser(course, user, false, visible, start, end); @@ -442,6 +445,32 @@ public StudentExam addStudentExamWithUser(Exam exam, User user) { return studentExam; } + /** + * Adds an exam session with the given parameters to the given student exam, associates the exam session with the given student exam and saves both entities in the database. + * + * @param studentExam the student exam to which the exam session should be added + * @param sessionToken the session token of the exam session + * @param ipAddress the IP address of the exam session + * @param browserFingerprint the browser fingerprint hash of the exam session + * @param instanceId the instance id of the exam session + * @param userAgent the user agent of the exam session + * @return the exam session that was added to the student exam + */ + public ExamSession addExamSessionToStudentExam(StudentExam studentExam, String sessionToken, String ipAddress, String browserFingerprint, String instanceId, String userAgent) { + ExamSession examSession = new ExamSession(); + examSession.setSessionToken(sessionToken); + examSession.setIpAddress(ipAddress); + examSession.setBrowserFingerprintHash(browserFingerprint); + examSession.setInstanceId(instanceId); + examSession.setStudentExam(studentExam); + examSession.setUserAgent(userAgent); + examSession.setStudentExam(studentExam); + examSession = examSessionRepository.save(examSession); + studentExam = studentExam.addExamSession(examSession); + studentExamRepository.save(studentExam); + return examSession; + } + public StudentExam addStudentExamForActiveExamWithUser(String user) { Course course = courseUtilService.addEmptyCourse(); User studentUser = userUtilService.getUserByLogin(user); diff --git a/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismCaseIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismCaseIntegrationTest.java index defb4494d5a8..1e31ee3e488a 100644 --- a/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismCaseIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismCaseIntegrationTest.java @@ -14,6 +14,7 @@ import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.metis.Post; @@ -54,6 +55,9 @@ class PlagiarismCaseIntegrationTest extends AbstractSpringIntegrationBambooBitbu @Autowired private ExerciseUtilService exerciseUtilService; + @Autowired + private CourseUtilService courseUtilService; + private Course course; private TextExercise textExercise; @@ -386,4 +390,25 @@ void testPlagiarismCase_getStudents() throws Exception { assertThat(teamPlagiarismCase.getStudents()).as("should get the set of all students in the team if it is a team plagiarism case").isEqualTo(teamStudents); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testNumberOfPlagiarismCasesForExercise_instructor_correct() throws Exception { + var cases = request.get("/api/courses/" + course.getId() + "/exercises/" + textExercise.getId() + "/plagiarism-cases-count", HttpStatus.OK, Long.class); + assertThat(cases).isEqualTo(5); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testNumberOfPlagiarismResultsForExercise_tutor_forbidden() throws Exception { + request.get("/api/courses/" + course.getId() + "/exercises/" + textExercise.getId() + "/plagiarism-cases-count", HttpStatus.FORBIDDEN, Long.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testNumberOfPlagiarismResultsForExercise_instructorNotInCourse_forbidden() throws Exception { + courseUtilService.updateCourseGroups("abc", course, ""); + request.get("/api/courses/" + course.getId() + "/exercises/" + textExercise.getId() + "/plagiarism-cases-count", HttpStatus.FORBIDDEN, Long.class); + courseUtilService.updateCourseGroups(TEST_PREFIX, course, ""); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismIntegrationTest.java index 041ecdb28014..b93cd24f6567 100644 --- a/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/plagiarism/PlagiarismIntegrationTest.java @@ -12,14 +12,18 @@ import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.domain.enumeration.Language; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismCase; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismComparison; import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismSubmission; import de.tum.in.www1.artemis.domain.plagiarism.text.TextPlagiarismResult; import de.tum.in.www1.artemis.domain.plagiarism.text.TextSubmissionElement; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; +import de.tum.in.www1.artemis.participation.ParticipationFactory; +import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.TextExerciseRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismComparisonRepository; @@ -49,6 +53,12 @@ class PlagiarismIntegrationTest extends AbstractSpringIntegrationBambooBitbucket @Autowired private TextExerciseUtilService textExerciseUtilService; + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ParticipationUtilService participationUtilService; + private Course course; private TextExercise textExercise; @@ -65,14 +75,20 @@ void initTestCase() { course = textExerciseUtilService.addCourseWithOneFinishedTextExercise(); textExercise = textExerciseRepository.findByCourseIdWithCategories(course.getId()).get(0); textPlagiarismResult = textExerciseUtilService.createTextPlagiarismResultForExercise(textExercise); + var textSubmission = ParticipationFactory.generateTextSubmission("", Language.GERMAN, true); + var submission1 = participationUtilService.addSubmission(textExercise, textSubmission, TEST_PREFIX + "student1"); + var submission2 = participationUtilService.addSubmission(textExercise, textSubmission, TEST_PREFIX + "student2"); + var submission3 = participationUtilService.addSubmission(textExercise, textSubmission, TEST_PREFIX + "student3"); plagiarismComparison1 = new PlagiarismComparison<>(); plagiarismComparison1.setPlagiarismResult(textPlagiarismResult); plagiarismComparison1.setStatus(CONFIRMED); var plagiarismSubmissionA1 = new PlagiarismSubmission(); plagiarismSubmissionA1.setStudentLogin(TEST_PREFIX + "student1"); + plagiarismSubmissionA1.setSubmissionId(submission1.getId()); var plagiarismSubmissionB1 = new PlagiarismSubmission(); plagiarismSubmissionB1.setStudentLogin(TEST_PREFIX + "student2"); + plagiarismSubmissionB1.setSubmissionId(submission2.getId()); plagiarismComparison1.setSubmissionA(plagiarismSubmissionA1); plagiarismComparison1.setSubmissionB(plagiarismSubmissionB1); plagiarismComparison1 = plagiarismComparisonRepository.save(plagiarismComparison1); @@ -82,8 +98,10 @@ void initTestCase() { plagiarismComparison2.setStatus(NONE); var plagiarismSubmissionA2 = new PlagiarismSubmission(); plagiarismSubmissionA2.setStudentLogin(TEST_PREFIX + "student2"); + plagiarismSubmissionA2.setSubmissionId(submission2.getId()); var plagiarismSubmissionB2 = new PlagiarismSubmission(); plagiarismSubmissionB2.setStudentLogin(TEST_PREFIX + "student3"); + plagiarismSubmissionB2.setSubmissionId(submission3.getId()); plagiarismComparison2.setSubmissionA(plagiarismSubmissionA2); plagiarismComparison2.setSubmissionB(plagiarismSubmissionB2); plagiarismComparison2 = plagiarismComparisonRepository.save(plagiarismComparison2); @@ -208,4 +226,25 @@ void testDeletePlagiarismComparisons_instructor_deleteAll() throws Exception { var result = plagiarismResultRepository.findFirstByExerciseIdOrderByLastModifiedDateDescOrNull(textExercise.getId()); assertThat(result).isNull(); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testNumberOfPlagiarismResultsForExercise_instructor_correct() throws Exception { + var results = request.get("/api/exercises/" + textExercise.getId() + "/potential-plagiarism-count", HttpStatus.OK, Long.class); + assertThat(results).isEqualTo(4); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testNumberOfPlagiarismResultsForExercise_tutor_forbidden() throws Exception { + request.get("/api/exercises/" + textExercise.getId() + "/potential-plagiarism-count", HttpStatus.FORBIDDEN, Long.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testNumberOfPlagiarismResultsForExercise_instructorNotInCourse_forbidden() throws Exception { + courseUtilService.updateCourseGroups("abc", course, ""); + request.get("/api/exercises/" + textExercise.getId() + "/potential-plagiarism-count", HttpStatus.FORBIDDEN, Long.class); + courseUtilService.updateCourseGroups(TEST_PREFIX, course, ""); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java index 0f88e045075b..551f2422811b 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java @@ -9,10 +9,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.function.Function; import javax.annotation.Nullable; @@ -617,6 +614,10 @@ public List getList(String path, HttpStatus expectedStatus, Class list return getList(path, expectedStatus, listElementType, new LinkedMultiValueMap<>()); } + public Set getSet(String path, HttpStatus expectedStatus, Class setElementType) throws Exception { + return getSet(path, expectedStatus, setElementType, new LinkedMultiValueMap<>()); + } + public SearchResultPageDTO getSearchResult(String path, HttpStatus expectedStatus, Class searchElementType, MultiValueMap params) throws Exception { MvcResult res = mvc.perform(MockMvcRequestBuilders.get(new URI(path)).params(params)).andExpect(status().is(expectedStatus.value())).andReturn(); restoreSecurityContext(); @@ -645,6 +646,20 @@ public List getList(String path, HttpStatus expectedStatus, Class list return mapper.readValue(res.getResponse().getContentAsString(), mapper.getTypeFactory().constructCollectionType(List.class, listElementType)); } + public Set getSet(String path, HttpStatus expectedStatus, Class setElementType, MultiValueMap params) throws Exception { + MvcResult res = mvc.perform(MockMvcRequestBuilders.get(new URI(path)).params(params)).andExpect(status().is(expectedStatus.value())).andReturn(); + restoreSecurityContext(); + + if (!expectedStatus.is2xxSuccessful()) { + if (res.getResponse().getContentType() != null && !res.getResponse().getContentType().equals("application/problem+json")) { + assertThat(res.getResponse().getContentAsString()).isNullOrEmpty(); + } + return null; + } + + return mapper.readValue(res.getResponse().getContentAsString(), mapper.getTypeFactory().constructCollectionType(Set.class, setElementType)); + } + public Map getMap(String path, HttpStatus expectedStatus, Class keyType, Class valueType) throws Exception { return getMap(path, expectedStatus, keyType, valueType, new LinkedMultiValueMap<>()); } diff --git a/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts b/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts index fb7155733a6d..8370f024633e 100644 --- a/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts @@ -13,6 +13,9 @@ import { ExamScoreDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; import { StatsForDashboard } from 'app/course/dashboards/stats-for-dashboard.model'; import { TextSubmission } from 'app/entities/text-submission.model'; import { AccountService } from 'app/core/auth/account.service'; +import { TextExercise } from 'app/entities/text-exercise.model'; +import { ModelingExercise, UMLDiagramType } from 'app/entities/modeling-exercise.model'; +import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; describe('Exam Management Service Tests', () => { let service: ExamManagementService; @@ -693,4 +696,17 @@ describe('Exam Management Service Tests', () => { expect(accountServiceSpy).toHaveBeenCalledOnce(); expect(accountServiceSpy).toHaveBeenCalledWith(course); })); + + it('should make GET request to retrieve exam exercises that potentially have plagiarism cases', fakeAsync(() => { + const exerciseGroup = new ExerciseGroup(); + const textExercise = new TextExercise(undefined, exerciseGroup); + const modelingExercise = new ModelingExercise(UMLDiagramType.ActivityDiagram, course, exerciseGroup); + const programmingExercise = new ProgrammingExercise(undefined, exerciseGroup); + + const exercises = [textExercise, modelingExercise, programmingExercise]; + service.getExercisesWithPotentialPlagiarismForExam(1, 1).subscribe((resp) => expect(resp).toEqual(exercises)); + const req = httpMock.expectOne({ method: 'GET', url: 'api/courses/1/exams/1/exercises-with-potential-plagiarism' }); + req.flush(exercises); + tick(); + })); }); diff --git a/src/test/javascript/spec/component/exam/manage/suspicious-behavior/plagiarism-cases-overview.component.spec.ts b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/plagiarism-cases-overview.component.spec.ts new file mode 100644 index 000000000000..d32c2a85a48f --- /dev/null +++ b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/plagiarism-cases-overview.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { PlagiarismCasesOverviewComponent } from 'app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockPipe } from 'ng-mocks'; +import { MockRouter } from '../../../../helpers/mocks/mock-router'; +import { Exercise } from 'app/entities/exercise.model'; + +describe('PlagiarismCasesOverviewComponent', () => { + let component: PlagiarismCasesOverviewComponent; + let fixture: ComponentFixture; + let router: Router; + const exercise1 = { + type: 'text', + id: 1, + exerciseGroup: { + id: 1, + exam: { + id: 1, + course: { + id: 1, + }, + }, + }, + } as Exercise; + const exercise2 = { + type: 'modeling', + id: 2, + exerciseGroup: { + id: 2, + exam: { + id: 2, + course: { + id: 2, + }, + }, + }, + } as Exercise; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [PlagiarismCasesOverviewComponent, MockPipe(ArtemisTranslatePipe)], + providers: [ + { + provide: Router, + useClass: MockRouter, + }, + ], + }); + fixture = TestBed.createComponent(PlagiarismCasesOverviewComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + component.courseId = 1; + component.examId = 2; + component.exercises = [exercise1, exercise2]; + component.plagiarismCasesPerExercise = new Map([ + [exercise1, 0], + [exercise2, 1], + ]); + component.plagiarismResultsPerExercise = new Map([ + [exercise1, 2], + [exercise2, 4], + ]); + fixture.detectChanges(); + }); + + it('should navigate to plagiarism cases on view plagiarism cases click', () => { + component.anyPlagiarismCases = true; + fixture.detectChanges(); + const viewCasesButton = fixture.debugElement.nativeElement.querySelector('#view-plagiarism-cases-btn'); + viewCasesButton.click(); + expect(router.navigate).toHaveBeenCalledWith(['/course-management', 1, 'exams', 2, 'plagiarism-cases']); + }); + + it('should not show view cases button if no cases exist', () => { + component.anyPlagiarismCases = false; + fixture.detectChanges(); + const viewCasesButton = fixture.debugElement.nativeElement.querySelector('#view-plagiarism-cases-btn'); + expect(viewCasesButton).toBeNull(); + }); + + it('should navigate to plagiarism results on view plagiarism results click', () => { + const viewCasesButtonExercise1 = fixture.debugElement.nativeElement.querySelector('#view-plagiarism-results-btn-0'); + viewCasesButtonExercise1.click(); + expect(router.navigate).toHaveBeenCalledWith(['/course-management', 1, 'exams', 2, 'exercise-groups', 1, 'text-exercises', 1, 'plagiarism']); + const viewCasesButtonExercise2 = fixture.debugElement.nativeElement.querySelector('#view-plagiarism-results-btn-1'); + viewCasesButtonExercise2.click(); + expect(router.navigate).toHaveBeenCalledWith(['/course-management', 1, 'exams', 2, 'exercise-groups', 2, 'modeling-exercises', 2, 'plagiarism']); + }); +}); diff --git a/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-behavior.component.spec.ts b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-behavior.component.spec.ts new file mode 100644 index 000000000000..7ec62ecec775 --- /dev/null +++ b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-behavior.component.spec.ts @@ -0,0 +1,144 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; + +import { SuspiciousBehaviorComponent } from 'app/exam/manage/suspicious-behavior/suspicious-behavior.component'; +import { SuspiciousSessionsService } from 'app/exam/manage/suspicious-behavior/suspicious-sessions.service'; +import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagiarism-cases.service'; +import { PlagiarismResultsService } from 'app/course/plagiarism-cases/shared/plagiarism-results.service'; +import { of } from 'rxjs'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { PlagiarismCasesOverviewComponent } from 'app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component'; +import { ButtonComponent } from 'app/shared/components/button.component'; +import { MockRouterLinkDirective } from '../../../../helpers/mocks/directive/mock-router-link.directive'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { SuspiciousExamSessions, SuspiciousSessionReason } from 'app/entities/exam-session.model'; +import { MockRouter } from '../../../../helpers/mocks/mock-router'; + +describe('SuspiciousBehaviorComponent', () => { + let component: SuspiciousBehaviorComponent; + let fixture: ComponentFixture; + const route = { snapshot: { paramMap: convertToParamMap({ courseId: 1, examId: 2 }) } } as unknown as ActivatedRoute; + let suspiciousSessionService: SuspiciousSessionsService; + let plagiarismCasesService: PlagiarismCasesService; + let plagiarismResultsService: PlagiarismResultsService; + let examService: ExamManagementService; + let router: Router; + const exercise1 = { + id: 1, + exerciseGroup: { + id: 1, + exam: { + id: 1, + course: { + id: 1, + }, + }, + }, + } as Exercise; + const exercise2 = { + id: 2, + exerciseGroup: { + id: 2, + exam: { + id: 2, + course: { + id: 2, + }, + }, + }, + } as Exercise; + + const suspiciousSessions = { + examSessions: [ + { + id: 1, + ipAddress: '192.168.0.0', + browserFingerprintHash: 'abc', + suspiciousReasons: [SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT], + }, + { + id: 2, + suspiciousReasons: [SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT], + ipAddress: '192.168.0.0', + browserFingerprintHash: 'abc', + }, + ], + } as SuspiciousExamSessions; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MockRouterLinkDirective], + declarations: [SuspiciousBehaviorComponent, MockPipe(ArtemisTranslatePipe), MockComponent(PlagiarismCasesOverviewComponent), MockComponent(ButtonComponent)], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useClass: MockRouter }, + ], + }); + fixture = TestBed.createComponent(SuspiciousBehaviorComponent); + component = fixture.componentInstance; + suspiciousSessionService = TestBed.inject(SuspiciousSessionsService); + plagiarismCasesService = TestBed.inject(PlagiarismCasesService); + plagiarismResultsService = TestBed.inject(PlagiarismResultsService); + examService = TestBed.inject(ExamManagementService); + router = TestBed.inject(Router); + + fixture.detectChanges(); + }); + + it('should set course and exam id onInit', () => { + component.ngOnInit(); + expect(component.courseId).toBe(1); + expect(component.examId).toBe(2); + }); + + it('should retrieve suspicious sessions onInit', () => { + const suspiciousSessionsServiceSpy = jest.spyOn(suspiciousSessionService, 'getSuspiciousSessions').mockReturnValue(of([suspiciousSessions])); + component.ngOnInit(); + expect(suspiciousSessionsServiceSpy).toHaveBeenCalledOnce(); + expect(suspiciousSessionsServiceSpy).toHaveBeenCalledWith(1, 2); + expect(component.suspiciousSessions).toEqual([suspiciousSessions]); + }); + + it('should navigate to suspicious sessions on click', () => { + const routerSpy = jest.spyOn(router, 'navigate'); + component.suspiciousSessions = [suspiciousSessions]; + fixture.detectChanges(); + fixture.debugElement.nativeElement.querySelector('#view-sessions-btn').click(); + expect(routerSpy).toHaveBeenCalledOnce(); + expect(routerSpy).toHaveBeenCalledWith(['/course-management', 1, 'exams', 2, 'suspicious-behavior', 'suspicious-sessions'], { + state: { suspiciousSessions: [suspiciousSessions] }, + }); + }); + + it('should retrieve plagiarism cases/results onInit', () => { + const examServiceSpy = jest.spyOn(examService, 'getExercisesWithPotentialPlagiarismForExam').mockReturnValue(of([exercise1, exercise2])); + const plagiarismCasesServiceSpy = jest.spyOn(plagiarismCasesService, 'getNumberOfPlagiarismCasesForExercise').mockReturnValueOnce(of(0)).mockReturnValueOnce(of(1)); + const plagiarismResultsServiceSpy = jest.spyOn(plagiarismResultsService, 'getNumberOfPlagiarismResultsForExercise').mockReturnValueOnce(of(2)).mockReturnValueOnce(of(4)); + component.ngOnInit(); + expect(examServiceSpy).toHaveBeenCalledOnce(); + expect(examServiceSpy).toHaveBeenCalledWith(1, 2); + expect(component.exercises).toEqual([exercise1, exercise2]); + expect(plagiarismCasesServiceSpy).toHaveBeenCalledTimes(2); + expect(plagiarismCasesServiceSpy).toHaveBeenCalledWith(exercise1); + expect(plagiarismCasesServiceSpy).toHaveBeenCalledWith(exercise2); + expect(component.plagiarismCasesPerExercise).toEqual( + new Map([ + [exercise1, 0], + [exercise2, 1], + ]), + ); + expect(component.anyPlagiarismCases).toBeTrue(); + expect(plagiarismResultsServiceSpy).toHaveBeenCalledTimes(2); + expect(plagiarismResultsServiceSpy).toHaveBeenCalledWith(1); + expect(plagiarismResultsServiceSpy).toHaveBeenCalledWith(2); + expect(component.plagiarismResultsPerExercise).toEqual( + new Map([ + [exercise1, 2], + [exercise2, 4], + ]), + ); + }); +}); diff --git a/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions-overview.component.spec.ts b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions-overview.component.spec.ts new file mode 100644 index 000000000000..d5d774737079 --- /dev/null +++ b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions-overview.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { SuspiciousSessionsOverviewComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; +import { SuspiciousExamSessions, SuspiciousSessionReason } from 'app/entities/exam-session.model'; +import { ArtemisTestModule } from '../../../../test.module'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { SuspiciousSessionsComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component'; + +describe('SuspiciousSessionsComponent', () => { + const suspiciousSessions = { + examSessions: [ + { + id: 1, + ipAddress: '192.168.0.0', + suspiciousReasons: [SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason], + }, + { id: 2, suspiciousReasons: [SuspiciousSessionReason, SuspiciousSessionReason.SAME_IP_ADDRESS], ipAddress: '192.168.0.0' }, + ], + } as SuspiciousExamSessions; + let component: SuspiciousSessionsOverviewComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [SuspiciousSessionsOverviewComponent, MockPipe(ArtemisTranslatePipe), MockComponent(SuspiciousSessionsComponent)], + }); + history.pushState({ suspiciousSessions: [suspiciousSessions] }, ''); + + fixture = TestBed.createComponent(SuspiciousSessionsOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should retrieve suspicious sessions onInit', fakeAsync(() => { + component.ngOnInit(); + expect(component.suspiciousSessions).toEqual([suspiciousSessions]); + })); +}); diff --git a/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.component.spec.ts b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.component.spec.ts new file mode 100644 index 000000000000..d839ccb2e3d1 --- /dev/null +++ b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SuspiciousSessionsComponent } from 'app/exam/manage/suspicious-behavior/suspicious-sessions/suspicious-sessions.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockPipe } from 'ng-mocks'; +import { StudentExam } from 'app/entities/student-exam.model'; +import { SuspiciousExamSessions, SuspiciousSessionReason } from 'app/entities/exam-session.model'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTestModule } from '../../../../test.module'; + +describe('SuspiciousSessionsComponent', () => { + let component: SuspiciousSessionsComponent; + let fixture: ComponentFixture; + const studentExam = { id: 1, exam: { id: 1, course: { id: 1 } } } as StudentExam; + const suspiciousSessions1 = { + examSessions: [ + { + suspiciousReasons: [SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT], + }, + ], + } as SuspiciousExamSessions; + + const suspiciousSessions2 = { + examSessions: [ + { + suspiciousReasons: [], + }, + ], + } as SuspiciousExamSessions; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [SuspiciousSessionsComponent, MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe)], + }); + fixture = TestBed.createComponent(SuspiciousSessionsComponent); + component = fixture.componentInstance; + component.suspiciousSessions = suspiciousSessions1; + }); + + it('should contain correct link to student exam in table cell', () => { + expect(component.getStudentExamLink(studentExam)).toBe('/course-management/1/exams/1/student-exams/1'); + }); + + it('should correctly determine suspicious reasons', () => { + component.suspiciousSessions = suspiciousSessions1; + component.ngOnInit(); + expect(component.suspiciousFingerprint).toBeTrue(); + expect(component.suspiciousIpAddress).toBeTrue(); + + component.suspiciousSessions = suspiciousSessions2; + component.ngOnInit(); + expect(component.suspiciousFingerprint).toBeFalse(); + expect(component.suspiciousIpAddress).toBeFalse(); + }); +}); diff --git a/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.service.spec.ts b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.service.spec.ts new file mode 100644 index 000000000000..58f565c32542 --- /dev/null +++ b/src/test/javascript/spec/component/exam/manage/suspicious-behavior/suspicious-sessions.service.spec.ts @@ -0,0 +1,40 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { SuspiciousSessionsService } from 'app/exam/manage/suspicious-behavior/suspicious-sessions.service'; +import { SuspiciousExamSessions, SuspiciousSessionReason } from 'app/entities/exam-session.model'; + +describe('SuspiciousSessionsService', () => { + let service: SuspiciousSessionsService; + let httpMock: HttpTestingController; + const suspiciousSessions = { + examSessions: [ + { + id: 1, + ipAddress: '192.168.0.0', + browserFingerprintHash: 'abc', + suspiciousReasons: [SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason, SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT], + }, + { + id: 2, + suspiciousReasons: [SuspiciousSessionReason, SuspiciousSessionReason.SAME_IP_ADDRESS, SuspiciousSessionReason.SAME_BROWSER_FINGERPRINT], + ipAddress: '192.168.0.0', + browserFingerprintHash: 'abc', + }, + ], + } as SuspiciousExamSessions; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(SuspiciousSessionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + it('should make GET request to retrieve suspicious submissions', fakeAsync(() => { + service.getSuspiciousSessions(1, 2).subscribe((resp) => expect(resp).toEqual(suspiciousSessions)); + const req = httpMock.expectOne({ method: 'GET', url: 'api/courses/1/exams/2/suspicious-sessions' }); + req.flush(suspiciousSessions); + tick(); + })); +}); diff --git a/src/test/javascript/spec/service/plagiarism-cases.service.spec.ts b/src/test/javascript/spec/service/plagiarism-cases.service.spec.ts index 1ba3655e1a5d..d72b46099546 100644 --- a/src/test/javascript/spec/service/plagiarism-cases.service.spec.ts +++ b/src/test/javascript/spec/service/plagiarism-cases.service.spec.ts @@ -41,7 +41,14 @@ describe('Plagiarism Cases Service', () => { status: PlagiarismStatus.CONFIRMED, } as PlagiarismComparison; - const textExercise = { id: 1, type: ExerciseType.TEXT } as TextExercise; + const textExercise = { + id: 1, + type: ExerciseType.TEXT, + course: { + id: 1, + }, + } as TextExercise; + const examTextExercise = { id: 1, type: ExerciseType.TEXT, exerciseGroup: { exam: { id: 1, course: { id: 1 } } } } as TextExercise; const plagiarismCase1 = { id: 1, @@ -155,4 +162,14 @@ describe('Plagiarism Cases Service', () => { req.flush(returnedFromService); tick(); })); + it.each([textExercise, examTextExercise])( + 'should make GET request to retrieve number of plagiarism cases', + fakeAsync(() => { + const numberOfResultsExercise = 2; + service.getNumberOfPlagiarismCasesForExercise(textExercise).subscribe((resp) => expect(resp).toEqual(numberOfResultsExercise)); + const req = httpMock.expectOne({ method: 'GET', url: 'api/courses/1/exercises/1/plagiarism-cases-count' }); + req.flush(numberOfResultsExercise); + tick(); + }), + ); }); diff --git a/src/test/javascript/spec/service/plagiarism-results.service.spec.ts b/src/test/javascript/spec/service/plagiarism-results.service.spec.ts new file mode 100644 index 000000000000..809adc604178 --- /dev/null +++ b/src/test/javascript/spec/service/plagiarism-results.service.spec.ts @@ -0,0 +1,22 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { PlagiarismResultsService } from 'app/course/plagiarism-cases/shared/plagiarism-results.service'; + +describe('Plagiarism Results Service', () => { + let service: PlagiarismResultsService; + let httpMock: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(PlagiarismResultsService); + httpMock = TestBed.inject(HttpTestingController); + }); + it('should make GET request to retrieve number of plagiarism results', fakeAsync(() => { + const numberOfResults = 2; + service.getNumberOfPlagiarismResultsForExercise(1).subscribe((resp) => expect(resp).toEqual(numberOfResults)); + const req = httpMock.expectOne({ method: 'GET', url: 'api/exercises/1/potential-plagiarism-count' }); + req.flush(numberOfResults); + tick(); + })); +}); From 0f8f21c8af154fb0418087356742ebcb9377a6aa Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Sat, 9 Sep 2023 09:11:38 +0200 Subject: [PATCH 25/47] Development: Improve Atlassian setup description (#7120) --- docs/dev/setup.rst | 2 +- docs/dev/setup/bamboo-bitbucket-jira.rst | 49 +++++++++++++----------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/docs/dev/setup.rst b/docs/dev/setup.rst index 5ccd6cc2d1c5..082a792a6be0 100644 --- a/docs/dev/setup.rst +++ b/docs/dev/setup.rst @@ -757,7 +757,7 @@ Other Docker Compose Setups .. figure:: setup/artemis-docker-file-structure.drawio.png :align: center - :target: ../../_images/artemis-docker-file-structure.drawio.png + :target: ../_images/artemis-docker-file-structure.drawio.png Overview of the Artemis Docker / Docker Compose structure diff --git a/docs/dev/setup/bamboo-bitbucket-jira.rst b/docs/dev/setup/bamboo-bitbucket-jira.rst index 2b429cc85978..0874f38def3c 100644 --- a/docs/dev/setup/bamboo-bitbucket-jira.rst +++ b/docs/dev/setup/bamboo-bitbucket-jira.rst @@ -134,32 +134,32 @@ under ``localhost:7990``. - **Jira:** * - .. figure:: setup/bamboo-bitbucket-jira/bamboo_bitbucket_applicationLink.png :align: center - :target: ../../_images/bamboo_bitbucket_applicationLink.png + :target: ../_images/bamboo_bitbucket_applicationLink.png Bamboo → Bitbucket - .. figure:: setup/bamboo-bitbucket-jira/bitbucket_bamboo_applicationLink.png :align: center - :target: ../../_images/bitbucket_bamboo_applicationLink.png + :target: ../_images/bitbucket_bamboo_applicationLink.png Bitbucket → Bamboo - .. figure:: setup/bamboo-bitbucket-jira/jira_bamboo_applicationLink.png :align: center - :target: ../../_images/jira_bamboo_applicationLink.png + :target: ../_images/jira_bamboo_applicationLink.png Jira → Bamboo * - .. figure:: setup/bamboo-bitbucket-jira/bamboo_jira_applicationLink.png :align: center - :target: ../../_images/bamboo_jira_applicationLink.png + :target: ../_images/bamboo_jira_applicationLink.png Bamboo → Jira - .. figure:: setup/bamboo-bitbucket-jira/bitbucket_jira_applicationLink.png :align: center - :target: ../../_images/bitbucket_jira_applicationLink.png + :target: ../_images/bitbucket_jira_applicationLink.png Bitbucket → Jira - .. figure:: setup/bamboo-bitbucket-jira/jira_bitbucket_applicationLink.png :align: center - :target: ../../_images/jira_bitbucket_applicationLink.png + :target: ../_images/jira_bitbucket_applicationLink.png Jira → Bitbucket @@ -175,8 +175,8 @@ under ``localhost:7990``. Jira `__ to synchronize the users in bitbucket and bamboo: - - Go to Jira → User management → Jira user server → Add application → - Create one application for bitbucket and one for bamboo → add the + - Go to `Jira → User management → Jira user server `__ + → Add application → Create one application for bitbucket and one for bamboo → add the IP-address ``0.0.0.0/0`` to IP Addresses .. list-table:: @@ -185,12 +185,12 @@ under ``localhost:7990``. - .. figure:: setup/bamboo-bitbucket-jira/jira_add_application_bamboo.png - - Go to Bitbucket and Bamboo → User Directories → Add Directories → - Atlassian Crowd → use the URL ``http://jira:8080`` as Server URL → + - Go to `Bitbucket → User Directories `__ + and `Bamboo → User Directories `__ + → Add Directories → Atlassian Crowd → use the URL ``http://jira:8080`` as Server URL → use the application name and password which you used in the previous step. Also, you should decrease the synchronisation period (e.g. to 2 - minutes). Press synchronise after adding the directory, the users and - groups should now be available. + minutes). .. list-table:: @@ -202,10 +202,12 @@ under ``localhost:7990``. Adding Crowd Server in **Bamboo** -#. Give the test users User access on Bitbucket: On the Administration interface (settings cogwheel on the top), - go to the Global permissions. Type the names of all test users in the search field ("Add Users") and give them - the "Bitbucket User" permission. If you skip this step, the users will not be able to log in to Bitbucket or - clone repositories. + - Press synchronise after adding the directory, the users and groups should now be available. + +#. Give the test users User access on Bitbucket: `On the Administration interface (settings cogwheel on the top), + go to the Global permissions `__. Type the names of all test users in + the search field ("Add Users") and give them the "Bitbucket User" permission. If you skip this step, the users + will not be able to log in to Bitbucket or clone repositories. #. In Bamboo create a global variable named SERVER_PLUGIN_SECRET_PASSWORD, the value of this variable will be used @@ -216,16 +218,17 @@ under ``localhost:7990``. #. Download the `bamboo-server-notification-plugin `__ - and add it to bamboo. Go to Bamboo → Manage apps → Upload app → select + and add it to bamboo. Go to `Bamboo → Manage apps `__ → Upload app → select the downloaded .jar file → Upload -#. Authorize the Bamboo agent. Bamboo Administration → Agents → Remote agents → Agent authentication +#. Authorize the Bamboo agent. `Bamboo Administration → Agents `__ + → Remote agents → Agent authentication Approve the agent and edit the IP address in a development setup to ``*.*.*.*`` as the Docker container doesn't have a static IP address. .. figure:: setup/bamboo-bitbucket-jira/bamboo_agent_configuration.png - :target: ../../_images/bamboo_agent_configuration.png + :target: ../_images/bamboo_agent_configuration.png :align: center #. Generate a personal access token @@ -235,8 +238,8 @@ under ``localhost:7990``. #. Personal access token for Bamboo: - - Log in as the admin user and go to Bamboo → Profile (top right corner) → Personal access tokens → - Create token + - Log in as the admin user and go to `Bamboo → Profile (top right corner) → Personal access tokens → + Create token `__ .. figure:: setup/bamboo-bitbucket-jira/bamboo-create-token.png :align: center @@ -253,8 +256,8 @@ under ``localhost:7990``. #. Personal access token for Bitbucket: - - Log in as the admin user and go to Bitbucket → Your profile image (top right corner) → Manage account → - HTTP access tokens → Create token + - Log in as the admin user and go to `Bitbucket → Your profile image (top right corner) → Manage account + `__ → HTTP access tokens → Create token .. figure:: setup/bamboo-bitbucket-jira/bitbucket_create_token.png :align: center From f6e9a37cfc01ebccb4f439ba2cd90e77ddbedd84 Mon Sep 17 00:00:00 2001 From: valentin-boehm <129070641+valentin-boehm@users.noreply.github.com> Date: Sat, 9 Sep 2023 12:42:17 +0200 Subject: [PATCH 26/47] Text exercises: Fix alignment of management buttons (#7143) --- ...-exercise-detail-common-actions.component.html | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/webapp/app/exercises/shared/exercise-detail-common-actions/non-programming-exercise-detail-common-actions.component.html b/src/main/webapp/app/exercises/shared/exercise-detail-common-actions/non-programming-exercise-detail-common-actions.component.html index fb61ed824eb5..a76eecf6a0af 100644 --- a/src/main/webapp/app/exercises/shared/exercise-detail-common-actions/non-programming-exercise-detail-common-actions.component.html +++ b/src/main/webapp/app/exercises/shared/exercise-detail-common-actions/non-programming-exercise-detail-common-actions.component.html @@ -1,19 +1,19 @@
- + Edit - + Participations - + Teams @@ -24,20 +24,20 @@ exercise.assessmentType === AssessmentType.SEMI_AUTOMATIC || exercise.assessmentType === AssessmentType.MANUAL || exercise.allowComplaintsForAutomaticAssessments " [routerLink]="shortBaseResource + 'assessment-dashboard/' + exercise.id" - class="btn btn-info btn-sm me-1 mb-3" + class="btn btn-info btn-sm me-1 mb-2" > {{ 'artemisApp.exercise.exerciseAssessmentDashboard' | artemisTranslate }} - + Scores - + Statistics @@ -46,8 +46,7 @@ From 65a308b9726f269a8285f3df8b9f658d1db8070e Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 10 Sep 2023 08:32:49 +0200 Subject: [PATCH 27/47] General: Fix slow exercise deletion in courses with many participants (#7164) --- .../service/ExerciseDeletionService.java | 3 ++ .../service/exam/ExamRegistrationService.java | 16 +-------- .../metis/conversation/ChannelService.java | 31 ---------------- .../conversation/ConversationService.java | 35 ------------------- .../GroupNotificationScheduleService.java | 4 ++- .../ProgrammingExerciseService.java | 2 +- .../web/rest/FileUploadExerciseResource.java | 9 ++--- .../artemis/web/rest/LectureResource.java | 7 +--- .../web/rest/ModelingExerciseResource.java | 10 ++---- .../web/rest/ProgrammingExerciseResource.java | 8 +---- .../web/rest/QuizExerciseResource.java | 8 +---- .../web/rest/TextExerciseResource.java | 9 ++--- .../exam/exam-scores/exam-scores.component.ts | 2 -- .../metis/metis-conversation.service.ts | 2 -- .../artemis/metis/ChannelIntegrationTest.java | 8 ++--- .../GroupNotificationServiceTest.java | 6 ++-- 16 files changed, 22 insertions(+), 138 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/ExerciseDeletionService.java b/src/main/java/de/tum/in/www1/artemis/service/ExerciseDeletionService.java index 79bc21dbb954..8a5fcdcc5c96 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ExerciseDeletionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ExerciseDeletionService.java @@ -23,6 +23,7 @@ import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismResultRepository; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; import de.tum.in.www1.artemis.service.programming.ProgrammingExerciseService; +import de.tum.in.www1.artemis.service.util.TimeLogUtil; /** * Service Implementation for managing Exercise. @@ -123,8 +124,10 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea var exercise = exerciseRepository.findByIdWithCompetenciesElseThrow(exerciseId); log.info("Request to delete {} with id {}", exercise.getClass().getSimpleName(), exerciseId); + long start = System.nanoTime(); Channel exreciseChannel = channelRepository.findChannelByExerciseId(exerciseId); channelService.deleteChannel(exreciseChannel); + log.info("Deleting the channel took {}", TimeLogUtil.formatDurationFrom(start)); if (exercise instanceof ModelingExercise modelingExercise) { log.info("Deleting clusters, elements and cancel scheduled operations of exercise {}", exercise.getId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamRegistrationService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamRegistrationService.java index edcd3cd02c22..8b886c052bb9 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamRegistrationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamRegistrationService.java @@ -20,7 +20,6 @@ import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationService; -import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; import de.tum.in.www1.artemis.service.user.UserService; import de.tum.in.www1.artemis.web.rest.dto.ExamUserDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; @@ -55,11 +54,9 @@ public class ExamRegistrationService { private final AuthorizationCheckService authorizationCheckService; - private final ChannelService channelService; - public ExamRegistrationService(ExamUserRepository examUserRepository, ExamRepository examRepository, UserService userService, ParticipationService participationService, UserRepository userRepository, AuditEventRepository auditEventRepository, CourseRepository courseRepository, StudentExamRepository studentExamRepository, - StudentParticipationRepository studentParticipationRepository, AuthorizationCheckService authorizationCheckService, ChannelService channelService) { + StudentParticipationRepository studentParticipationRepository, AuthorizationCheckService authorizationCheckService) { this.examRepository = examRepository; this.userService = userService; this.userRepository = userRepository; @@ -70,7 +67,6 @@ public ExamRegistrationService(ExamUserRepository examUserRepository, ExamReposi this.studentParticipationRepository = studentParticipationRepository; this.authorizationCheckService = authorizationCheckService; this.examUserRepository = examUserRepository; - this.channelService = channelService; } /** @@ -133,7 +129,6 @@ public List registerStudentsForExam(Long courseId, Long examId, Lis } } examRepository.save(exam); - channelService.registerUsersToExamChannel(usersAddedToExam, exam); try { User currentUser = userRepository.getUserWithGroupsAndAuthorities(); @@ -202,8 +197,6 @@ public void registerStudentToExam(Course course, Exam exam, User student) { registeredExamUser = examUserRepository.save(registeredExamUser); exam.addExamUser(registeredExamUser); examRepository.save(exam); - - channelService.registerUsersToExamChannel(List.of(student.getLogin()), exam); } else { log.warn("Student {} is already registered for the exam {}", student.getLogin(), exam.getId()); @@ -264,9 +257,6 @@ public void unregisterStudentFromExam(Exam exam, boolean deleteParticipationsAnd examRepository.save(exam); examUserRepository.delete(registeredExamUser); - // Remove the student from exam channel - channelService.deregisterUsersFromExamChannel(Set.of(student), exam.getId()); - // The student exam might already be generated, then we need to delete it Optional optionalStudentExam = studentExamRepository.findWithExercisesByUserIdAndExamId(student.getId(), exam.getId()); optionalStudentExam.ifPresent(studentExam -> removeStudentExam(studentExam, deleteParticipationsAndSubmission)); @@ -306,10 +296,6 @@ public void unregisterAllStudentFromExam(Exam exam, boolean deleteParticipations examRepository.save(exam); examUserRepository.deleteAllById(registeredExamUsers.stream().map(ExamUser::getId).toList()); - var students = userRepository.getStudents(exam.getCourse()); - - channelService.deregisterUsersFromExamChannel(students, exam.getId()); - // remove all students exams Set studentExams = studentExamRepository.findAllWithoutTestRunsWithExercisesByExamId(exam.getId()); studentExams.forEach(studentExam -> removeStudentExam(studentExam, deleteParticipationsAndSubmission)); diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java index 71a6a251db67..9bf6550e967a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java @@ -188,22 +188,6 @@ public void registerTutorsAndInstructorsToChannel(Course course, Channel channel registerUsersToChannel(false, true, true, List.of(), course, channel); } - /** - * Adds users to the channel of the given exam asynchronously - * - * @param users list of user logins to register for the exam channel - * @param exam exam to which channel the users should be added - */ - @Async - public void registerUsersToExamChannel(List users, Exam exam) { - Channel channel = channelRepository.findChannelByExamId(exam.getId()); - if (channel == null) { - return; - } - SecurityUtils.setAuthorizationObject(); - registerUsersToChannel(false, false, false, users, exam.getCourse(), channel); - } - /** * Register users to the newly created channel * @@ -391,21 +375,6 @@ public Channel updateExamChannel(Exam originalExam, Exam updatedExam) { return updateChannelName(channel, updatedExam.getChannelName()); } - /** - * Removes users from an exam channel - * - * @param users users to remove from the channel - * @param examId id of the exam the channel belongs to - */ - public void deregisterUsersFromExamChannel(Set users, Long examId) { - Channel channel = channelRepository.findChannelByExamId(examId); - if (channel == null) { - return; - } - - conversationService.deregisterUsersFromAConversation(channel.getCourse(), users, channel); - } - private Channel updateChannelName(Channel channel, String newChannelName) { // Update channel name if necessary diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java index a8336697033d..5db5b8df1c9a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java @@ -10,7 +10,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RequestBody; import de.tum.in.www1.artemis.domain.*; @@ -249,11 +248,7 @@ public void deregisterUsersFromAConversation(Course course, Set users, Con * * @param conversation the conversation to be deleted */ - @Transactional // ok because of delete public void deleteConversation(Conversation conversation) { - var usersToMessage = conversationParticipantRepository.findConversationParticipantByConversationId(conversation.getId()).stream().map(ConversationParticipant::getUser) - .collect(Collectors.toSet()); - broadcastOnConversationMembershipChannel(conversation.getCourse(), MetisCrudAction.DELETE, conversation, usersToMessage); this.postRepository.deleteAllByConversationId(conversation.getId()); this.conversationParticipantRepository.deleteAllByConversationId(conversation.getId()); this.conversationRepository.deleteById(conversation.getId()); @@ -272,36 +267,6 @@ public void broadcastOnConversationMembershipChannel(Course course, MetisCrudAct recipients.forEach(user -> sendToConversationMembershipChannel(metisCrudAction, conversation, user, conversationParticipantTopicName)); } - /** - * Deregister all clients from the exercise channel of the given exercise - * - * @param exercise the exercise that is being deleted - */ - public void deregisterAllClientsFromChannel(Exercise exercise) { - // deregister all clients from the channel - Channel originalChannel = channelRepository.findChannelByExerciseId(exercise.getId()); - if (exercise.isCourseExercise() && originalChannel != null) { - Set channelParticipants = conversationParticipantRepository.findConversationParticipantByConversationId(originalChannel.getId()); - Set usersToBeDeregistered = channelParticipants.stream().map(ConversationParticipant::getUser).collect(Collectors.toSet()); - broadcastOnConversationMembershipChannel(originalChannel.getCourse(), MetisCrudAction.DELETE, originalChannel, usersToBeDeregistered); - } - } - - /** - * Deregister all clients from the lecture channel of the given exercise - * - * @param lecture the lecture that is being deleted - */ - public void deregisterAllClientsFromChannel(Lecture lecture) { - // deregister all clients from the channel - Channel originalChannel = channelRepository.findChannelByLectureId(lecture.getId()); - if (originalChannel != null) { - Set channelParticipants = conversationParticipantRepository.findConversationParticipantByConversationId(originalChannel.getId()); - Set usersToBeDeregistered = channelParticipants.stream().map(ConversationParticipant::getUser).collect(Collectors.toSet()); - broadcastOnConversationMembershipChannel(lecture.getCourse(), MetisCrudAction.DELETE, originalChannel, usersToBeDeregistered); - } - } - @NotNull public static String getConversationParticipantTopicName(Long courseId) { return METIS_WEBSOCKET_CHANNEL_PREFIX + "courses/" + courseId + "/conversations/user/"; diff --git a/src/main/java/de/tum/in/www1/artemis/service/notifications/GroupNotificationScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/notifications/GroupNotificationScheduleService.java index ffb48c5782ae..288f32aa5fb0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/notifications/GroupNotificationScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/notifications/GroupNotificationScheduleService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.ExerciseDateService; import de.tum.in.www1.artemis.service.messaging.InstanceMessageSendService; @@ -140,7 +141,8 @@ private void checkAndCreateAssessedExerciseSubmissionNotificationsWhenUpdatingEx * @param exercise that is created */ @Async - public void checkNotificationsForNewExercise(Exercise exercise) { + public void checkNotificationsForNewExerciseAsync(Exercise exercise) { + SecurityUtils.setAuthorizationObject(); // required for async // TODO: in a course with 2000 participants, this can take really long, we should optimize this checkNotificationForExerciseRelease(exercise); checkNotificationForAssessmentDueDate(exercise); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java index 9c964f93fa27..5256f48f1a8d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseService.java @@ -222,7 +222,7 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // not yet saved in the database, so we cannot save the submission accordingly (see ProgrammingSubmissionService.processNewProgrammingSubmission) versionControl.addWebHooksForExercise(savedProgrammingExercise); scheduleOperations(savedProgrammingExercise.getId()); - groupNotificationScheduleService.checkNotificationsForNewExercise(savedProgrammingExercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(savedProgrammingExercise); return savedProgrammingExercise; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/FileUploadExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/FileUploadExerciseResource.java index c6acb02443fe..88f19fc188a2 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/FileUploadExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/FileUploadExerciseResource.java @@ -27,7 +27,6 @@ import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; @@ -81,14 +80,12 @@ public class FileUploadExerciseResource { private final ChannelRepository channelRepository; - private final ConversationService conversationService; - public FileUploadExerciseResource(FileUploadExerciseRepository fileUploadExerciseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, CourseService courseService, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, FileUploadSubmissionExportService fileUploadSubmissionExportService, GradingCriterionRepository gradingCriterionRepository, CourseRepository courseRepository, ParticipationRepository participationRepository, GroupNotificationScheduleService groupNotificationScheduleService, FileUploadExerciseImportService fileUploadExerciseImportService, FileUploadExerciseService fileUploadExerciseService, ChannelService channelService, - ChannelRepository channelRepository, ConversationService conversationService) { + ChannelRepository channelRepository) { this.fileUploadExerciseRepository = fileUploadExerciseRepository; this.userRepository = userRepository; this.courseService = courseService; @@ -104,7 +101,6 @@ public FileUploadExerciseResource(FileUploadExerciseRepository fileUploadExercis this.fileUploadExerciseService = fileUploadExerciseService; this.channelService = channelService; this.channelRepository = channelRepository; - this.conversationService = conversationService; } /** @@ -133,7 +129,7 @@ public ResponseEntity createFileUploadExercise(@RequestBody FileUploadExercise result = fileUploadExerciseRepository.save(fileUploadExercise); channelService.createExerciseChannel(result, Optional.ofNullable(fileUploadExercise.getChannelName())); - groupNotificationScheduleService.checkNotificationsForNewExercise(fileUploadExercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(fileUploadExercise); return ResponseEntity.created(new URI("/api/file-upload-exercises/" + result.getId())).body(result); } @@ -333,7 +329,6 @@ public ResponseEntity deleteFileUploadExercise(@PathVariable Long exercise authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, exercise, user); // note: we use the exercise service here, because this one makes sure to clean up all lazy references correctly. exerciseService.logDeletion(exercise, exercise.getCourseViaExerciseGroupOrCourseMember(), user); - conversationService.deregisterAllClientsFromChannel(exercise); exerciseDeletionService.delete(exerciseId, false, false); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, exercise.getTitle())).build(); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LectureResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LectureResource.java index 59408ae7f4f8..2a43ac61cba5 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LectureResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LectureResource.java @@ -32,7 +32,6 @@ import de.tum.in.www1.artemis.service.LectureImportService; import de.tum.in.www1.artemis.service.LectureService; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @@ -66,15 +65,13 @@ public class LectureResource { private final ExerciseService exerciseService; - private final ConversationService conversationService; - private final ChannelService channelService; private final ChannelRepository channelRepository; public LectureResource(LectureRepository lectureRepository, LectureService lectureService, LectureImportService lectureImportService, CourseRepository courseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, ExerciseService exerciseService, ChannelService channelService, - ConversationService conversationService, ChannelRepository channelRepository) { + ChannelRepository channelRepository) { this.lectureRepository = lectureRepository; this.lectureService = lectureService; this.lectureImportService = lectureImportService; @@ -83,7 +80,6 @@ public LectureResource(LectureRepository lectureRepository, LectureService lectu this.authCheckService = authCheckService; this.exerciseService = exerciseService; this.channelService = channelService; - this.conversationService = conversationService; this.channelRepository = channelRepository; } @@ -367,7 +363,6 @@ public ResponseEntity deleteLecture(@PathVariable Long lectureId) { } authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); - conversationService.deregisterAllClientsFromChannel(lecture); log.debug("REST request to delete Lecture : {}", lectureId); lectureService.delete(lecture); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, lectureId.toString())).build(); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java index b18c2ef6b990..5f262e3fa6e8 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ModelingExerciseResource.java @@ -32,7 +32,6 @@ import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; import de.tum.in.www1.artemis.service.plagiarism.ModelingPlagiarismDetectionService; import de.tum.in.www1.artemis.service.util.TimeLogUtil; @@ -90,8 +89,6 @@ public class ModelingExerciseResource { private final ChannelService channelService; - private final ConversationService conversationService; - private final ChannelRepository channelRepository; public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepository, UserRepository userRepository, CourseService courseService, @@ -99,8 +96,7 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos ModelingExerciseService modelingExerciseService, ExerciseDeletionService exerciseDeletionService, PlagiarismResultRepository plagiarismResultRepository, ModelingExerciseImportService modelingExerciseImportService, SubmissionExportService modelingSubmissionExportService, ExerciseService exerciseService, GroupNotificationScheduleService groupNotificationScheduleService, GradingCriterionRepository gradingCriterionRepository, - ModelingPlagiarismDetectionService modelingPlagiarismDetectionService, ChannelService channelService, ConversationService conversationService, - ChannelRepository channelRepository) { + ModelingPlagiarismDetectionService modelingPlagiarismDetectionService, ChannelService channelService, ChannelRepository channelRepository) { this.modelingExerciseRepository = modelingExerciseRepository; this.courseService = courseService; this.modelingExerciseService = modelingExerciseService; @@ -117,7 +113,6 @@ public ModelingExerciseResource(ModelingExerciseRepository modelingExerciseRepos this.gradingCriterionRepository = gradingCriterionRepository; this.modelingPlagiarismDetectionService = modelingPlagiarismDetectionService; this.channelService = channelService; - this.conversationService = conversationService; this.channelRepository = channelRepository; } @@ -155,7 +150,7 @@ public ResponseEntity createModelingExercise(@RequestBody Mode channelService.createExerciseChannel(result, Optional.ofNullable(modelingExercise.getChannelName())); modelingExerciseService.scheduleOperations(result.getId()); - groupNotificationScheduleService.checkNotificationsForNewExercise(modelingExercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(modelingExercise); return ResponseEntity.created(new URI("/api/modeling-exercises/" + result.getId())).body(result); } @@ -293,7 +288,6 @@ public ResponseEntity deleteModelingExercise(@PathVariable Long exerciseId authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, modelingExercise, user); // note: we use the exercise service here, because this one makes sure to clean up all lazy references correctly. exerciseService.logDeletion(modelingExercise, modelingExercise.getCourseViaExerciseGroupOrCourseMember(), user); - conversationService.deregisterAllClientsFromChannel(modelingExercise); exerciseDeletionService.delete(exerciseId, false, false); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, modelingExercise.getTitle())).build(); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java index 994caff41ee3..fd265be5796f 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java @@ -38,7 +38,6 @@ import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; import de.tum.in.www1.artemis.service.messaging.InstanceMessageSendService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.programming.*; import de.tum.in.www1.artemis.web.rest.dto.BuildLogStatisticsDTO; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; @@ -108,8 +107,6 @@ public class ProgrammingExerciseResource { private final BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository; - private final ConversationService conversationService; - private final InstanceMessageSendService instanceMessageSendService; public ProgrammingExerciseResource(ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseTestCaseRepository programmingExerciseTestCaseRepository, @@ -120,8 +117,7 @@ public ProgrammingExerciseResource(ProgrammingExerciseRepository programmingExer StaticCodeAnalysisService staticCodeAnalysisService, GradingCriterionRepository gradingCriterionRepository, CourseRepository courseRepository, GitService gitService, AuxiliaryRepositoryService auxiliaryRepositoryService, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, ProfileService profileService, - BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, ConversationService conversationService, ChannelRepository channelRepository, - InstanceMessageSendService instanceMessageSendService) { + BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, ChannelRepository channelRepository, InstanceMessageSendService instanceMessageSendService) { this.profileService = profileService; this.programmingExerciseRepository = programmingExerciseRepository; this.programmingExerciseTestCaseRepository = programmingExerciseTestCaseRepository; @@ -143,7 +139,6 @@ public ProgrammingExerciseResource(ProgrammingExerciseRepository programmingExer this.solutionProgrammingExerciseParticipationRepository = solutionProgrammingExerciseParticipationRepository; this.templateProgrammingExerciseParticipationRepository = templateProgrammingExerciseParticipationRepository; this.buildLogStatisticsEntryRepository = buildLogStatisticsEntryRepository; - this.conversationService = conversationService; this.channelRepository = channelRepository; this.instanceMessageSendService = instanceMessageSendService; } @@ -456,7 +451,6 @@ public ResponseEntity deleteProgrammingExercise(@PathVariable long exercis User user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, programmingExercise, user); exerciseService.logDeletion(programmingExercise, programmingExercise.getCourseViaExerciseGroupOrCourseMember(), user); - conversationService.deregisterAllClientsFromChannel(programmingExercise); exerciseDeletionService.delete(exerciseId, deleteStudentReposBuildPlans, deleteBaseReposBuildPlans); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, programmingExercise.getTitle())).build(); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java index d785222ddb75..5e11afd43ae4 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java @@ -31,7 +31,6 @@ import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationService; import de.tum.in.www1.artemis.service.scheduled.cache.quiz.QuizScheduleService; @@ -98,15 +97,12 @@ public class QuizExerciseResource { private final ChannelRepository channelRepository; - private final ConversationService conversationService; - public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizExerciseRepository quizExerciseRepository, CourseService courseService, UserRepository userRepository, ExerciseDeletionService exerciseDeletionServiceService, QuizScheduleService quizScheduleService, QuizStatisticService quizStatisticService, QuizExerciseImportService quizExerciseImportService, AuthorizationCheckService authCheckService, CourseRepository courseRepository, GroupNotificationService groupNotificationService, ExerciseService exerciseService, ExamDateService examDateService, QuizMessagingService quizMessagingService, GroupNotificationScheduleService groupNotificationScheduleService, StudentParticipationRepository studentParticipationRepository, QuizBatchService quizBatchService, - QuizBatchRepository quizBatchRepository, SubmissionRepository submissionRepository, ChannelService channelService, ChannelRepository channelRepository, - ConversationService conversationService) { + QuizBatchRepository quizBatchRepository, SubmissionRepository submissionRepository, ChannelService channelService, ChannelRepository channelRepository) { this.quizExerciseService = quizExerciseService; this.quizExerciseRepository = quizExerciseRepository; this.exerciseDeletionService = exerciseDeletionServiceService; @@ -128,7 +124,6 @@ public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizExercis this.submissionRepository = submissionRepository; this.channelService = channelService; this.channelRepository = channelRepository; - this.conversationService = conversationService; } /** @@ -553,7 +548,6 @@ public ResponseEntity deleteQuizExercise(@PathVariable Long quizExerciseId // note: we use the exercise service here, because this one makes sure to clean up all lazy references correctly. exerciseService.logDeletion(quizExercise, quizExercise.getCourseViaExerciseGroupOrCourseMember(), user); - conversationService.deregisterAllClientsFromChannel(quizExercise); exerciseDeletionService.delete(quizExerciseId, false, false); quizExerciseService.cancelScheduledQuiz(quizExerciseId); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, quizExercise.getTitle())).build(); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java index 85cae438d727..7cd6b2abefb6 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java @@ -27,7 +27,6 @@ import de.tum.in.www1.artemis.service.feature.FeatureToggle; import de.tum.in.www1.artemis.service.messaging.InstanceMessageSendService; import de.tum.in.www1.artemis.service.metis.conversation.ChannelService; -import de.tum.in.www1.artemis.service.metis.conversation.ConversationService; import de.tum.in.www1.artemis.service.notifications.GroupNotificationScheduleService; import de.tum.in.www1.artemis.service.plagiarism.TextPlagiarismDetectionService; import de.tum.in.www1.artemis.service.util.TimeLogUtil; @@ -99,8 +98,6 @@ public class TextExerciseResource { private final ChannelService channelService; - private final ConversationService conversationService; - private final ChannelRepository channelRepository; public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextExerciseService textExerciseService, FeedbackRepository feedbackRepository, @@ -110,7 +107,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE TextSubmissionExportService textSubmissionExportService, ExampleSubmissionRepository exampleSubmissionRepository, ExerciseService exerciseService, GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, InstanceMessageSendService instanceMessageSendService, TextPlagiarismDetectionService textPlagiarismDetectionService, CourseRepository courseRepository, - ChannelService channelService, ChannelRepository channelRepository, ConversationService conversationService) { + ChannelService channelService, ChannelRepository channelRepository) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; this.plagiarismResultRepository = plagiarismResultRepository; @@ -133,7 +130,6 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.textPlagiarismDetectionService = textPlagiarismDetectionService; this.courseRepository = courseRepository; this.channelService = channelService; - this.conversationService = conversationService; this.channelRepository = channelRepository; } @@ -170,7 +166,7 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise channelService.createExerciseChannel(result, Optional.ofNullable(textExercise.getChannelName())); instanceMessageSendService.sendTextExerciseSchedule(result.getId()); - groupNotificationScheduleService.checkNotificationsForNewExercise(textExercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(textExercise); return ResponseEntity.created(new URI("/api/text-exercises/" + result.getId())).body(result); } @@ -300,7 +296,6 @@ public ResponseEntity deleteTextExercise(@PathVariable Long exerciseId) { authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, textExercise, user); // NOTE: we use the exerciseDeletionService here, because this one makes sure to clean up all lazy references correctly. exerciseService.logDeletion(textExercise, textExercise.getCourseViaExerciseGroupOrCourseMember(), user); - conversationService.deregisterAllClientsFromChannel(textExercise); exerciseDeletionService.delete(exerciseId, false, false); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, textExercise.getTitle())).build(); } diff --git a/src/main/webapp/app/exam/exam-scores/exam-scores.component.ts b/src/main/webapp/app/exam/exam-scores/exam-scores.component.ts index a6ba3b558ff7..730e6f1d61ce 100644 --- a/src/main/webapp/app/exam/exam-scores/exam-scores.component.ts +++ b/src/main/webapp/app/exam/exam-scores/exam-scores.component.ts @@ -20,7 +20,6 @@ import { AlertService } from 'app/core/util/alert.service'; import { roundValueSpecifiedByCourseSettings } from 'app/shared/util/utils'; import { LocaleConversionService } from 'app/shared/service/locale-conversion.service'; import { JhiLanguageHelper } from 'app/core/language/language.helper'; -import { TranslateService } from '@ngx-translate/core'; import { ParticipantScoresService, ScoresDTO } from 'app/shared/participant-scores/participant-scores.service'; import { captureException } from '@sentry/angular-ivy'; import { GradingSystemService } from 'app/grading-system/grading-system.service'; @@ -138,7 +137,6 @@ export class ExamScoresComponent implements OnInit, OnDestroy { private changeDetector: ChangeDetectorRef, private languageHelper: JhiLanguageHelper, private localeConversionService: LocaleConversionService, - private translateService: TranslateService, private participantScoresService: ParticipantScoresService, private gradingSystemService: GradingSystemService, private courseManagementService: CourseManagementService, diff --git a/src/main/webapp/app/shared/metis/metis-conversation.service.ts b/src/main/webapp/app/shared/metis/metis-conversation.service.ts index 08f1f69dc2d9..c0ecdce30b5d 100644 --- a/src/main/webapp/app/shared/metis/metis-conversation.service.ts +++ b/src/main/webapp/app/shared/metis/metis-conversation.service.ts @@ -13,7 +13,6 @@ import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-c import { ChannelService } from 'app/shared/metis/conversations/channel.service'; import { onError } from 'app/shared/util/global.utils'; import { Course } from 'app/entities/course.model'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; import { GroupChatService } from 'app/shared/metis/conversations/group-chat.service'; @@ -46,7 +45,6 @@ export class MetisConversationService implements OnDestroy { private _isServiceSetup$: ReplaySubject = new ReplaySubject(1); constructor( - private courseManagementService: CourseManagementService, private groupChatService: GroupChatService, private oneToOneChatService: OneToOneChatService, private channelService: ChannelService, diff --git a/src/test/java/de/tum/in/www1/artemis/metis/ChannelIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/metis/ChannelIntegrationTest.java index 94d013253145..8597c6c55704 100644 --- a/src/test/java/de/tum/in/www1/artemis/metis/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/metis/ChannelIntegrationTest.java @@ -34,6 +34,8 @@ class ChannelIntegrationTest extends AbstractConversationTest { + private static final String TEST_PREFIX = "chtest"; + @Autowired TutorialGroupRepository tutorialGroupRepository; @@ -46,8 +48,6 @@ class ChannelIntegrationTest extends AbstractConversationTest { @Autowired private LectureRepository lectureRepository; - private static final String TEST_PREFIX = "chtest"; - @Autowired private TextExerciseUtilService textExerciseUtilService; @@ -244,8 +244,6 @@ void deleteChannel_asInstructor_shouldDeleteChannel(boolean isPublicChannel) thr request.delete("/api/courses/" + exampleCourseId + "/channels/" + channel.getId(), HttpStatus.OK); // then assertThat(channelRepository.findById(channel.getId())).isEmpty(); - verifyMultipleParticipantTopicWebsocketSent(MetisCrudAction.DELETE, channel.getId(), "instructor1"); - verifyNoParticipantTopicWebsocketSentExceptAction(MetisCrudAction.DELETE); } @ParameterizedTest @@ -283,8 +281,6 @@ void deleteChannel_asCreator_shouldDeleteChannel(boolean isPublicChannel) throws request.delete("/api/courses/" + exampleCourseId + "/channels/" + channel.getId(), HttpStatus.OK); // then assertThat(channelRepository.findById(channel.getId())).isEmpty(); - verifyMultipleParticipantTopicWebsocketSent(MetisCrudAction.DELETE, channel.getId(), "tutor1"); - verifyNoParticipantTopicWebsocketSentExceptAction(MetisCrudAction.DELETE); } @ParameterizedTest diff --git a/src/test/java/de/tum/in/www1/artemis/notification/GroupNotificationServiceTest.java b/src/test/java/de/tum/in/www1/artemis/notification/GroupNotificationServiceTest.java index 85672749103c..763e5cdf0842 100644 --- a/src/test/java/de/tum/in/www1/artemis/notification/GroupNotificationServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/notification/GroupNotificationServiceTest.java @@ -289,7 +289,7 @@ void testNotifyAboutExerciseUpdate_correctReleaseDate_courseExercise() { */ @Test void testCheckNotificationForExerciseRelease_undefinedReleaseDate() { - groupNotificationScheduleService.checkNotificationsForNewExercise(exercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(exercise); verify(groupNotificationService, timeout(1500)).notifyAllGroupsAboutReleasedExercise(any()); } @@ -299,7 +299,7 @@ void testCheckNotificationForExerciseRelease_undefinedReleaseDate() { @Test void testCheckNotificationForExerciseRelease_currentOrPastReleaseDate() { exercise.setReleaseDate(CURRENT_TIME); - groupNotificationScheduleService.checkNotificationsForNewExercise(exercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(exercise); verify(groupNotificationService, timeout(1500)).notifyAllGroupsAboutReleasedExercise(any()); } @@ -309,7 +309,7 @@ void testCheckNotificationForExerciseRelease_currentOrPastReleaseDate() { @Test void testCheckNotificationForExerciseRelease_futureReleaseDate() { exercise.setReleaseDate(FUTURE_TIME); - groupNotificationScheduleService.checkNotificationsForNewExercise(exercise); + groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(exercise); verify(instanceMessageSendService, timeout(1500)).sendExerciseReleaseNotificationSchedule(any()); } From 12057ed62683db3897691d395f5f93baf583a05a Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Sun, 10 Sep 2023 08:33:58 +0200 Subject: [PATCH 28/47] Exam mode: Fix text exercise score display when two correction rounds are enabled (#7153) --- .../web/rest/TextExerciseResource.java | 4 +++ .../text/TextAssessmentIntegrationTest.java | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java index 7cd6b2abefb6..6361d050658d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java @@ -361,6 +361,10 @@ public ResponseEntity getDataForTextEditor(@PathVariable L if (!authCheckService.isAtLeastTeachingAssistantForExercise(textExercise, user)) { result.filterSensitiveInformation(); } + + // only send the one latest result to the client + textSubmission.setResults(List.of(result)); + participation.setResults(Set.of(result)); } participation.addSubmission(textSubmission); diff --git a/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java index efbea3b16883..196aa22e7ce1 100644 --- a/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/text/TextAssessmentIntegrationTest.java @@ -529,6 +529,38 @@ void getDataForTextEditor_testExam() throws Exception { assertThat(exam.getCourse()).isNull(); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void getDataForTextEditor_secondCorrection_oneResult() throws Exception { + Exam exam = examUtilService.addExamWithExerciseGroup(course, true); + exam.setVisibleDate(now().minusHours(3)); + exam.setStartDate(now().minusHours(2)); + exam.setEndDate(now().minusHours(1)); + exam.setPublishResultsDate(now().minusMinutes(30)); + exam.setNumberOfCorrectionRoundsInExam(2); + + ExerciseGroup exerciseGroup = exam.getExerciseGroups().get(0); + TextExercise textExercise = TextExerciseFactory.generateTextExerciseForExam(exerciseGroup); + exerciseGroup.addExercise(textExercise); + exerciseGroupRepository.save(exerciseGroup); + textExercise = exerciseRepo.save(textExercise); + + examRepository.save(exam); + + TextSubmission textSubmission = ParticipationFactory.generateTextSubmission("Some text", Language.ENGLISH, true); + textSubmission = textExerciseUtilService.saveTextSubmissionWithResultAndAssessor(textExercise, textSubmission, TEST_PREFIX + "student1", TEST_PREFIX + "tutor1"); + // second correction round + textSubmission = textExerciseUtilService.saveTextSubmissionWithResultAndAssessor(textExercise, textSubmission, TEST_PREFIX + "student1", TEST_PREFIX + "tutor2"); + + var participation = request.get("/api/text-editor/" + textSubmission.getParticipation().getId(), HttpStatus.OK, Participation.class); + + assertThat(participation).isNotNull(); + assertThat(participation.getSubmissions()).containsExactly(textSubmission); + var submission = participation.getSubmissions().iterator().next(); + assertThat(submission.getResults()).hasSize(1); + assertThat(participation.getResults()).hasSize(1); + } + @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void getDataForTextEditor_studentHidden() throws Exception { From dcea51ac1cd34cbb79ddf3117a9ebcdb333fc97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= <38322605+JohannesStoehr@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:40:34 +0200 Subject: [PATCH 29/47] Programming exercises: Fix illegal submission for practice submissions after due date (#7169) --- .../ProgrammingSubmissionService.java | 10 +++++----- ...nAndResultBitbucketBambooIntegrationTest.java | 16 +++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java index 2d011c2aca04..388821a2274f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java @@ -236,8 +236,8 @@ private void checkForIllegalSubmission(ProgrammingExerciseParticipation programm User student = optionalStudentWithGroups.get(); if (!isAllowedToSubmit(studentParticipation, student, programmingSubmission)) { - final String message = "The student %s illegally submitted code after the allowed individual due date (including the grace period) in the participation %d for the programming exercise %d" - .formatted(student.getLogin(), programmingExerciseParticipation.getId(), programmingExercise.getId()); + final String message = ("The student %s illegally submitted code after the allowed individual due date (including the grace period) in the participation %d for the " + + "programming exercise \"%s\"").formatted(student.getLogin(), programmingExerciseParticipation.getId(), programmingExercise.getTitle()); programmingSubmission.setType(SubmissionType.ILLEGAL); programmingMessagingService.notifyInstructorGroupAboutIllegalSubmissionsForExercise(programmingExercise, message); log.warn(message); @@ -246,8 +246,8 @@ private void checkForIllegalSubmission(ProgrammingExerciseParticipation programm // we include submission policies here: if the student (for whatever reason) has more submission than allowed attempts, the submission would be illegal if (exceedsSubmissionPolicy(studentParticipation, submissionPolicy)) { - final String message = "The student %s illegally submitted code after the submission policy lock limit %d in the participation %d for the programming exercise %d" - .formatted(student.getLogin(), submissionPolicy.getSubmissionLimit(), programmingExerciseParticipation.getId(), programmingExercise.getId()); + final String message = "The student %s illegally submitted code after the submission policy lock limit %d in the participation %d for the programming exercise \"%s\"" + .formatted(student.getLogin(), submissionPolicy.getSubmissionLimit(), programmingExerciseParticipation.getId(), programmingExercise.getTitle()); programmingSubmission.setType(SubmissionType.ILLEGAL); programmingMessagingService.notifyInstructorGroupAboutIllegalSubmissionsForExercise(programmingExercise, message); log.warn(message); @@ -272,7 +272,7 @@ private boolean isAllowedToSubmit(ProgrammingExerciseStudentParticipation partic private boolean isAllowedToSubmitForCourseExercise(ProgrammingExerciseStudentParticipation participation, ProgrammingSubmission programmingSubmission) { var dueDate = ExerciseDateService.getDueDate(participation); - if (dueDate.isEmpty()) { + if (dueDate.isEmpty() || participation.isTestRun()) { return true; } return dueDate.get().plusSeconds(PROGRAMMING_GRACE_PERIOD_SECONDS).isAfter(programmingSubmission.getSubmissionDate()); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java index ccd5d479c067..8a94261d32cd 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java @@ -947,9 +947,9 @@ void shouldCreateGradleFeedback() throws Exception { } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") - @MethodSource("testGracePeriodValues") + @MethodSource("testSubmissionAfterDueDateValues") @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGracePeriod(ZonedDateTime dueDate, SubmissionType expectedType, boolean expectedRated) throws Exception { + void testSubmissionAfterDueDate(ZonedDateTime dueDate, SubmissionType expectedType, boolean expectedRated, boolean testRun) throws Exception { var user = userRepository.findUserWithGroupsAndAuthoritiesByLogin(TEST_PREFIX + "student1").orElseThrow(); Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); @@ -959,6 +959,10 @@ void testGracePeriod(ZonedDateTime dueDate, SubmissionType expectedType, boolean // Add a participation for the programming exercise var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, user.getLogin()); + if (testRun) { + participation.setTestRun(testRun); + participation = participationRepository.save(participation); + } // mock request for fetchCommitInfo() final String projectKey = "test201904bprogrammingexercise6"; @@ -993,13 +997,15 @@ void testGracePeriod(ZonedDateTime dueDate, SubmissionType expectedType, boolean assertThat(createdResult.getParticipation().getId()).isEqualTo(updatedParticipation.getId()); } - private static Stream testGracePeriodValues() { + private static Stream testSubmissionAfterDueDateValues() { ZonedDateTime now = ZonedDateTime.now(); return Stream.of( // short after due date -> grace period active, type manual + rated - Arguments.of(now.minusSeconds(10), SubmissionType.MANUAL, true), + Arguments.of(now.minusSeconds(10), SubmissionType.MANUAL, true, false), // long after due date -> grace period not active, illegal + non rated - Arguments.of(now.minusMinutes(2), SubmissionType.ILLEGAL, false)); + Arguments.of(now.minusMinutes(2), SubmissionType.ILLEGAL, false, false), + // After grace period but a practice submission and therefore ok + Arguments.of(now.minusMinutes(2), SubmissionType.MANUAL, false, true)); } private Result assertBuildError(Long participationId, String userLogin, ProgrammingLanguage programmingLanguage) throws Exception { From b0c93a3974f94b8281197c8e17e78cc8b5aa528f Mon Sep 17 00:00:00 2001 From: Lennart Keller <44754405+lennart-keller@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:42:08 +0200 Subject: [PATCH 30/47] Development: Make exam channels course wide (#7167) --- .../service/exam/ExamImportService.java | 5 +-- .../metis/conversation/ChannelService.java | 39 ++----------------- .../www1/artemis/web/rest/ExamResource.java | 9 ++--- .../artemis/exam/ExamIntegrationTest.java | 19 --------- 4 files changed, 8 insertions(+), 64 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java index 8a9db8e1db6f..12121000919e 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java @@ -8,7 +8,6 @@ import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; -import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; import de.tum.in.www1.artemis.domain.quiz.QuizExercise; import de.tum.in.www1.artemis.repository.*; @@ -102,9 +101,7 @@ public Exam importExamWithExercises(Exam examToCopy, long targetCourseId) { // 2nd: Copy the exercise groups to the exam copyExerciseGroupsWithExercisesToExam(exerciseGroupsToCopy, examCopied); - Channel createdChannel = channelService.createExamChannel(examCopied, Optional.ofNullable(examToCopy.getChannelName())); - channelService.registerTutorsAndInstructorsToChannel(examCopied.getCourse(), createdChannel); - + channelService.createExamChannel(examCopied, Optional.ofNullable(examToCopy.getChannelName())); return examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examCopied.getId()); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java index 9bf6550e967a..5bf92bc64838 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java @@ -11,7 +11,6 @@ import javax.validation.Valid; import javax.validation.constraints.NotNull; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -25,7 +24,6 @@ import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.metis.ConversationParticipantRepository; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; -import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.metis.conversation.errors.ChannelNameDuplicateException; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.metis.conversation.dtos.ChannelDTO; @@ -158,36 +156,6 @@ public Channel createChannel(Course course, Channel channel, Optional crea return savedChannel; } - /** - * Adds all course students to the given channel asynchronously - * - * @param course the course to add the students from - * @param channel the channel to add the students to - */ - @Async - public void registerCourseStudentsToChannelAsynchronously(Course course, Channel channel) { - if (channel == null) { - return; - } - SecurityUtils.setAuthorizationObject(); - registerUsersToChannel(true, false, false, List.of(), course, channel); - } - - /** - * Adds tutors and instructors to the given channel asynchronously - * - * @param course the course to add the tutors and instructors from - * @param channel the exam channel to add the users to - */ - @Async - public void registerTutorsAndInstructorsToChannel(Course course, Channel channel) { - if (channel == null || !course.getCourseInformationSharingConfiguration().isMessagingEnabled()) { - return; - } - SecurityUtils.setAuthorizationObject(); - registerUsersToChannel(false, true, true, List.of(), course, channel); - } - /** * Register users to the newly created channel * @@ -277,7 +245,7 @@ public void unarchiveChannel(Long channelId) { } /** - * Creates a channel for a lecture and sets the channel name of the lecture accordingly. Also adds all course members asynchronously. + * Creates a channel for a lecture and sets the channel name of the lecture accordingly. * * @param lecture the lecture to create the channel for * @param channelName the name of the channel @@ -292,7 +260,7 @@ public Channel createLectureChannel(Lecture lecture, Optional channelNam } /** - * Creates a channel for a course exercise and sets the channel name of the exercise accordingly. Also adds all course members asynchronously. + * Creates a channel for a course exercise and sets the channel name of the exercise accordingly. * * @param exercise the exercise to create the channel for * @param channelName the name of the channel @@ -308,7 +276,7 @@ public Channel createExerciseChannel(Exercise exercise, Optional channel } /** - * Creates a channel for a real exam and sets the channel name of the exam accordingly. Also adds all course members asynchronously. + * Creates a channel for a real exam and sets the channel name of the exam accordingly. * * @param exam the exam to create the channel for * @param channelName the name of the channel @@ -320,7 +288,6 @@ public Channel createExamChannel(Exam exam, Optional channelName) { } Channel channelToCreate = createDefaultChannel(channelName, "exam-", exam.getTitle()); channelToCreate.setIsPublic(false); - channelToCreate.setIsCourseWide(false); channelToCreate.setExam(exam); Channel createdChannel = createChannel(exam.getCourse(), channelToCreate, Optional.of(userRepository.getUserWithGroupsAndAuthorities())); exam.setChannelName(createdChannel.getName()); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 00b3bfe6a122..9f42c7385907 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -33,7 +33,9 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.exam.*; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; +import de.tum.in.www1.artemis.domain.exam.StudentExam; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.participation.TutorParticipation; import de.tum.in.www1.artemis.repository.*; @@ -171,8 +173,7 @@ public ResponseEntity createExam(@PathVariable Long courseId, @RequestBody Exam savedExam = examRepository.save(exam); - Channel createdChannel = channelService.createExamChannel(savedExam, Optional.ofNullable(exam.getChannelName())); - channelService.registerTutorsAndInstructorsToChannel(savedExam.getCourse(), createdChannel); + channelService.createExamChannel(savedExam, Optional.ofNullable(exam.getChannelName())); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/exams/" + savedExam.getId())).body(savedExam); } @@ -928,8 +929,6 @@ public ResponseEntity registerCourseStudents(@PathVariable Long courseId, } examRegistrationService.addAllStudentsOfCourseToExam(courseId, exam); - Channel channel = channelRepository.findChannelByExamId(exam.getId()); - channelService.registerCourseStudentsToChannelAsynchronously(exam.getCourse(), channel); return ResponseEntity.ok().body(null); } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index bd1b3d4ee8f3..3d3a23e2b37e 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -41,7 +41,6 @@ import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.exam.*; -import de.tum.in.www1.artemis.domain.metis.ConversationParticipant; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; import de.tum.in.www1.artemis.domain.modeling.ModelingSubmission; @@ -60,7 +59,6 @@ import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.*; -import de.tum.in.www1.artemis.repository.metis.ConversationParticipantRepository; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; import de.tum.in.www1.artemis.security.SecurityUtils; @@ -177,9 +175,6 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe @Autowired private ChannelRepository channelRepository; - @Autowired - private ConversationParticipantRepository conversationParticipantRepository; - @Autowired private UserUtilService userUtilService; @@ -1001,13 +996,6 @@ void testCreateExam_asInstructor() throws Exception { Channel channelFromDB = channelRepository.findChannelByExamId(savedExam.getId()); assertThat(channelFromDB).isNotNull(); - - // Check that the conversation participants are added correctly to the exercise channel - await().until(() -> { - SecurityUtils.setAuthorizationObject(); - Set conversationParticipants = conversationParticipantRepository.findConversationParticipantByConversationId(channelFromDB.getId()); - return conversationParticipants.size() == 3; // only the instructors and tutors should be added to exam channel, not students (see @BeforeEach) - }); } private List createExamsWithInvalidDates(Course course) { @@ -1693,13 +1681,6 @@ void testAddAllRegisteredUsersToExam() throws Exception { assertThat(channelFromDB).isNotNull(); assertThat(channelFromDB.getExam()).isEqualTo(exam); assertThat(channelFromDB.getName()).isEqualTo(examChannel.getName()); - - // Check that the conversation participants are added correctly to the exercise channel - await().until(() -> { - SecurityUtils.setAuthorizationObject(); - Set conversationParticipants = conversationParticipantRepository.findConversationParticipantByConversationId(channelFromDB.getId()); - return conversationParticipants.size() == 4; // 3 students should be added (see @BeforeEach) + 1 new student = 5 - }); } @Test From d960b7da2d7c9ee52c3727efddd5dc8e681cd7cb Mon Sep 17 00:00:00 2001 From: Lennart Keller <44754405+lennart-keller@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:45:49 +0200 Subject: [PATCH 31/47] Development: Migrate legacy course-wide channels (#7151) --- .../changelog/20230907114600_changelog.xml | 28 +++++++++++++++++++ .../resources/config/liquibase/master.xml | 1 + 2 files changed, 29 insertions(+) create mode 100644 src/main/resources/config/liquibase/changelog/20230907114600_changelog.xml diff --git a/src/main/resources/config/liquibase/changelog/20230907114600_changelog.xml b/src/main/resources/config/liquibase/changelog/20230907114600_changelog.xml new file mode 100644 index 000000000000..e979182051f3 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230907114600_changelog.xml @@ -0,0 +1,28 @@ + + + + + Set the is_course_wide value to true for course-wide channels that have been created before this flag existed. + On the first hand, these are lecture and exercise channels, identified by an existing lecture_id or exercise_id. + On the other hand, these are the 4 default channels (announcement, organization, random, tech-support) created on course creation, + identified by the absence of a creator and no association with a tutorial group. + + + UPDATE conversation c + SET is_course_wide = true + WHERE c.lecture_id IS NOT null + OR c.exercise_id IS NOT null + OR c.exam_id IS NOT null + OR ( + c.creator_id IS null + AND NOT EXISTS ( + SELECT 1 + FROM tutorial_group AS tg + WHERE tg.tutorial_group_channel_id = c.id + ) + ) + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 26c149abbd2f..eb868ed1b3bf 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -52,6 +52,7 @@ + From 1ddc67615bd60258309caf39984b28a80614f2d3 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Mon, 11 Sep 2023 10:53:47 +0200 Subject: [PATCH 32/47] Programming exercises: Speedup archival (#7165) --- .../config/PublicResourcesConfiguration.java | 2 +- .../service/CourseExamExportService.java | 6 ++--- .../www1/artemis/service/CourseService.java | 4 ++- .../DataExportExerciseCreationService.java | 10 +++---- .../artemis/service/exam/ExamService.java | 6 +++-- .../service/exam/StudentExamService.java | 2 +- .../ProgrammingExerciseExportService.java | 27 ++++++++++++++----- .../artemis/service/util/TimeLogUtil.java | 22 ++++++++++----- ...ogrammingExerciseExportImportResource.java | 7 ++--- .../rest/dto/RepositoryExportOptionsDTO.java | 1 + .../artemis/course/CourseTestService.java | 10 +++---- 11 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/config/PublicResourcesConfiguration.java b/src/main/java/de/tum/in/www1/artemis/config/PublicResourcesConfiguration.java index 8a9a88798481..338dd09582dd 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/PublicResourcesConfiguration.java +++ b/src/main/java/de/tum/in/www1/artemis/config/PublicResourcesConfiguration.java @@ -82,7 +82,7 @@ private static String getClasspathPublicSubPathLocation(String... subPaths) { */ private static String getFileSystemPublicSubPathResourceLocation(String... subPaths) { var userDir = System.getProperty("user.dir"); - var morePaths = Stream.concat(Stream.of("public"), Arrays.stream(subPaths)).toList().toArray(new String[1 + subPaths.length]); + var morePaths = Stream.concat(Stream.of("public"), Arrays.stream(subPaths)).toArray(String[]::new); return "file:" + Path.of(userDir, morePaths) + "/"; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/CourseExamExportService.java b/src/main/java/de/tum/in/www1/artemis/service/CourseExamExportService.java index f2db41af11d6..8edeb8dcff62 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/CourseExamExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/CourseExamExportService.java @@ -115,7 +115,7 @@ public Optional exportCourse(Course course, String outputDir, List Optional exportedCourse = zipExportedExercises(outputDir, exportErrors, notificationTopic, tmpCourseDir, exportedFiles); - log.info("Successfully exported course {}. The zip file is located at: {}", course.getId(), exportedCourse); + log.info("Successfully exported course {}. The zip file is located at: {}", course.getId(), exportedCourse.orElse(null)); return exportedCourse; } @@ -178,7 +178,7 @@ public Optional exportExam(Exam exam, String outputDir, List expor Optional exportedExamPath = zipExportedExercises(outputDir, exportErrors, notificationTopic, tempExamsDir, exportedExercises); - log.info("Successfully exported exam {}. The zip file is located at: {}", exam.getId(), exportedExamPath); + log.info("Successfully exported exam {}. The zip file is located at: {}", exam.getId(), exportedExamPath.orElse(null)); return exportedExamPath; } @@ -356,7 +356,7 @@ private List exportExercises(String notificationTopic, Set exerc // Export exercises for (var exercise : sortedExercises) { - log.info("Exporting exercise {} with id {} ", exercise.getTitle(), exercise.getId()); + log.info("Exporting {} exercise {} with id {} ", exercise.getType(), exercise.getTitle(), exercise.getId()); // Notify the user after the progress currentProgress++; diff --git a/src/main/java/de/tum/in/www1/artemis/service/CourseService.java b/src/main/java/de/tum/in/www1/artemis/service/CourseService.java index 3f24326ca201..aabbfac38301 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/CourseService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/CourseService.java @@ -738,6 +738,7 @@ public StatsForDashboardDTO getStatsForDashboardDTO(Course course) { */ @Async public void archiveCourse(Course course) { + long start = System.nanoTime(); SecurityUtils.setAuthorizationObject(); // Archiving a course is only possible after the course is over @@ -746,7 +747,7 @@ public void archiveCourse(Course course) { } // This contains possible errors encountered during the archive process - ArrayList exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); groupNotificationService.notifyInstructorGroupAboutCourseArchiveState(course, NotificationType.COURSE_ARCHIVE_STARTED, exportErrors); @@ -775,6 +776,7 @@ public void archiveCourse(Course course) { } groupNotificationService.notifyInstructorGroupAboutCourseArchiveState(course, NotificationType.COURSE_ARCHIVE_FINISHED, exportErrors); + log.info("archive course took {}", TimeLogUtil.formatDurationFrom(start)); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java index 0e33e0470a2f..f29034fbf690 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java @@ -7,9 +7,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -132,16 +130,16 @@ public void createProgrammingExerciseExport(ProgrammingExercise programmingExerc repositoryExportOptions.setCombineStudentCommits(false); repositoryExportOptions.setFilterLateSubmissionsIndividualDueDate(false); repositoryExportOptions.setExcludePracticeSubmissions(false); - repositoryExportOptions.setNormalizeCodeStyle(true); + repositoryExportOptions.setNormalizeCodeStyle(false); var listOfProgrammingExerciseParticipations = programmingExercise.getStudentParticipations().stream() .filter(studentParticipation -> studentParticipation instanceof ProgrammingExerciseStudentParticipation) .map(studentParticipation -> (ProgrammingExerciseStudentParticipation) studentParticipation).toList(); - List exportRepoErrors = new ArrayList<>(); + // we use this directory only to clone the repository and don't do this in our current directory because the current directory is part of the final data export // --> we can delete it after use var tempRepoWorkingDir = fileService.getTemporaryUniquePath(repoClonePath, 10); programmingExerciseExportService.exportStudentRepositories(programmingExercise, listOfProgrammingExerciseParticipations, repositoryExportOptions, tempRepoWorkingDir, - exerciseDir, exportRepoErrors); + exerciseDir, Collections.synchronizedList(new ArrayList<>())); createPlagiarismCaseInfoExport(programmingExercise, exerciseDir, userId); diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamService.java index cae59edc33ee..a35a35ea1c1c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamService.java @@ -1187,15 +1187,16 @@ public StatsForDashboardDTO getStatsForExamAssessmentDashboard(Course course, Lo */ @Async public void archiveExam(Exam exam) { + long start = System.nanoTime(); SecurityUtils.setAuthorizationObject(); - // Archiving a course is only possible after the exam is over + // Archiving an exam is only possible after the exam is over if (ZonedDateTime.now().isBefore(exam.getEndDate())) { return; } // This contains possible errors encountered during the archive process - ArrayList exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); groupNotificationService.notifyInstructorGroupAboutExamArchiveState(exam, NotificationType.EXAM_ARCHIVE_STARTED, exportErrors); @@ -1224,6 +1225,7 @@ public void archiveExam(Exam exam) { } groupNotificationService.notifyInstructorGroupAboutExamArchiveState(exam, NotificationType.EXAM_ARCHIVE_FINISHED, exportErrors); + log.info("archive exam took {}", TimeLogUtil.formatDurationFrom(start)); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java index bb6d6b097cf1..dd46b5396650 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java @@ -695,7 +695,7 @@ public CompletableFuture startExercises(Long examId) { generatedParticipations.size(), startedAt, lock); return null; })) - .toList().toArray(new CompletableFuture[studentExams.size()]); + .toArray(CompletableFuture[]::new); return CompletableFuture.allOf(futures).thenApply((emtpy) -> { threadPool.shutdown(); sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.get(), failedExamsCounter.get(), studentExams.size(), generatedParticipations.size(), startedAt, diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java index 2dbd072ccf6c..74326f490bc0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java @@ -11,6 +11,8 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -236,6 +238,7 @@ public Path exportProgrammingExerciseRepositories(ProgrammingExercise exercise, .map(studentParticipation -> (ProgrammingExerciseStudentParticipation) studentParticipation).sorted(Comparator.comparing(DomainObject::getId)).toList(); var exportOptions = new RepositoryExportOptionsDTO(); exportOptions.setAnonymizeRepository(false); + exportOptions.setExportAllParticipants(true); // Export student repositories and add them to list var exportedStudentRepositoryFiles = exportStudentRepositories(exercise, studentParticipations, exportOptions, outputDir, outputDir, exportErrors).stream() @@ -528,27 +531,36 @@ public List exportStudentRepositories(ProgrammingExercise programmingExerc RepositoryExportOptionsDTO repositoryExportOptions, Path workingDir, Path outputDir, List exportErrors) { var programmingExerciseId = programmingExercise.getId(); if (repositoryExportOptions.isExportAllParticipants()) { - log.info("Request to export all student or team repositories of programming exercise {} with title '{}'", programmingExerciseId, programmingExercise.getTitle()); + log.info("Request to export all {} student or team repositories of programming exercise {} with title '{}'", participations.size(), programmingExerciseId, + programmingExercise.getTitle()); } else { - log.info("Request to export the repositories of programming exercise {} with title '{}' of the following students or teams: {}", programmingExerciseId, - programmingExercise.getTitle(), participations.stream().map(StudentParticipation::getParticipantIdentifier).collect(Collectors.joining(", "))); + log.info("Request to export the repositories of programming exercise {} with title '{}' of {} students or teams", programmingExerciseId, programmingExercise.getTitle(), + participations.size()); + log.debug("Export repositories for students or teams: {}", + participations.stream().map(StudentParticipation::getParticipantIdentifier).collect(Collectors.joining(", "))); } - List exportedStudentRepositories = new ArrayList<>(); - participations.forEach(participation -> { + List exportedStudentRepositories = Collections.synchronizedList(new ArrayList<>()); + + log.info("export student repositories for programming exercise {} in parallel", programmingExercise.getId()); + var threadPool = Executors.newFixedThreadPool(10); + var futures = participations.stream().map(participation -> CompletableFuture.runAsync(() -> { try { + log.debug("invoke createZipForRepositoryWithParticipation for participation {}", participation.getId()); Path zipFile = createZipForRepositoryWithParticipation(programmingExercise, participation, repositoryExportOptions, workingDir, outputDir); if (zipFile != null) { exportedStudentRepositories.add(zipFile); } } - catch (Exception e) { + catch (Exception exception) { var error = "Failed to export the student repository with participation: " + participation.getId() + " for programming exercise '" + programmingExercise.getTitle() + "' (id: " + programmingExercise.getId() + ") because the repository couldn't be downloaded. "; exportErrors.add(error); } - }); + }, threadPool).toCompletableFuture()).toArray(CompletableFuture[]::new); + // wait until all operations finish + CompletableFuture.allOf(futures).thenRun(threadPool::shutdown).join(); return exportedStudentRepositories; } @@ -633,6 +645,7 @@ private Path createZipForRepositoryWithParticipation(final ProgrammingExercise p return null; } + // TODO: this operation is only necessary if the repo was not newly cloned gitService.resetToOriginHead(repository); if (repositoryExportOptions.isFilterLateSubmissions()) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/util/TimeLogUtil.java b/src/main/java/de/tum/in/www1/artemis/service/util/TimeLogUtil.java index ee3428af1373..2d7a4bf7a8c7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/util/TimeLogUtil.java +++ b/src/main/java/de/tum/in/www1/artemis/service/util/TimeLogUtil.java @@ -10,15 +10,23 @@ public class TimeLogUtil { */ public static String formatDurationFrom(long timeNanoStart) { long durationInMicroSeconds = (System.nanoTime() - timeNanoStart) / 1000; - if (durationInMicroSeconds > 1000) { - double durationInMilliSeconds = durationInMicroSeconds / 1000.0; - if (durationInMilliSeconds > 1000) { - double durationInSeconds = durationInMilliSeconds / 1000.0; - return roundOffTo2DecPlaces(durationInSeconds) + "s"; - } + if (durationInMicroSeconds < 1000) { + return durationInMicroSeconds + "µs"; + } + double durationInMilliSeconds = durationInMicroSeconds / 1000.0; + if (durationInMilliSeconds < 1000) { return roundOffTo2DecPlaces(durationInMilliSeconds) + "ms"; } - return durationInMicroSeconds + "µs"; + double durationInSeconds = durationInMilliSeconds / 1000.0; + if (durationInSeconds < 60) { + return roundOffTo2DecPlaces(durationInSeconds) + "sec"; + } + double durationInMinutes = durationInSeconds / 60.0; + if (durationInMinutes < 60) { + return roundOffTo2DecPlaces(durationInMinutes) + "min"; + } + double durationInHours = durationInMinutes / 60.0; + return roundOffTo2DecPlaces(durationInHours) + "hours"; } private static String roundOffTo2DecPlaces(double val) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseExportImportResource.java index aa74a6c01503..689bb01ba706 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseExportImportResource.java @@ -8,10 +8,7 @@ import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import javax.validation.constraints.NotNull; @@ -270,7 +267,7 @@ public ResponseEntity exportInstructorExercise(@PathVariable long exer long start = System.nanoTime(); Path path; try { - path = programmingExerciseExportService.exportProgrammingExerciseInstructorMaterial(programmingExercise, new ArrayList<>()); + path = programmingExerciseExportService.exportProgrammingExerciseInstructorMaterial(programmingExercise, Collections.synchronizedList(new ArrayList<>())); } catch (Exception e) { log.error("Error while exporting programming exercise with id " + exerciseId + " for instructor", e); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/RepositoryExportOptionsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/RepositoryExportOptionsDTO.java index 17a737abcf3b..e95197b598e0 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/RepositoryExportOptionsDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/RepositoryExportOptionsDTO.java @@ -8,6 +8,7 @@ * This is a dto for the repository export options. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) +// TODO: we should convert this into a Record public class RepositoryExportOptionsDTO { private boolean exportAllParticipants; diff --git a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java index 00511d38be22..bd77cb71ecaa 100644 --- a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java @@ -2102,7 +2102,7 @@ else if (exercise instanceof TextExercise) { } private List archiveCourseAndExtractFiles(Course course) throws IOException { - List exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); Optional exportedCourse = courseExamExportService.exportCourse(course, courseArchivesDirPath, exportErrors); assertThat(exportedCourse).isNotEmpty(); @@ -2118,7 +2118,7 @@ private List archiveCourseAndExtractFiles(Course course) throws IOExceptio public void testExportCourse_cannotCreateTmpDir() throws Exception { Course course = courseUtilService.createCourseWithTestModelingAndFileUploadExercisesAndSubmissions(userPrefix); - List exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); MockedStatic mockedFiles = mockStatic(Files.class); mockedFiles.when(() -> Files.createDirectories(argThat(path -> path.toString().contains("exports")))).thenThrow(IOException.class); @@ -2130,7 +2130,7 @@ public void testExportCourse_cannotCreateTmpDir() throws Exception { public void testExportCourse_cannotCreateCourseExercisesDir() throws Exception { Course course = courseUtilService.createCourseWithTestModelingAndFileUploadExercisesAndSubmissions(userPrefix); - List exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); MockedStatic mockedFiles = mockStatic(Files.class); mockedFiles.when(() -> Files.createDirectory(argThat(path -> path.toString().contains("course-exercises")))).thenThrow(IOException.class); @@ -2142,7 +2142,7 @@ public void testExportCourse_cannotCreateCourseExercisesDir() throws Exception { public void testExportCourseExam_cannotCreateTmpDir() throws Exception { Course course = courseUtilService.createCourseWithExamAndExercises(userPrefix); - List exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); Optional exam = examRepo.findByCourseId(course.getId()).stream().findFirst(); assertThat(exam).isPresent(); @@ -2157,7 +2157,7 @@ public void testExportCourseExam_cannotCreateTmpDir() throws Exception { public void testExportCourseExam_cannotCreateExamsDir() throws Exception { Course course = courseUtilService.createCourseWithExamAndExercises(userPrefix); - List exportErrors = new ArrayList<>(); + List exportErrors = Collections.synchronizedList(new ArrayList<>()); course = courseRepo.findWithEagerExercisesById(course.getId()); From f3967d973a65d6c95cbdfeca8c5da0031ada32d4 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 12 Sep 2023 18:38:41 +0200 Subject: [PATCH 33/47] Programming exercises: Fix issues with binary files when importing and starting programming exercises (#7178) --- .../artemis/aop/logging/LoggingAspect.java | 8 +- .../tum/in/www1/artemis/domain/UserGroup.java | 27 ++++- .../in/www1/artemis/service/FileService.java | 106 +++++++++++------- .../vcs/AbstractVersionControlService.java | 31 ++++- .../ProgrammingExerciseExportService.java | 4 +- ...grammingExerciseImportFromFileService.java | 5 +- .../ProgrammingExerciseImportService.java | 21 ++-- .../ProgrammingExerciseRepositoryService.java | 2 +- .../www1/artemis/service/FileServiceTest.java | 16 +-- 9 files changed, 148 insertions(+), 72 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java b/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java index 1fe5d3b127fb..b78cc86e65cb 100644 --- a/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java +++ b/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java @@ -10,6 +10,7 @@ import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; +import de.tum.in.www1.artemis.service.connectors.vcs.AbstractVersionControlService; import tech.jhipster.config.JHipsterConstants; /** @@ -53,10 +54,14 @@ public void applicationPackagePointcut() { */ @AfterThrowing(pointcut = "applicationPackagePointcut() && springBeanPointcut()", throwing = "e") public void logAfterThrowing(JoinPoint joinPoint, Throwable e) { + if (AbstractVersionControlService.isReadFullyShortReadOfBlockException(e)) { + // ignore + return; + } + if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { log.error("Exception in {}.{}() with cause = \'{}\' and exception = \'{}\'", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), e.getCause() != null ? e.getCause() : "NULL", e.getMessage(), e); - } else { log.error("Exception in {}.{}() with cause = {}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), @@ -86,7 +91,6 @@ public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { } catch (IllegalArgumentException e) { log.error("Illegal argument: {} in {}.{}()", Arrays.toString(joinPoint.getArgs()), joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName()); - throw e; } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/UserGroup.java b/src/main/java/de/tum/in/www1/artemis/domain/UserGroup.java index 091be72ed18e..ebb09576e010 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/UserGroup.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/UserGroup.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.domain; import java.io.Serializable; +import java.util.Objects; import javax.persistence.*; @@ -18,12 +19,36 @@ public class UserGroup { private String group; @Embeddable - public class UserGroupKey implements Serializable { + public static class UserGroupKey implements Serializable { @Column(name = "user_id") private Long userId; @Column(name = "`groups`") private String group; + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + UserGroupKey that = (UserGroupKey) o; + + if (!Objects.equals(userId, that.userId)) { + return false; + } + return Objects.equals(group, that.group); + } + + @Override + public int hashCode() { + int result = userId != null ? userId.hashCode() : 0; + result = 31 * result + (group != null ? group.hashCode() : 0); + return result; + } } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileService.java b/src/main/java/de/tum/in/www1/artemis/service/FileService.java index 9e7ff41a1af9..84877534deb6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/FileService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/FileService.java @@ -1,11 +1,11 @@ package de.tum.in.www1.artemis.service; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import java.io.*; import java.net.URLDecoder; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -59,6 +59,13 @@ public class FileService implements DisposableBean { private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors()); + /** + * A list of common binary file extensions. + * Extensions must be lower-case without leading dots. + */ + private static final Set binaryFileExtensions = Set.of("png", "jpg", "jpeg", "heic", "gif", "tiff", "psd", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pages", + "numbers", "key", "odt", "zip", "rar", "7z", "tar", "iso", "mdb", "sqlite", "exe", "jar"); + /** * The list of file extensions that are allowed to be uploaded in a Markdown editor. * Extensions must be lower-case without leading dots. @@ -631,7 +638,7 @@ private Path getTargetPath(final Resource resource, final Path prefix, final Pat filePath = resource.getFile().toPath(); } else { - final String url = URLDecoder.decode(resource.getURL().toString(), StandardCharsets.UTF_8); + final String url = URLDecoder.decode(resource.getURL().toString(), UTF_8); filePath = Path.of(url); } @@ -738,7 +745,7 @@ public void replacePlaceholderSections(String filePath, Map sec throw new FilePathParsingException("File " + filePath + " should be updated but does not exist."); } - try (var reader = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8)); var writer = new BufferedWriter(new FileWriter(tempFile, StandardCharsets.UTF_8))) { + try (var reader = new BufferedReader(new FileReader(file, UTF_8)); var writer = new BufferedWriter(new FileWriter(tempFile, UTF_8))) { Map.Entry matchingStartPattern = null; String line = reader.readLine(); while (line != null) { @@ -856,29 +863,27 @@ public void replaceVariablesInFileName(String startPath, String targetString, St /** * This replaces all occurrences of the target Strings with the replacement Strings in the given file and saves the file *

- * {@link #replaceVariablesInFile(String, Map) replaceVariablesInFile} + * {@link #replaceVariablesInFile(Path, Map) replaceVariablesInFile} * * @param startPath the path where the start directory is located * @param replacements the replacements that should be applied - * @throws IOException if an issue occurs on file access for the replacement of the variables. */ - public void replaceVariablesInFileRecursive(String startPath, Map replacements) throws IOException { + public void replaceVariablesInFileRecursive(Path startPath, Map replacements) { replaceVariablesInFileRecursive(startPath, replacements, Collections.emptyList()); } /** * This replaces all occurrences of the target Strings with the replacement Strings in the given file and saves the file *

- * {@link #replaceVariablesInFile(String, Map) replaceVariablesInFile} + * {@link #replaceVariablesInFile(Path, Map) replaceVariablesInFile} * * @param startPath the path where the start directory is located * @param replacements the replacements that should be applied * @param filesToIgnore the name of files for which no replacement should be done - * @throws IOException if an issue occurs on file access for the replacement of the variables. */ - public void replaceVariablesInFileRecursive(String startPath, Map replacements, List filesToIgnore) throws IOException { + public void replaceVariablesInFileRecursive(Path startPath, Map replacements, List filesToIgnore) { log.debug("Replacing {} in files in directory {}", replacements, startPath); - File directory = new File(startPath); + File directory = startPath.toFile(); if (!directory.exists() || !directory.isDirectory()) { throw new RuntimeException("Files in directory " + startPath + " should be replaced but the directory does not exist."); } @@ -889,7 +894,7 @@ public void replaceVariablesInFileRecursive(String startPath, Map replacements) throws IOException { + public void replaceVariablesInFile(Path filePath, Map replacements) { log.debug("Replacing {} in file {}", replacements, filePath); - // https://stackoverflow.com/questions/3935791/find-and-replace-words-lines-in-a-file - Path replaceFilePath = Path.of(filePath); - Charset charset = StandardCharsets.UTF_8; - String fileContent = Files.readString(replaceFilePath, charset); - for (Map.Entry replacement : replacements.entrySet()) { - fileContent = fileContent.replace(replacement.getKey(), replacement.getValue()); + if (isBinaryFile(filePath)) { + // do not try to read binary files with 'readString' + return; + } + try { + // Note: Java does not offer a good way to check if a file is binary or not. If the basic check above fails (e.g. due to a custom binary file from an instructor), + // but the file is still binary, we try to read it. In case the method readString fails, we only log this below, but continue, because the exception should NOT + // interrupt the ongoing process + String fileContent = Files.readString(filePath, UTF_8); + for (Map.Entry replacement : replacements.entrySet()) { + fileContent = fileContent.replace(replacement.getKey(), replacement.getValue()); + } + Files.writeString(filePath, fileContent, UTF_8); } - Files.writeString(replaceFilePath, fileContent, charset); + catch (IOException ex) { + log.warn("Exception {} occurred when trying to replace {} in (binary) file {}", ex.getMessage(), replacements, filePath); + // continue + } + } + + /** + * very simple and non-exhaustive check for the most common binary files such as images + * Unfortunately, Java cannot determine this correctly, so we need to provide typical file endings here + * + * @param filePath the path of the file + * @return whether the simple check for file endings determines the underlying file to be binary (true) or not (false) + */ + private static boolean isBinaryFile(Path filePath) { + final String fileExtension = FilenameUtils.getExtension(filePath.getFileName().toString()); + return binaryFileExtensions.stream().anyMatch(fileExtension::equalsIgnoreCase); } /** * This normalizes all line endings to UNIX-line-endings recursively from the startPath. *

- * {@link #normalizeLineEndings(String) normalizeLineEndings} + * {@link #normalizeLineEndings(Path) normalizeLineEndings} * * @param startPath the path where the start directory is located * @throws IOException if an issue occurs on file access for the normalizing of the line endings. */ - public void normalizeLineEndingsDirectory(String startPath) throws IOException { + public void normalizeLineEndingsDirectory(Path startPath) throws IOException { log.debug("Normalizing file endings in directory {}", startPath); - File directory = new File(startPath); + File directory = startPath.toFile(); if (!directory.exists() || !directory.isDirectory()) { throw new RuntimeException("File endings in directory " + startPath + " should be normalized but the directory does not exist."); } @@ -948,7 +974,7 @@ public void normalizeLineEndingsDirectory(String startPath) throws IOException { Collection files = FileUtils.listFiles(directory, FileFilterUtils.trueFileFilter(), directoryFileFilter); for (File file : files) { - normalizeLineEndings(file.getAbsolutePath()); + normalizeLineEndings(file.toPath()); } } @@ -960,28 +986,29 @@ public void normalizeLineEndingsDirectory(String startPath) throws IOException { * @param filePath the path where the file is located * @throws IOException if an issue occurs on file access for the normalizing of the line endings. */ - public void normalizeLineEndings(String filePath) throws IOException { + public void normalizeLineEndings(Path filePath) throws IOException { log.debug("Normalizing line endings in file {}", filePath); + if (isBinaryFile(filePath)) { + // do not try to read binary files with 'readString' + return; + } // https://stackoverflow.com/questions/3776923/how-can-i-normalize-the-eol-character-in-java - Path replaceFilePath = Path.of(filePath); - Charset charset = StandardCharsets.UTF_8; - - String fileContent = Files.readString(replaceFilePath, charset); + String fileContent = Files.readString(filePath, UTF_8); fileContent = fileContent.replaceAll("\\r\\n?", "\n"); - Files.writeString(replaceFilePath, fileContent, charset); + Files.writeString(filePath, fileContent, UTF_8); } /** * This converts all files to the UTF-8 encoding recursively from the startPath. *

- * {@link #convertToUTF8(String) convertToUTF8} + * {@link #convertToUTF8(Path) convertToUTF8} * * @param startPath the path where the start directory is located * @throws IOException if an issue occurs on file access when converting to UTF-8. */ - public void convertToUTF8Directory(String startPath) throws IOException { + public void convertToUTF8Directory(Path startPath) throws IOException { log.debug("Converting files in directory {} to UTF-8", startPath); - File directory = new File(startPath); + File directory = startPath.toFile(); if (!directory.exists() || !directory.isDirectory()) { throw new RuntimeException("Files in directory " + startPath + " should be converted to UTF-8 but the directory does not exist."); } @@ -992,7 +1019,7 @@ public void convertToUTF8Directory(String startPath) throws IOException { Collection files = FileUtils.listFiles(directory, FileFilterUtils.trueFileFilter(), directoryFileFilter); for (File file : files) { - convertToUTF8(file.getAbsolutePath()); + convertToUTF8(file.toPath()); } } @@ -1003,17 +1030,16 @@ public void convertToUTF8Directory(String startPath) throws IOException { * @param filePath the path where the file is located * @throws IOException if an issue occurs on file access when converting to UTF-8. */ - public void convertToUTF8(String filePath) throws IOException { + public void convertToUTF8(Path filePath) throws IOException { log.debug("Converting file {} to UTF-8", filePath); - Path replaceFilePath = Path.of(filePath); - byte[] contentArray = Files.readAllBytes(replaceFilePath); + byte[] contentArray = Files.readAllBytes(filePath); Charset charset = detectCharset(contentArray); log.debug("Detected charset for file {} is {}", filePath, charset.name()); String fileContent = new String(contentArray, charset); - Files.writeString(replaceFilePath, fileContent, StandardCharsets.UTF_8); + Files.writeString(filePath, fileContent, UTF_8); } /** @@ -1132,7 +1158,7 @@ public void createDirectory(Path path) { * @return Path to the written file */ public Path writeStringToFile(String stringToWrite, Path path) { - try (var outStream = new OutputStreamWriter(new FileOutputStream(path.toString()), StandardCharsets.UTF_8)) { + try (var outStream = new OutputStreamWriter(new FileOutputStream(path.toString()), UTF_8)) { outStream.write(stringToWrite); } catch (IOException e) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/AbstractVersionControlService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/AbstractVersionControlService.java index e4810c43451d..8523a53018e6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/AbstractVersionControlService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/AbstractVersionControlService.java @@ -1,12 +1,12 @@ package de.tum.in.www1.artemis.service.connectors.vcs; -import static de.tum.in.www1.artemis.config.Constants.*; - import java.io.IOException; import java.nio.file.Path; +import java.util.Objects; import org.apache.commons.io.FileUtils; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.internal.JGitText; import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -123,8 +123,15 @@ public VcsRepositoryUrl copyRepository(String sourceProjectKey, String sourceRep // copy by pushing the source's content to the target's repo gitService.pushSourceToTargetRepo(targetRepo, targetRepoUrl, sourceBranch); } - catch (GitAPIException | VersionControlException e) { + catch (GitAPIException | VersionControlException ex) { + if (isReadFullyShortReadOfBlockException(ex)) { + // NOTE: we ignore this particular error: it sometimes happens when pushing code that includes binary files, however the push operation typically worked correctly + // TODO: verify that the push operation actually worked correctly, e.g. by comparing the number of commits in the source and target repo + log.warn("TransportException/EOFException with 'Short read of block' when copying repository {} to {}. Will ignore it", sourceRepoUrl, targetRepoUrl); + return targetRepoUrl; + } Path localPath = gitService.getDefaultLocalPathOfRepo(targetRepoUrl); + // clean up in case of an error try { if (targetRepo != null) { // delete the target repo if an error occurs @@ -135,16 +142,30 @@ public VcsRepositoryUrl copyRepository(String sourceProjectKey, String sourceRep FileUtils.deleteDirectory(localPath.toFile()); } } - catch (IOException ex) { + catch (IOException ioException) { // ignore log.error("Could not delete directory of the failed cloned repository in: {}", localPath); } - throw new VersionControlException("Could not copy repository " + sourceRepositoryName + " to the target repository " + targetRepositoryName, e); + throw new VersionControlException("Could not copy repository " + sourceRepositoryName + " to the target repository " + targetRepositoryName, ex); } return targetRepoUrl; } + /** + * checks for a specific exception that we would like to ignore + * + * @param ex the exception + * @return whether we found the specific one or not + */ + public static boolean isReadFullyShortReadOfBlockException(Throwable ex) { + return ex instanceof org.eclipse.jgit.api.errors.TransportException transportException + && transportException.getCause() instanceof org.eclipse.jgit.errors.TransportException innerTransportException + && innerTransportException.getCause() instanceof java.io.EOFException eofException && eofException.getMessage().equals(JGitText.get().shortReadOfBlock) + && Objects.equals(eofException.getStackTrace()[0].getClassName(), "org.eclipse.jgit.util.IO") + && Objects.equals(eofException.getStackTrace()[0].getMethodName(), "readFully"); + } + @Override public String getOrRetrieveBranchOfParticipation(ProgrammingExerciseParticipation participation) { if (participation instanceof ProgrammingExerciseStudentParticipation studentParticipation) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java index 74326f490bc0..ac7bed156470 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java @@ -670,8 +670,8 @@ private Path createZipForRepositoryWithParticipation(final ProgrammingExercise p if (repositoryExportOptions.isNormalizeCodeStyle()) { try { log.debug("Normalizing code style for participation {}", participation); - fileService.normalizeLineEndingsDirectory(repository.getLocalPath().toString()); - fileService.convertToUTF8Directory(repository.getLocalPath().toString()); + fileService.normalizeLineEndingsDirectory(repository.getLocalPath()); + fileService.convertToUTF8Directory(repository.getLocalPath()); } catch (IOException ex) { log.warn("Cannot normalize code style in the repository {} due to the following exception: {}", repository.getLocalPath(), ex.getMessage()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java index 2151f85aff20..61dce26dc239 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportFromFileService.java @@ -78,6 +78,7 @@ public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise checkRepositoriesExist(importExerciseDir); var oldShortName = getProgrammingExerciseFromDetailsFile(importExerciseDir).getShortName(); programmingExerciseService.validateNewProgrammingExerciseSettings(programmingExerciseForImport, course); + // TODO: creating the whole exercise (from template) is a bad solution in this case, we do not want the template content, instead we want the file content of the zip importedProgrammingExercise = programmingExerciseService.createProgrammingExercise(programmingExerciseForImport); if (Boolean.TRUE.equals(programmingExerciseForImport.isStaticCodeAnalysisEnabled())) { staticCodeAnalysisService.createDefaultCategories(importedProgrammingExercise); @@ -131,9 +132,9 @@ private void importRepositoriesFromFile(ProgrammingExercise newExercise, Path ba gitService.commitAndPush(testRepo, "Import tests from file", true, null); } - private void replaceImportedExerciseShortName(Map replacements, Repository... repositories) throws IOException { + private void replaceImportedExerciseShortName(Map replacements, Repository... repositories) { for (Repository repository : repositories) { - fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toString(), replacements, SHORT_NAME_REPLACEMENT_EXCLUSIONS); + fileService.replaceVariablesInFileRecursive(repository.getLocalPath(), replacements, SHORT_NAME_REPLACEMENT_EXCLUSIONS); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java index 18484590eded..f3a347493af8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseImportService.java @@ -95,18 +95,18 @@ public void importRepositories(final ProgrammingExercise templateExercise, final String sourceBranch = versionControl.getOrRetrieveBranchOfExercise(templateExercise); + // TODO: in case one of those operations fail, we should do error handling and revert all previous operations versionControl.copyRepository(sourceProjectKey, templateRepoName, sourceBranch, targetProjectKey, RepositoryType.TEMPLATE.getName()); versionControl.copyRepository(sourceProjectKey, solutionRepoName, sourceBranch, targetProjectKey, RepositoryType.SOLUTION.getName()); versionControl.copyRepository(sourceProjectKey, testRepoName, sourceBranch, targetProjectKey, RepositoryType.TESTS.getName()); - List auxiliaryRepositories = templateExercise.getAuxiliaryRepositories(); - for (int i = 0; i < auxiliaryRepositories.size(); i++) { - AuxiliaryRepository auxiliaryRepository = auxiliaryRepositories.get(i); - String repositoryUrl = versionControl - .copyRepository(sourceProjectKey, auxiliaryRepository.getRepositoryName(), sourceBranch, targetProjectKey, auxiliaryRepository.getName()).toString(); - AuxiliaryRepository newAuxiliaryRepository = newExercise.getAuxiliaryRepositories().get(i); - newAuxiliaryRepository.setRepositoryUrl(repositoryUrl); - auxiliaryRepositoryRepository.save(newAuxiliaryRepository); + List auxRepos = templateExercise.getAuxiliaryRepositories(); + for (int i = 0; i < auxRepos.size(); i++) { + AuxiliaryRepository auxRepo = auxRepos.get(i); + var repoUrl = versionControl.copyRepository(sourceProjectKey, auxRepo.getRepositoryName(), sourceBranch, targetProjectKey, auxRepo.getName()).toString(); + AuxiliaryRepository newAuxRepo = newExercise.getAuxiliaryRepositories().get(i); + newAuxRepo.setRepositoryUrl(repoUrl); + auxiliaryRepositoryRepository.save(newAuxRepo); } // Unprotect the default branch of the template exercise repo. @@ -255,12 +255,11 @@ private void adjustProjectNames(ProgrammingExercise templateExercise, Programmin * @param repositoryName the name of the repository that should be adjusted * @param user the user which performed the action (used as Git author) * @throws GitAPIException If the checkout/push of one repository fails - * @throws IOException If the values in the files could not be replaced */ - private void adjustProjectName(Map replacements, String projectKey, String repositoryName, User user) throws GitAPIException, IOException { + private void adjustProjectName(Map replacements, String projectKey, String repositoryName, User user) throws GitAPIException { final var repositoryUrl = versionControlService.orElseThrow().getCloneRepositoryUrl(projectKey, repositoryName); Repository repository = gitService.getOrCheckoutRepository(repositoryUrl, true); - fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toAbsolutePath().toString(), replacements, List.of("gradle-wrapper.jar")); + fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toAbsolutePath(), replacements, List.of("gradle-wrapper.jar")); gitService.stageAllChanges(repository); gitService.commitAndPush(repository, "Template adjusted by Artemis", true, user); repository.setFiles(null); // Clear cache to avoid multiple commits when Artemis server is not restarted between attempts diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java index 4bc551968cbc..688cacd177a0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseRepositoryService.java @@ -580,7 +580,7 @@ void replacePlaceholders(final ProgrammingExercise programmingExercise, final Re replacements.put("${exerciseName}", programmingExercise.getTitle()); replacements.put("${studentWorkingDirectory}", Constants.STUDENT_WORKING_DIRECTORY); replacements.put("${packaging}", programmingExercise.hasSequentialTestRuns() ? "pom" : "jar"); - fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toAbsolutePath().toString(), replacements, List.of("gradle-wrapper.jar")); + fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toAbsolutePath(), replacements, List.of("gradle-wrapper.jar")); } /** diff --git a/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java index c49477ca5012..30a3dc719c1d 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java @@ -104,7 +104,7 @@ void normalizeFileEndingsUnix_normalized() throws IOException { int size = Files.readAllBytes(Path.of(".", "exportTest", "LineEndingsUnix.java")).length; assertThat(size).isEqualTo(129); - fileService.normalizeLineEndings(Path.of(".", "exportTest", "LineEndingsUnix.java").toString()); + fileService.normalizeLineEndings(Path.of(".", "exportTest", "LineEndingsUnix.java")); size = Files.readAllBytes(Path.of(".", "exportTest", "LineEndingsUnix.java")).length; assertThat(size).isEqualTo(129); } @@ -122,7 +122,7 @@ void normalizeFileEndingsWindows_normalized() throws IOException { int size = Files.readAllBytes(Path.of(".", "exportTest", "LineEndingsWindows.java")).length; assertThat(size).isEqualTo(136); - fileService.normalizeLineEndings(Path.of(".", "exportTest", "LineEndingsWindows.java").toString()); + fileService.normalizeLineEndings(Path.of(".", "exportTest", "LineEndingsWindows.java")); size = Files.readAllBytes(Path.of(".", "exportTest", "LineEndingsWindows.java")).length; assertThat(size).isEqualTo(129); } @@ -140,7 +140,7 @@ void normalizeEncodingISO_8559_1() throws IOException { Charset charset = fileService.detectCharset(Files.readAllBytes(Path.of(".", "exportTest", "EncodingISO_8559_1.java"))); assertThat(charset).isEqualTo(StandardCharsets.ISO_8859_1); - fileService.convertToUTF8(Path.of(".", "exportTest", "EncodingISO_8559_1.java").toString()); + fileService.convertToUTF8(Path.of(".", "exportTest", "EncodingISO_8559_1.java")); charset = fileService.detectCharset(Files.readAllBytes(Path.of(".", "exportTest", "EncodingISO_8559_1.java"))); assertThat(charset).isEqualTo(StandardCharsets.UTF_8); } @@ -156,7 +156,7 @@ void replacePlaceHolder() throws IOException { Map replacements = new HashMap<>(); replacements.put("${exerciseName}", "SomeCoolExerciseName"); - fileService.replaceVariablesInFileRecursive(pomXml.getParent(), replacements); + fileService.replaceVariablesInFileRecursive(pomXml.getParentFile().toPath(), replacements); fileContent = FileUtils.readFileToString(pomXml, Charset.defaultCharset()); assertThat(fileContent).doesNotContain("${exerciseName}").contains("SomeCoolExerciseName"); @@ -173,7 +173,7 @@ void replacePlaceHolderIgnoreNames() throws IOException { Map replacements = new HashMap<>(); replacements.put("${exerciseName}", "SomeCoolExerciseName"); - fileService.replaceVariablesInFileRecursive(pomXml.getParent(), replacements, List.of("pom.xml")); + fileService.replaceVariablesInFileRecursive(pomXml.getParentFile().toPath(), replacements, List.of("pom.xml")); fileContent = FileUtils.readFileToString(pomXml, Charset.defaultCharset()); assertThat(fileContent).contains("${exerciseName}").doesNotContain("SomeCoolExerciseName"); @@ -288,19 +288,19 @@ void testPublicPathForActualPathOrThrow_shouldThrowException() { @Test void testReplaceVariablesInFileRecursive_shouldThrowException() { - assertThatRuntimeException().isThrownBy(() -> fileService.replaceVariablesInFileRecursive("some-path", new HashMap<>())) + assertThatRuntimeException().isThrownBy(() -> fileService.replaceVariablesInFileRecursive(Path.of("some-path"), new HashMap<>())) .withMessageEndingWith("should be replaced but the directory does not exist."); } @Test void testNormalizeLineEndingsDirectory_shouldThrowException() { - assertThatRuntimeException().isThrownBy(() -> fileService.normalizeLineEndingsDirectory("some-path")) + assertThatRuntimeException().isThrownBy(() -> fileService.normalizeLineEndingsDirectory(Path.of("some-path"))) .withMessageEndingWith("should be normalized but the directory does not exist."); } @Test void testConvertToUTF8Directory_shouldThrowException() { - assertThatRuntimeException().isThrownBy(() -> fileService.convertToUTF8Directory("some-path")) + assertThatRuntimeException().isThrownBy(() -> fileService.convertToUTF8Directory(Path.of("some-path"))) .withMessageEndingWith("should be converted to UTF-8 but the directory does not exist."); } From 64187ce9be0ca6492bc172afe9477ca6dd191357 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 12 Sep 2023 19:07:04 +0200 Subject: [PATCH 34/47] Development: Bump version to 6.5.0 --- build.gradle | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 27e7282568f4..19452c131ffa 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ plugins { } group = "de.tum.in.www1.artemis" -version = "6.4.3" +version = "6.5.0" description = "Interactive Learning with Individual Feedback" sourceCompatibility=17 diff --git a/package-lock.json b/package-lock.json index 426607e0d086..6ce1f04ecd2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis", - "version": "6.4.3", + "version": "6.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "6.4.3", + "version": "6.5.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c741d72fd202..33c99708233e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "6.4.3", + "version": "6.5.0", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", From dc35b8cc23c2bf6fdea2ea80784305c365c9bd61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:27:36 +0200 Subject: [PATCH 35/47] Development: Update version for docker/setup-qemu-action from 2 to 3 (#7183) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0837a57891cf..cb2cb27529f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,7 +115,7 @@ jobs: if: ${{ github.event_name == 'release' }} uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 # Build and Push to GitHub Container Registry From 4321727b537cf572df5b5935621c522602cb1c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:27:54 +0200 Subject: [PATCH 36/47] Development: Update version for docker/setup-buildx-action from 2 to 3 (#7184) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb2cb27529f4..8d07af38982d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -117,7 +117,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 # Build and Push to GitHub Container Registry - name: Login to GitHub Container Registry uses: docker/login-action@v2 From 4332d9e11655ed417350356ef0a3acf2c509df4a Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Wed, 13 Sep 2023 18:20:51 +0200 Subject: [PATCH 37/47] Development: Fix sorry cypress for cypress version 13 (#7191) --- src/test/cypress/cypress.config.ts | 10 ++++- src/test/cypress/package-lock.json | 71 ++++++++++++++++++++---------- src/test/cypress/package.json | 10 ++--- 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src/test/cypress/cypress.config.ts b/src/test/cypress/cypress.config.ts index 50dce7d9019e..fa5c941d1de5 100644 --- a/src/test/cypress/cypress.config.ts +++ b/src/test/cypress/cypress.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'cypress'; import { cloudPlugin } from 'cypress-cloud/plugin'; +import fs from 'fs'; export default defineConfig({ clientCertificates: [ @@ -38,7 +39,6 @@ export default defineConfig({ screenshotsFolder: 'screenshots', videosFolder: 'videos', video: true, - videoUploadOnPasses: false, screenshotOnRunFailure: true, viewportWidth: 1920, viewportHeight: 1080, @@ -65,6 +65,14 @@ export default defineConfig({ return null; }, }); + on('after:spec', (spec: Cypress.Spec, results: CypressCommandLine.RunResult) => { + if (results && results.video) { + const failures = results.tests.some((test) => test.attempts.some((attempt) => attempt.state === 'failed')); + if (!failures) { + fs.unlinkSync(results.video); + } + } + }); on('before:browser:launch', (browser, launchOptions) => { launchOptions.args.push('--lang=en'); return launchOptions; diff --git a/src/test/cypress/package-lock.json b/src/test/cypress/package-lock.json index f96cb4c9180c..632fff350ea3 100644 --- a/src/test/cypress/package-lock.json +++ b/src/test/cypress/package-lock.json @@ -8,13 +8,13 @@ "license": "MIT", "devDependencies": { "@4tw/cypress-drag-drop": "2.2.4", - "@types/node": "20.5.9", - "cypress": "13.1.0", - "cypress-cloud": "1.9.4", + "@types/node": "20.6.0", + "cypress": "13.2.0", + "cypress-cloud": "1.10.0-beta.4", "cypress-file-upload": "5.0.8", "cypress-wait-until": "2.0.1", "typescript": "5.2.2", - "uuid": "9.0.0", + "uuid": "9.0.1", "wait-on": "7.0.1" } }, @@ -301,9 +301,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.5.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", - "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==", + "version": "20.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.0.tgz", + "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==", "dev": true }, "node_modules/@types/sinonjs__fake-timers": { @@ -904,15 +904,15 @@ } }, "node_modules/cypress": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.1.0.tgz", - "integrity": "sha512-LUKxCYlB973QBFls1Up4FAE9QIYobT+2I8NvvAwMfQS2YwsWbr6yx7y9hmsk97iqbHkKwZW3MRjoK1RToBFVdQ==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.2.0.tgz", + "integrity": "sha512-AvDQxBydE771GTq0TR4ZUBvv9m9ffXuB/ueEtpDF/6gOcvFR96amgwSJP16Yhqw6VhmwqspT5nAGzoxxB+D89g==", "dev": true, "hasInstallScript": true, "dependencies": { "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", - "@types/node": "^16.18.39", + "@types/node": "^18.17.5", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", @@ -962,9 +962,9 @@ } }, "node_modules/cypress-cloud": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/cypress-cloud/-/cypress-cloud-1.9.4.tgz", - "integrity": "sha512-zItu3zTtSOFMfKExlqOrWXA8A3aI2fhKLGuVDDVYFtW+uthrSlImVfIl+Yj+KB8lnJmLR/+z94Q3GvCPHKQzxg==", + "version": "1.10.0-beta.4", + "resolved": "https://registry.npmjs.org/cypress-cloud/-/cypress-cloud-1.10.0-beta.4.tgz", + "integrity": "sha512-8pe+ifmf8Uotx9lVL4Crq9LQokAa8U6/09p+wj9XEZoEiY/FJhbbvOygduqZmF6BaQaDAf5q1DagKKHmLXGErA==", "dev": true, "dependencies": { "@cypress/commit-info": "^2.2.0", @@ -975,8 +975,10 @@ "commander": "^10.0.0", "common-path-prefix": "^3.0.0", "cy2": "^3.4.2", + "date-fns": "^2.30.0", "debug": "^4.3.4", "execa": "^5.1.1", + "fast-safe-stringify": "^2.1.1", "getos": "^3.2.1", "globby": "^11.1.0", "is-absolute": "^1.0.0", @@ -995,9 +997,6 @@ }, "engines": { "node": ">=14.7.0" - }, - "peerDependencies": { - "cypress": ">=10.0.0" } }, "node_modules/cypress-cloud/node_modules/commander": { @@ -1072,9 +1071,9 @@ "dev": true }, "node_modules/cypress/node_modules/@types/node": { - "version": "16.18.48", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz", - "integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==", + "version": "18.17.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.15.tgz", + "integrity": "sha512-2yrWpBk32tvV/JAd3HNHWuZn/VDN1P+72hWirHnvsvTGSqbANi+kSeuQR9yAHnbvaBvHDsoTdXV0Fe+iRtHLKA==", "dev": true }, "node_modules/dashdash": { @@ -1089,6 +1088,22 @@ "node": ">=0.10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.9", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", @@ -1330,6 +1345,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -3036,10 +3057,14 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/src/test/cypress/package.json b/src/test/cypress/package.json index 53fb0f5c540d..12a8aded34da 100644 --- a/src/test/cypress/package.json +++ b/src/test/cypress/package.json @@ -8,13 +8,13 @@ ], "devDependencies": { "@4tw/cypress-drag-drop": "2.2.4", - "@types/node": "20.5.9", - "cypress": "13.1.0", - "cypress-cloud": "1.9.4", + "@types/node": "20.6.0", + "cypress": "13.2.0", + "cypress-cloud": "1.10.0-beta.4", "cypress-file-upload": "5.0.8", "cypress-wait-until": "2.0.1", "typescript": "5.2.2", - "uuid": "9.0.0", + "uuid": "9.0.1", "wait-on": "7.0.1" }, "overrides": { @@ -30,7 +30,7 @@ "cypress:open": "cypress open", "cypress:run": "cypress run --browser=chrome", "cypress:setup": "cypress install && cypress run --quiet --spec init/ImportUsers.cy.ts", - "cypress:record:mysql": "npx cypress-cloud run --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (MySQL)\"", + "cypress:record:mysql": "npx cypress-cloud run --cloud-debug --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (MySQL)\"", "cypress:record:postgres": "npx cypress-cloud run --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (Postgres)\"", "update": "npm-upgrade" } From a51749b743c9c5d06315a4feda9e056d10c43818 Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Thu, 14 Sep 2023 10:11:36 +0200 Subject: [PATCH 38/47] General: Fix an issue on the user settings page (#7195) --- .../user-settings-container.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html b/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html index 732d8bee607c..e8363986a020 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html +++ b/src/main/webapp/app/shared/user-settings/user-settings-container/user-settings-container.component.html @@ -3,7 +3,6 @@

-
{{ currentUser.name }} From 1fa8b4ee7a699acb91be00e80785d22dde051ed1 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Thu, 14 Sep 2023 10:12:46 +0200 Subject: [PATCH 39/47] Development: Fix cypress dependency issue (#7196) --- {.bamboo => .ci}/E2E-tests/cleanup.sh | 0 {.bamboo => .ci}/E2E-tests/execute.sh | 4 ++-- {.bamboo => .ci}/migration-check/cleanup.sh | 0 {.bamboo => .ci}/migration-check/execute.sh | 4 ++-- docker/cypress-E2E-tests-mysql.yml | 2 +- docker/cypress-E2E-tests-postgres.yml | 2 +- src/test/cypress/package.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename {.bamboo => .ci}/E2E-tests/cleanup.sh (100%) rename {.bamboo => .ci}/E2E-tests/execute.sh (95%) rename {.bamboo => .ci}/migration-check/cleanup.sh (100%) rename {.bamboo => .ci}/migration-check/execute.sh (94%) diff --git a/.bamboo/E2E-tests/cleanup.sh b/.ci/E2E-tests/cleanup.sh similarity index 100% rename from .bamboo/E2E-tests/cleanup.sh rename to .ci/E2E-tests/cleanup.sh diff --git a/.bamboo/E2E-tests/execute.sh b/.ci/E2E-tests/execute.sh similarity index 95% rename from .bamboo/E2E-tests/execute.sh rename to .ci/E2E-tests/execute.sh index 92de1a959efd..971d05dea8a6 100755 --- a/.bamboo/E2E-tests/execute.sh +++ b/.ci/E2E-tests/execute.sh @@ -4,10 +4,10 @@ DB=$1 if [ "$DB" = "mysql" ]; then COMPOSE_FILE="cypress-E2E-tests-mysql.yml" -elif [ "$DB" = "postgresql" ]; then +elif [ "$DB" = "postgres" ]; then COMPOSE_FILE="cypress-E2E-tests-postgres.yml" else - echo "Invalid database type. Please choose either mysql or postgresql." + echo "Invalid database type. Please choose either mysql or postgres." exit 1 fi diff --git a/.bamboo/migration-check/cleanup.sh b/.ci/migration-check/cleanup.sh similarity index 100% rename from .bamboo/migration-check/cleanup.sh rename to .ci/migration-check/cleanup.sh diff --git a/.bamboo/migration-check/execute.sh b/.ci/migration-check/execute.sh similarity index 94% rename from .bamboo/migration-check/execute.sh rename to .ci/migration-check/execute.sh index 9b9ecdceb41f..50c42043f1ec 100755 --- a/.bamboo/migration-check/execute.sh +++ b/.ci/migration-check/execute.sh @@ -4,10 +4,10 @@ DB=$1 if [ "$DB" = "mysql" ]; then COMPOSE_FILE="artemis-migration-check-mysql.yml" -elif [ "$DB" = "postgresql" ]; then +elif [ "$DB" = "postgres" ]; then COMPOSE_FILE="artemis-migration-check-postgres.yml" else - echo "Invalid database type. Please choose either mysql or postgresql." + echo "Invalid database type. Please choose either mysql or postgres." exit 1 fi diff --git a/docker/cypress-E2E-tests-mysql.yml b/docker/cypress-E2E-tests-mysql.yml index 02a040a68dcd..835b6880e033 100644 --- a/docker/cypress-E2E-tests-mysql.yml +++ b/docker/cypress-E2E-tests-mysql.yml @@ -44,7 +44,7 @@ services: environment: CYPRESS_DB_TYPE: "MySQL" SORRY_CYPRESS_PROJECT_ID: "artemis-mysql" - command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:mysql & sleep 60 && npm run cypress:record:mysql & wait)" + command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci --legacy-peer-deps && npm run cypress:setup && (npm run cypress:record:mysql & sleep 60 && npm run cypress:record:mysql & wait)" networks: artemis: diff --git a/docker/cypress-E2E-tests-postgres.yml b/docker/cypress-E2E-tests-postgres.yml index e2396270d899..3d24c792a177 100644 --- a/docker/cypress-E2E-tests-postgres.yml +++ b/docker/cypress-E2E-tests-postgres.yml @@ -44,7 +44,7 @@ services: environment: CYPRESS_DB_TYPE: "Postgres" SORRY_CYPRESS_PROJECT_ID: "artemis-postgres" - command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:postgres & sleep 60 && npm run cypress:record:postgres & wait)" + command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci --legacy-peer-deps && npm run cypress:setup && (npm run cypress:record:postgres & sleep 60 && npm run cypress:record:postgres & wait)" networks: artemis: diff --git a/src/test/cypress/package.json b/src/test/cypress/package.json index 12a8aded34da..e8a82103f70a 100644 --- a/src/test/cypress/package.json +++ b/src/test/cypress/package.json @@ -30,7 +30,7 @@ "cypress:open": "cypress open", "cypress:run": "cypress run --browser=chrome", "cypress:setup": "cypress install && cypress run --quiet --spec init/ImportUsers.cy.ts", - "cypress:record:mysql": "npx cypress-cloud run --cloud-debug --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (MySQL)\"", + "cypress:record:mysql": "npx cypress-cloud run --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (MySQL)\"", "cypress:record:postgres": "npx cypress-cloud run --parallel --record --ci-build-id \"${SORRY_CYPRESS_BRANCH_NAME} #${SORRY_CYPRESS_BUILD_ID} ${SORRY_CYPRESS_RERUN_COUNT} (Postgres)\"", "update": "npm-upgrade" } From 0d369e9c780665fdc11f3b2dd62675ed0bd40c7e Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Thu, 14 Sep 2023 10:16:02 +0200 Subject: [PATCH 40/47] Development: Fix issues with cypress dependencies --- src/test/cypress/package-lock.json | 10 +++++----- src/test/cypress/package.json | 7 ++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/test/cypress/package-lock.json b/src/test/cypress/package-lock.json index 632fff350ea3..677ef5fe04c8 100644 --- a/src/test/cypress/package-lock.json +++ b/src/test/cypress/package-lock.json @@ -7,7 +7,7 @@ "name": "artemis_cypress", "license": "MIT", "devDependencies": { - "@4tw/cypress-drag-drop": "2.2.4", + "@4tw/cypress-drag-drop": "2.2.5", "@types/node": "20.6.0", "cypress": "13.2.0", "cypress-cloud": "1.10.0-beta.4", @@ -19,12 +19,12 @@ } }, "node_modules/@4tw/cypress-drag-drop": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@4tw/cypress-drag-drop/-/cypress-drag-drop-2.2.4.tgz", - "integrity": "sha512-6mmjJ0SHjciI0JNofdVcN1LYLbvUcUT3oY2O+1HaTlQlpKPlX9kBc040Sik6RYFK7cgWvUNwoDoDoGSKA4IS/g==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@4tw/cypress-drag-drop/-/cypress-drag-drop-2.2.5.tgz", + "integrity": "sha512-3ghTmzhOmUqeN6U3QmUnKRUxI7OMLbJA4hHUY/eS/FhWJgxbiGgcaELbolWnBAOpajPXcsNQGYEj9brd59WH6A==", "dev": true, "peerDependencies": { - "cypress": "^2.1.0 || ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0" + "cypress": "2 - 13" } }, "node_modules/@babel/runtime": { diff --git a/src/test/cypress/package.json b/src/test/cypress/package.json index e8a82103f70a..26ce69d2666e 100644 --- a/src/test/cypress/package.json +++ b/src/test/cypress/package.json @@ -7,7 +7,7 @@ "node_modules" ], "devDependencies": { - "@4tw/cypress-drag-drop": "2.2.4", + "@4tw/cypress-drag-drop": "2.2.5", "@types/node": "20.6.0", "cypress": "13.2.0", "cypress-cloud": "1.10.0-beta.4", @@ -21,10 +21,7 @@ "semver": "7.5.3", "word-wrap": "1.2.3", "debug": "4.3.4", - "tough-cookie": "4.1.3", - "@4tw/cypress-drag-drop": { - "cypress": "13.1.0" - } + "tough-cookie": "4.1.3" }, "scripts": { "cypress:open": "cypress open", From a2f1397627201ca289b73227a3703153edad5b48 Mon Sep 17 00:00:00 2001 From: Lennart Keller <44754405+lennart-keller@users.noreply.github.com> Date: Thu, 14 Sep 2023 10:42:23 +0200 Subject: [PATCH 41/47] Development: Fix Postgres Incompatibility for conversation user information query (#7190) --- .../repository/metis/conversation/ConversationRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/tum/in/www1/artemis/repository/metis/conversation/ConversationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/metis/conversation/ConversationRepository.java index 93ee6dbb5d11..d522170180f5 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/metis/conversation/ConversationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/metis/conversation/ConversationRepository.java @@ -64,7 +64,7 @@ default Conversation findByIdElseThrow(long conversationId) { LEFT JOIN Post p ON conv.id = p.conversation.id AND (p.creationDate > cp.lastRead OR (channel.isCourseWide IS true AND cp.lastRead IS null)) WHERE conv.id IN :conversationIds AND (channel.isCourseWide IS true OR (conv.id = cp.conversation.id AND cp.user.id = :userId)) - GROUP BY conv.id + GROUP BY conv.id, cp.id, cp.isModerator, cp.isFavorite, cp.isHidden, cp.lastRead """) List getUserInformationForConversations(@Param("conversationIds") Iterable conversationIds, @Param("userId") Long userId); From 61a94902c84ddf608bba8dceee388c46b81c98a4 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Thu, 14 Sep 2023 10:45:33 +0200 Subject: [PATCH 42/47] Programming exercises: Fix missing branch configuration (#7189) --- .../in/www1/artemis/web/rest/ProgrammingExerciseResource.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java index fd265be5796f..99d4d4ed88f7 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseResource.java @@ -266,6 +266,9 @@ public ResponseEntity updateProgrammingExercise(@RequestBod // Forbid conversion between normal course exercise and exam exercise exerciseService.checkForConversionBetweenExamAndCourseExercise(updatedProgrammingExercise, programmingExerciseBeforeUpdate, ENTITY_NAME); + // Ignore changes to the default branch + updatedProgrammingExercise.setBranch(programmingExerciseBeforeUpdate.getBranch()); + if (updatedProgrammingExercise.getAuxiliaryRepositories() == null) { // make sure the default value is set properly updatedProgrammingExercise.setAuxiliaryRepositories(new ArrayList<>()); From 9e8bd093c101740d1b5630732d3906711837534f Mon Sep 17 00:00:00 2001 From: Ludwig <33753999+4ludwig4@users.noreply.github.com> Date: Thu, 14 Sep 2023 10:51:37 +0200 Subject: [PATCH 43/47] Development: Add postgres docker compose setup (#6759) --- .gitignore | 2 + .../artemis-postgres-data/.gitkeep | 0 docker/artemis-dev-mysql.yml | 7 +-- docker/artemis-dev-postgres.yml | 39 ++++++++++++++++ docker/artemis-migration-check-postgres.yml | 6 ++- docker/artemis-prod-postgres.yml | 38 ++++++++++++++++ docker/artemis/config/cypress-mysql.env | 40 ++--------------- docker/artemis/config/cypress-postgres.env | 45 +------------------ docker/artemis/config/cypress.env | 44 ++++++++++++++++++ docker/artemis/config/dev.env | 11 +++++ docker/artemis/config/postgres.env | 9 ++++ docker/artemis/config/prod.env | 15 ++++++- docker/cypress-E2E-tests-mysql.yml | 1 + docker/cypress-E2E-tests-postgres.yml | 16 ++++--- docker/mailhog.yml | 2 +- docker/{postgresql.yml => postgres.yml} | 19 ++++---- docker/{postgresql => postgres}/default.env | 1 + docker/test-server-postgresql.yml | 14 +++--- docs/admin/productionSetupTips.rst | 21 +++++++++ docs/dev/setup.rst | 16 +++++-- 20 files changed, 231 insertions(+), 115 deletions(-) create mode 100644 docker/.docker-data/artemis-postgres-data/.gitkeep create mode 100644 docker/artemis-dev-postgres.yml create mode 100644 docker/artemis-prod-postgres.yml create mode 100644 docker/artemis/config/cypress.env create mode 100644 docker/artemis/config/dev.env create mode 100644 docker/artemis/config/postgres.env rename docker/{postgresql.yml => postgres.yml} (64%) rename docker/{postgresql => postgres}/default.env (65%) diff --git a/.gitignore b/.gitignore index f897ea313607..fe02c2ff3875 100644 --- a/.gitignore +++ b/.gitignore @@ -185,6 +185,8 @@ data-exports/ !/docker/.docker-data/artemis-data/.gitkeep /docker/.docker-data/artemis-mysql-data/* !/docker/.docker-data/artemis-mysql-data/.gitkeep +/docker/.docker-data/artemis-postgres-data/* +!/docker/.docker-data/artemis-postgres-data/.gitkeep ###################### # Cypress diff --git a/docker/.docker-data/artemis-postgres-data/.gitkeep b/docker/.docker-data/artemis-postgres-data/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/docker/artemis-dev-mysql.yml b/docker/artemis-dev-mysql.yml index 2e218cf60b7e..65655ef1b970 100644 --- a/docker/artemis-dev-mysql.yml +++ b/docker/artemis-dev-mysql.yml @@ -17,11 +17,8 @@ services: # expose the port to make it reachable docker internally even if the external port mapping changes expose: - "5005" - environment: - # The following enables the Java Remote Debugging port. More infos in the documentation: - # https://ls1intum.github.io/Artemis/dev/setup.html#debugging-with-docker - _JAVA_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 - SPRING_PROFILES_ACTIVE: artemis,scheduling,athena,dev,docker + env_file: + - ./artemis/config/dev.env depends_on: mysql: condition: service_healthy diff --git a/docker/artemis-dev-postgres.yml b/docker/artemis-dev-postgres.yml new file mode 100644 index 000000000000..5e1344511b6f --- /dev/null +++ b/docker/artemis-dev-postgres.yml @@ -0,0 +1,39 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis-Dev-Postgres Setup +# ---------------------------------------------------------------------------------------------------------------------- + +services: + artemis-app: + extends: + file: ./artemis.yml + service: artemis-app + # just add this linux workaround for docker compose in a development version of artemis as developers + # might want to access external services on the docker host + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "8080:8080" + - "5005:5005" # Java Remote Debugging port declared in the java cmd options + # expose the port to make it reachable docker internally even if the external port mapping changes + expose: + - "5005" + env_file: + - ./artemis/config/dev.env + - ./artemis/config/postgres.env + depends_on: + postgres: + condition: service_healthy + postgres: + extends: + file: ./postgres.yml + service: postgres + +networks: + artemis: + driver: "bridge" + name: artemis +volumes: + artemis-postgres-data: + name: artemis-postgres-data + artemis-data: + name: artemis-data diff --git a/docker/artemis-migration-check-postgres.yml b/docker/artemis-migration-check-postgres.yml index 2ddd82d07132..0d801be844d2 100644 --- a/docker/artemis-migration-check-postgres.yml +++ b/docker/artemis-migration-check-postgres.yml @@ -8,6 +8,8 @@ services: file: ./artemis.yml service: artemis-app env_file: + - ./artemis/config/postgres.env + - ./artemis/config/cypress.env - ./artemis/config/cypress-postgres.env - ./artemis/config/migration-check.env depends_on: @@ -32,7 +34,7 @@ networks: driver: "bridge" name: artemis volumes: - artemis-postgresql-data: - name: artemis-postgresql-data + artemis-postgres-data: + name: artemis-postgres-data artemis-data: name: artemis-data diff --git a/docker/artemis-prod-postgres.yml b/docker/artemis-prod-postgres.yml new file mode 100644 index 000000000000..fdb0c691f0ef --- /dev/null +++ b/docker/artemis-prod-postgres.yml @@ -0,0 +1,38 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis-Prod-Postgres Setup +# ---------------------------------------------------------------------------------------------------------------------- + +services: + artemis-app: + extends: + file: ./artemis.yml + service: artemis-app + depends_on: + postgres: + condition: service_healthy + restart: on-failure:3 + volumes: + - ./.docker-data/artemis-data:/opt/artemis/data + env_file: + - ./artemis/config/postgres.env + postgres: + extends: + file: ./postgres.yml + service: postgres + restart: on-failure:3 + volumes: + - ./.docker-data/artemis-postgres-data:/var/lib/postgresql/data + nginx: + extends: + file: ./nginx.yml + service: nginx + # the artemis-app service needs to be started, otherwise there are problems with name resolution in docker + depends_on: + artemis-app: + condition: service_started + restart: on-failure:3 + +networks: + artemis: + driver: "bridge" + name: artemis diff --git a/docker/artemis/config/cypress-mysql.env b/docker/artemis/config/cypress-mysql.env index 17177a7afbf3..b9f006a4b395 100644 --- a/docker/artemis/config/cypress-mysql.env +++ b/docker/artemis/config/cypress-mysql.env @@ -1,34 +1,21 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis configuration overrides for the Cypress E2E MySQL setups +# ---------------------------------------------------------------------------------------------------------------------- + SPRING_PROFILES_ACTIVE="artemis,scheduling,bamboo,bitbucket,jira,prod,docker" SPRING_DATASOURCE_URL="jdbc:mysql://artemis-mysql:3306/Artemis?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf8&useSSL=false&useLegacyDatetimeCode=false&serverTimezone=UTC" SPRING_DATASOURCE_USERNAME="root" -SPRING_DATASOURCE_PASSWORD="" -SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE="100" SPRING_JPA_DATABASE_PLATFORM="org.hibernate.dialect.MySQL8Dialect" SPRING_JPA_DATABASE="MYSQL" -SPRING_JPA_HIBERNATE_CONNECTION_CHARSET="utf8mb4" -SPRING_JPA_HIBERNATE_CONNECTION_CHARACTERENCODING="utf8mb4" -SPRING_JPA_HIBERNATE_CONNECTION_USEUNICODE="true" - -SPRING_PROMETHEUS_MONITORINGIP="131.159.89.160" - -# for bamboo and bitbucket notifications on /api/programming-exercises/new-result it seems like port -# 54321 is internally open for the bamboo agents -# also can't use SSL for this as the hostnames are not integrated in the self-signed certificate -SERVER_URL="http://${HOST_HOSTNAME}:54321" -# We don't need secure passwords for testing. Lower rounds will speed up tests. 4 is the lowest -ARTEMIS_BCRYPTSALTROUNDS="4" ARTEMIS_USERMANAGEMENT_USEEXTERNAL="true" ARTEMIS_USERMANAGEMENT_EXTERNAL_URL="https://jira-prelive.ase.in.tum.de" ARTEMIS_USERMANAGEMENT_EXTERNAL_USER="${bamboo_jira_prelive_admin_user}" ARTEMIS_USERMANAGEMENT_EXTERNAL_PASSWORD="${bamboo_jira_prelive_admin_password}" ARTEMIS_USERMANAGEMENT_EXTERNAL_ADMINGROUPNAME="artemis-dev" -ARTEMIS_USERMANAGEMENT_INTERNALADMIN_USERNAME="${bamboo_artemis_admin_username}" -ARTEMIS_USERMANAGEMENT_INTERNALADMIN_PASSWORD="${bamboo_artemis_admin_password}" -ARTEMIS_USERMANAGEMENT_LOGIN_ACCOUNTNAME="TUM" ARTEMIS_VERSIONCONTROL_URL="https://bitbucket-prelive.ase.in.tum.de" ARTEMIS_VERSIONCONTROL_USER="${bamboo_jira_prelive_admin_user}" @@ -40,22 +27,3 @@ ARTEMIS_CONTINUOUSINTEGRATION_PASSWORD="${bamboo_jira_prelive_admin_password}" ARTEMIS_CONTINUOUSINTEGRATION_TOKEN="${bamboo_ARTEMIS_CONTINUOUS_INTEGRATION_TOKEN_SECRET}" ARTEMIS_CONTINUOUSINTEGRATION_ARTEMISAUTHENTICATIONTOKENVALUE="${bamboo_ARTEMIS_CONTINUOUS_INTEGRATION_ARTEMIS_AUTHENTICATION_TOKEN_VALUE_SECRET}" ARTEMIS_CONTINUOUSINTEGRATION_VCSAPPLICATIONLINKNAME="Bitbucket Prelive" -ARTEMIS_CONTINUOUSINTEGRATION_EMPTYCOMMITNECESSARY="true" - -ARTEMIS_APOLLON_CONVERSIONSERVICEURL="https://apollon.ase.in.tum.de/api/converter" - -# Token is valid 3 days -JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDS="259200" -# Token is valid 30 days -JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDSFORREMEMBERME="2592000" - -# Properties to be exposed on the /info management endpoint - -INFO_IMPRINT="https://ase.in.tum.de/lehrstuhl_1/component/content/article/179-imprint" -INFO_TESTSERVER="true" -INFO_TEXTASSESSMENTANALYTICSENABLED="true" -INFO_STUDENTEXAMSTORESESSIONDATA="true" - -LOGGING_FILE_NAME="/opt/artemis/data/artemis.log" - -MANAGEMENT_METRICS_EXPORT_PROMETHEUS_ENABLED="true" diff --git a/docker/artemis/config/cypress-postgres.env b/docker/artemis/config/cypress-postgres.env index d0ab68263c90..73254a9afebc 100644 --- a/docker/artemis/config/cypress-postgres.env +++ b/docker/artemis/config/cypress-postgres.env @@ -1,34 +1,10 @@ # ---------------------------------------------------------------------------------------------------------------------- -# Artemis configurations for Postgres setups +# Artemis configuration overrides for the Cypress E2E Postgres setups # ---------------------------------------------------------------------------------------------------------------------- SPRING_PROFILES_ACTIVE="artemis,scheduling,jenkins,gitlab,prod,docker" -SPRING_DATASOURCE_URL="jdbc:postgresql://artemis-postgresql:5432/Artemis?sslmode=disable" -SPRING_DATASOURCE_USERNAME="postgres" -SPRING_DATASOURCE_PASSWORD="" -SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE="100" - -SPRING_JPA_DATABASE_PLATFORM="org.hibernate.dialect.PostgreSQL10Dialect" -SPRING_JPA_DATABASE="POSTGRESQL" -SPRING_JPA_HIBERNATE_CONNECTION_CHARSET="utf8mb4" -SPRING_JPA_HIBERNATE_CONNECTION_CHARACTERENCODING="utf8mb4" -SPRING_JPA_HIBERNATE_CONNECTION_USEUNICODE="true" - -SPRING_PROMETHEUS_MONITORINGIP="131.159.89.160" - -# for bamboo and bitbucket notifications on /api/programming-exercises/new-result it seems like port -# 54321 is internally open for the bamboo agents -# also can't use SSL for this as the hostnames are not integrated in the self-signed certificate -SERVER_URL="http://${HOST_HOSTNAME}:54321" - -# We don't need secure passwords for testing. Lower rounds will speed up tests. 4 is the lowest -ARTEMIS_BCRYPTSALTROUNDS="4" - ARTEMIS_USERMANAGEMENT_USEEXTERNAL="false" -ARTEMIS_USERMANAGEMENT_INTERNALADMIN_USERNAME="${bamboo_artemis_admin_username}" -ARTEMIS_USERMANAGEMENT_INTERNALADMIN_PASSWORD="${bamboo_artemis_admin_password}" -ARTEMIS_USERMANAGEMENT_LOGIN_ACCOUNTNAME="TUM" ARTEMIS_VERSIONCONTROL_URL="https://gitlab-test.artemis.in.tum.de" ARTEMIS_VERSIONCONTROL_USER="${bamboo_gitlab_admin_user}" @@ -44,26 +20,7 @@ ARTEMIS_CONTINUOUSINTEGRATION_SECRETPUSHTOKEN="${bamboo_jenkins_secret_push_toke ARTEMIS_CONTINUOUSINTEGRATION_VCSCREDENTIALS="${bamboo_jenkins_vcs_credentials_secret}" ARTEMIS_CONTINUOUSINTEGRATION_ARTEMISAUTHENTICATIONTOKENKEY="${bamboo_jenkins_artemis_ci_authentication_token_key_secret}" ARTEMIS_CONTINUOUSINTEGRATION_ARTEMISAUTHENTICATIONTOKENVALUE="${bamboo_jenkins_artemis_ci_authentication_token_value_secret}" -ARTEMIS_CONTINUOUSINTEGRATION_EMPTYCOMMITNECESSARY="true" ARTEMIS_CONTINUOUSINTEGRATION_BUILDTIMEOUT="30" -ARTEMIS_APOLLON_CONVERSIONSERVICEURL="https://apollon.ase.in.tum.de/api/converter" - -# Token is valid 3 days -JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDS="259200" -# Token is valid 30 days -JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDSFORREMEMBERME="2592000" - -# Properties to be exposed on the /info management endpoint - -INFO_IMPRINT="https://ase.in.tum.de/lehrstuhl_1/component/content/article/179-imprint" -INFO_TESTSERVER="true" -INFO_TEXTASSESSMENTANALYTICSENABLED="true" -INFO_STUDENTEXAMSTORESESSIONDATA="true" - -LOGGING_FILE_NAME="/opt/artemis/data/artemis.log" - -MANAGEMENT_METRICS_EXPORT_PROMETHEUS_ENABLED="true" - JENKINS_INTERNALURLS_CIURL="https://jenkins-test.artemis.in.tum.de" JENKINS_INTERNALURLS_VCNURL="https://gitlab-test.artemis.in.tum.de" diff --git a/docker/artemis/config/cypress.env b/docker/artemis/config/cypress.env new file mode 100644 index 000000000000..c056a8d9004c --- /dev/null +++ b/docker/artemis/config/cypress.env @@ -0,0 +1,44 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Common Artemis configurations for the Cypress E2E MySQL and Postgres setups +# ---------------------------------------------------------------------------------------------------------------------- + +SPRING_DATASOURCE_PASSWORD="" +SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE="100" + +SPRING_JPA_HIBERNATE_CONNECTION_CHARSET="utf8mb4" +SPRING_JPA_HIBERNATE_CONNECTION_CHARACTERENCODING="utf8mb4" +SPRING_JPA_HIBERNATE_CONNECTION_USEUNICODE="true" + +SPRING_PROMETHEUS_MONITORINGIP="131.159.89.160" + +# for bamboo and bitbucket notifications on /api/programming-exercises/new-result it seems like port +# 54321 is internally open for the bamboo agents +# also can't use SSL for this as the hostnames are not integrated in the self-signed certificate +SERVER_URL="http://${HOST_HOSTNAME}:54321" + +# We don't need secure passwords for testing. Lower rounds will speed up tests. 4 is the lowest +ARTEMIS_BCRYPTSALTROUNDS="4" + +ARTEMIS_USERMANAGEMENT_INTERNALADMIN_USERNAME="${bamboo_artemis_admin_username}" +ARTEMIS_USERMANAGEMENT_INTERNALADMIN_PASSWORD="${bamboo_artemis_admin_password}" +ARTEMIS_USERMANAGEMENT_LOGIN_ACCOUNTNAME="TUM" + +ARTEMIS_CONTINUOUSINTEGRATION_EMPTYCOMMITNECESSARY="true" + +ARTEMIS_APOLLON_CONVERSIONSERVICEURL="https://apollon.ase.in.tum.de/api/converter" + +# Token is valid 3 days +JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDS="259200" +# Token is valid 30 days +JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDSFORREMEMBERME="2592000" + +# Properties to be exposed on the /info management endpoint + +INFO_IMPRINT="https://ase.in.tum.de/lehrstuhl_1/component/content/article/179-imprint" +INFO_TESTSERVER="true" +INFO_TEXTASSESSMENTANALYTICSENABLED="true" +INFO_STUDENTEXAMSTORESESSIONDATA="true" + +LOGGING_FILE_NAME="/opt/artemis/data/artemis.log" + +MANAGEMENT_METRICS_EXPORT_PROMETHEUS_ENABLED="true" diff --git a/docker/artemis/config/dev.env b/docker/artemis/config/dev.env new file mode 100644 index 000000000000..4dc4617261bd --- /dev/null +++ b/docker/artemis/config/dev.env @@ -0,0 +1,11 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis configurations for Dev Artemis setups +# ---------------------------------------------------------------------------------------------------------------------- +# The default Artemis Spring profiles for Docker are defined here. +# ---------------------------------------------------------------------------------------------------------------------- + +SPRING_PROFILES_ACTIVE: artemis,scheduling,athena,dev,docker + +# The following enables the Java Remote Debugging port. More infos in the documentation: +# https://ls1intum.github.io/Artemis/dev/setup.html#debugging-with-docker +_JAVA_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 diff --git a/docker/artemis/config/postgres.env b/docker/artemis/config/postgres.env new file mode 100644 index 000000000000..2c8a07e95f37 --- /dev/null +++ b/docker/artemis/config/postgres.env @@ -0,0 +1,9 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis configurations for Postgres setups +# ---------------------------------------------------------------------------------------------------------------------- + +SPRING_DATASOURCE_URL="jdbc:postgresql://artemis-postgres:5432/Artemis?sslmode=disable" +SPRING_DATASOURCE_USERNAME="postgres" + +SPRING_JPA_DATABASE_PLATFORM="org.hibernate.dialect.PostgreSQL10Dialect" +SPRING_JPA_DATABASE="POSTGRESQL" diff --git a/docker/artemis/config/prod.env b/docker/artemis/config/prod.env index 3f708bdc709e..a86a87bcf51a 100644 --- a/docker/artemis/config/prod.env +++ b/docker/artemis/config/prod.env @@ -1,6 +1,15 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Artemis configurations for Prod Artemis setups +# ---------------------------------------------------------------------------------------------------------------------- +# The default Artemis Spring profiles for Docker are defined here. +# ---------------------------------------------------------------------------------------------------------------------- SPRING_PROFILES_ACTIVE="artemis,scheduling,athena,prod,docker" +# ---------------------------------------------------------------------------------------------------------------------- # Secret Overrides +# ---------------------------------------------------------------------------------------------------------------------- +# Change these default secrets in another not-commited environment override file for prod systems! +# ---------------------------------------------------------------------------------------------------------------------- ARTEMIS_ATHENA_SECRET="abcdefg12345" ARTEMIS_USERMANAGEMENT_INTERNALADMIN_USERNAME="artemis_admin" ARTEMIS_USERMANAGEMENT_INTERNALADMIN_PASSWORD="artemis_admin" @@ -17,6 +26,10 @@ SPRING_WEBSOCKET_BROKER_PASSWORD="guest" JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64SECRET="bXktc2VjcmV0LWtleS13aGljaC1zaG91bGQtYmUtY2hhbmdlZC1pbi1wcm9kdWN0aW9uLWFuZC1iZS1iYXNlNjQtZW5jb2RlZAo=" JHIPSTER_REGISTRY_PASSWORD="AN-ADMIN-PASSWORD-THAT-MUST-BE-CHANGED (FROM REGISTRY CONFIG)" - +# ---------------------------------------------------------------------------------------------------------------------- # Plain Prod Artemis Overrides +# ---------------------------------------------------------------------------------------------------------------------- +# Keep these at a minimum! Try to change the default values either in the application-docker.yml or even better +# in one of the other application.yml or application-*.yml files. +# ---------------------------------------------------------------------------------------------------------------------- ARTEMIS_USERMANAGEMENT_USEEXTERNAL="false" diff --git a/docker/cypress-E2E-tests-mysql.yml b/docker/cypress-E2E-tests-mysql.yml index 835b6880e033..96f9fe67989f 100644 --- a/docker/cypress-E2E-tests-mysql.yml +++ b/docker/cypress-E2E-tests-mysql.yml @@ -16,6 +16,7 @@ services: mysql: condition: service_healthy env_file: + - ./artemis/config/cypress.env - ./artemis/config/cypress-mysql.env nginx: diff --git a/docker/cypress-E2E-tests-postgres.yml b/docker/cypress-E2E-tests-postgres.yml index 3d24c792a177..a42d2232adc7 100644 --- a/docker/cypress-E2E-tests-postgres.yml +++ b/docker/cypress-E2E-tests-postgres.yml @@ -1,21 +1,23 @@ # ---------------------------------------------------------------------------------------------------------------------- -# Cypress Setup PostgreSQL +# Cypress Setup Postgres # ---------------------------------------------------------------------------------------------------------------------- services: - postgresql: + postgres: extends: - file: ./postgresql.yml - service: postgresql + file: ./postgres.yml + service: postgres artemis-app: extends: file: ./artemis.yml service: artemis-app depends_on: - postgresql: + postgres: condition: service_healthy env_file: + - ./artemis/config/postgres.env + - ./artemis/config/cypress.env - ./artemis/config/cypress-postgres.env nginx: @@ -51,7 +53,7 @@ networks: driver: "bridge" name: artemis volumes: - artemis-postgresql-data: - name: artemis-postgresql-data + artemis-postgres-data: + name: artemis-postgres-data artemis-data: name: artemis-data diff --git a/docker/mailhog.yml b/docker/mailhog.yml index 5145f67cdff4..70af79096071 100644 --- a/docker/mailhog.yml +++ b/docker/mailhog.yml @@ -7,7 +7,7 @@ services: mailhog: container_name: artemis-mailhog - image: mailhog/mailhog + image: docker.io/mailhog/mailhog pull_policy: always ports: - "1025:1025" diff --git a/docker/postgresql.yml b/docker/postgres.yml similarity index 64% rename from docker/postgresql.yml rename to docker/postgres.yml index aec7d23cfee7..097c9530baee 100644 --- a/docker/postgresql.yml +++ b/docker/postgres.yml @@ -3,19 +3,22 @@ # ---------------------------------------------------------------------------------------------------------------------- services: - postgresql: - container_name: artemis-postgresql - image: postgres:15.3-alpine + postgres: + container_name: artemis-postgres + image: docker.io/library/postgres:15.3-alpine pull_policy: always user: postgres command: ["postgres", "-c", "max_connections=200"] volumes: - - artemis-postgresql-data:/var/lib/postgresql/data + - artemis-postgres-data:/var/lib/postgresql/data # DO NOT use this default file for production systems! env_file: - - ./postgresql/default.env + - ./postgres/default.env ports: - - "5432:5432" + - "127.0.0.1:5432:5432" + # expose the port to make it reachable docker internally even if the external port mapping changes + expose: + - "5432" healthcheck: test: pg_isready -U postgres -d Artemis interval: 5s @@ -32,5 +35,5 @@ networks: name: artemis volumes: - artemis-postgresql-data: - name: artemis-postgresql-data + artemis-postgres-data: + name: artemis-postgres-data diff --git a/docker/postgresql/default.env b/docker/postgres/default.env similarity index 65% rename from docker/postgresql/default.env rename to docker/postgres/default.env index 9d69c3af68ff..d92a5b5f2722 100644 --- a/docker/postgresql/default.env +++ b/docker/postgres/default.env @@ -1,3 +1,4 @@ POSTGRES_HOST_AUTH_METHOD=trust POSTGRES_USER=postgres POSTGRES_DB=Artemis +PGDATA=/var/lib/postgresql/data/pgdata diff --git a/docker/test-server-postgresql.yml b/docker/test-server-postgresql.yml index 140d77a0cb3b..e9740206ed52 100644 --- a/docker/test-server-postgresql.yml +++ b/docker/test-server-postgresql.yml @@ -1,5 +1,5 @@ # ---------------------------------------------------------------------------------------------------------------------- -# Setup for a test server with PostgreSQL +# Setup for a test server with Postgres # ---------------------------------------------------------------------------------------------------------------------- # It is designed to take in a lot of environment variables to take in all the configuration of the test server. # ---------------------------------------------------------------------------------------------------------------------- @@ -11,7 +11,7 @@ services: service: artemis-app image: ghcr.io/ls1intum/artemis:${ARTEMIS_DOCKER_TAG:-latest} depends_on: - postgresql: + postgres: condition: service_healthy pull_policy: always restart: always @@ -22,15 +22,15 @@ services: - ${ARTEMIS_LEGAL_MOUNT:-./.docker-data/artemis-legal}:/opt/artemis/legal - ${ARTEMIS_DATA_EXPORT_MOUNT:-./.docker-data/artemis-data-exports}:/opt/artemis/data-exports - postgresql: + postgres: extends: - file: ./postgresql.yml - service: postgresql + file: ./postgres.yml + service: postgres restart: always env_file: - - ${DATABASE_ENV_FILE:-./postgresql/default.env} + - ${DATABASE_ENV_FILE:-./postgres/default.env} volumes: - - ${DATABASE_VOLUME_MOUNT:-./.docker-data/artemis-postgresql-data}:/var/lib/postgresql/data + - ${DATABASE_VOLUME_MOUNT:-./.docker-data/artemis-postgres-data}:/var/lib/postgresql/data nginx: extends: diff --git a/docs/admin/productionSetupTips.rst b/docs/admin/productionSetupTips.rst index fadaebb46766..cc95bee1b4ee 100644 --- a/docs/admin/productionSetupTips.rst +++ b/docs/admin/productionSetupTips.rst @@ -35,3 +35,24 @@ Place the webpage that should be shown in case of Artemis being unreachable (in error_page 501 502 503 /service_down.html; } + +## Gather all Docker Compose-related tips here which are not relevant for developers! +.. _docker_compose_setup_prod: + +Docker Compose Setup +-------------------- + +The :ref:`development section of the documentation ` provides a introduction to +Docker Compose setups for Artemis. +This section provides additional information for administrators. + +File Permissions +^^^^^^^^^^^^^^^^ +If you use the production Docker Compose Setups (``artemis-prod-*.yml``) with bind mounts change +the file permissions accordingly: + +.. code:: bash + + sudo chown -R $(id -u):70 docker/.docker-data/artemis-postgres-data + sudo chown -R $(id -u):999 docker/.docker-data/artemis-mysql-data + sudo chown -R $(id -u):1337 docker/.docker-data/artemis-data diff --git a/docs/dev/setup.rst b/docs/dev/setup.rst index 082a792a6be0..47d87eb8fa82 100644 --- a/docs/dev/setup.rst +++ b/docs/dev/setup.rst @@ -119,7 +119,8 @@ PostgreSQL Setup No special PostgreSQL settings are required. You can either use your package manager’s version, or set it up using a container. -An example Docker Compose setup based on the `official container image `_ is provided in ``src/main/docker/postgresql.yml``. +An example Docker Compose setup based on the `official container image `_ +is provided in ``src/main/docker/postgres.yml``. When setting up the Artemis server, the following values need to be added/updated in the server configuration (see setup steps below) to connect to PostgreSQL instead of MySQL: @@ -719,6 +720,8 @@ HTTP. We need to extend the configuration in the file ------------------------------------------------------------------------------------------------------------------------ +.. _docker_compose_setup_dev: + Alternative: Docker Compose Setup --------------------------------- @@ -765,7 +768,11 @@ The easiest way to configure a local deployment via Docker is a deployment with In the directory ``docker/`` you can find the following *docker compose* files for different **setups**: * ``artemis-dev-mysql.yml``: **Artemis-Dev-MySQL** Setup containing the development build of Artemis and a MySQL DB +* ``artemis-dev-postgres.yml``: **Artemis-Dev-Postgres** Setup containing the development build of Artemis and + a PostgreSQL DB * ``artemis-prod-mysql.yml``: **Artemis-Prod-MySQL** Setup containing the production build of Artemis and a MySQL DB +* ``artemis-prod-postgres.yml``: **Artemis-Prod-Postgres** Setup containing the production build of Artemis and + a PostgreSQL DB * ``atlassian.yml``: **Atlassian** Setup containing a Jira, Bitbucket and Bamboo instance (see `Bamboo, Bitbucket and Jira Setup Guide <#bamboo-bitbucket-and-jira-setup>`__ for the configuration of this setup) @@ -775,14 +782,15 @@ In the directory ``docker/`` you can find the following *docker compose* files f * ``monitoring.yml``: **Prometheus-Grafana** Setup containing a Prometheus and Grafana instance * ``mysql.yml``: **MySQL** Setup containing a MySQL DB instance * ``nginx.yml``: **Nginx** Setup containing a preconfigured Nginx instance -* ``postgresql.yml``: **PostgreSQL** Setup containing a PostgreSQL DB instance +* ``postgres.yml``: **Postgres** Setup containing a PostgreSQL DB instance -Two example commands to run such setups: +Three example commands to run such setups: .. code:: bash docker compose -f docker/atlassian.yml up docker compose -f docker/mysql.yml -f docker/gitlab-jenkins.yml up + docker compose -f docker/artemis-dev-postgres.yml up .. tip:: There is also a single ``docker-compose.yml`` in the directory ``docker/`` which mirrors the setup of ``artemis-prod-mysql.yml``. @@ -796,7 +804,7 @@ is defined in the following files: * ``artemis.yml``: **Artemis Service** * ``mysql.yml``: **MySQL DB Service** * ``nginx.yml``: **Nginx Service** -* ``postgresql.yml``: **PostgreSQL DB Service** +* ``postgres.yml``: **PostgreSQL DB Service** * ``gitlab.yml``: **GitLab Service** * ``jenkins.yml``: **Jenkins Service** From d3075d01c145185312f6d9ff777e6fc7ab9f42ce Mon Sep 17 00:00:00 2001 From: Lennart Keller <44754405+lennart-keller@users.noreply.github.com> Date: Thu, 14 Sep 2023 10:57:04 +0200 Subject: [PATCH 44/47] Communication: Create channels for test-exams (#7171) --- .../service/metis/conversation/ChannelService.java | 3 --- .../app/exam/manage/exams/exam-update.component.ts | 2 +- .../tum/in/www1/artemis/exam/ExamIntegrationTest.java | 5 ++++- .../spec/component/exam/exam-update.component.spec.ts | 11 ++++++++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java index 5bf92bc64838..1bea6402da04 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ChannelService.java @@ -283,9 +283,6 @@ public Channel createExerciseChannel(Exercise exercise, Optional channel * @return the created channel */ public Channel createExamChannel(Exam exam, Optional channelName) { - if (exam.isTestExam()) { - return null; - } Channel channelToCreate = createDefaultChannel(channelName, "exam-", exam.getTitle()); channelToCreate.setIsPublic(false); channelToCreate.setExam(exam); diff --git a/src/main/webapp/app/exam/manage/exams/exam-update.component.ts b/src/main/webapp/app/exam/manage/exams/exam-update.component.ts index 3541b4d1a46a..0997d8694461 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-update.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-update.component.ts @@ -74,7 +74,7 @@ export class ExamUpdateComponent implements OnInit { next: (response: HttpResponse) => { this.exam.course = response.body!; this.course = response.body!; - this.hideChannelNameInput = exam.testExam || (exam.id !== undefined && exam.channelName === undefined) || !isMessagingEnabled(this.course); + this.hideChannelNameInput = (exam.id !== undefined && exam.channelName === undefined) || !isMessagingEnabled(this.course); }, error: (err: HttpErrorResponse) => onError(this.alertService, err), }); diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index 3d3a23e2b37e..b325756679bd 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -1028,9 +1028,12 @@ private List createExamsWithInvalidDates(Course course) { void testCreateTestExam_asInstructor() throws Exception { // Test the creation of a test exam Exam examA = ExamFactory.generateTestExam(course1); - request.post("/api/courses/" + course1.getId() + "/exams", examA, HttpStatus.CREATED); + URI examUri = request.post("/api/courses/" + course1.getId() + "/exams", examA, HttpStatus.CREATED); + Exam savedExam = request.get(String.valueOf(examUri), HttpStatus.OK, Exam.class); verify(examAccessService).checkCourseAccessForInstructorElseThrow(course1.getId()); + Channel channelFromDB = channelRepository.findChannelByExamId(savedExam.getId()); + assertThat(channelFromDB).isNotNull(); } @Test diff --git a/src/test/javascript/spec/component/exam/exam-update.component.spec.ts b/src/test/javascript/spec/component/exam/exam-update.component.spec.ts index bc8f77f8d679..a5e668ad7e97 100644 --- a/src/test/javascript/spec/component/exam/exam-update.component.spec.ts +++ b/src/test/javascript/spec/component/exam/exam-update.component.spec.ts @@ -12,7 +12,7 @@ import { of, throwError } from 'rxjs'; import { RouterTestingModule } from '@angular/router/testing'; import { FormsModule } from '@angular/forms'; import { FontAwesomeTestingModule } from '@fortawesome/angular-fontawesome/testing'; -import { Course } from 'app/entities/course.model'; +import { Course, CourseInformationSharingConfiguration } from 'app/entities/course.model'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; import { MarkdownEditorComponent } from 'app/shared/markdown-editor/markdown-editor.component'; @@ -57,6 +57,7 @@ describe('Exam Update Component', () => { const course = new Course(); course.id = 1; + course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING; const routes = [ { path: 'course-management/:courseId/exams/:examId', component: DummyComponent }, { path: 'course-management/:courseId/exams', component: DummyComponent }, @@ -211,6 +212,14 @@ describe('Exam Update Component', () => { expect(component.isValidConfiguration).toBeFalse(); }); + it('should show channel name input for test exams', fakeAsync(() => { + examWithoutExercises.testExam = true; + examWithoutExercises.channelName = 'test-exam'; + component.ngOnInit(); + tick(); + expect(component.hideChannelNameInput).toBeFalse(); + })); + it('should validate the example solution publication date correctly', () => { const newExamWithoutExercises = new Exam(); newExamWithoutExercises.id = 2; From 9acb62fd1a12adf0efa96dbd505349522ddc2f11 Mon Sep 17 00:00:00 2001 From: Dominik Remo <47261058+DominikRemo@users.noreply.github.com> Date: Thu, 14 Sep 2023 10:58:13 +0200 Subject: [PATCH 45/47] Development: Disable secure cookies for local testing with dev profile (#7162) --- .../tum/in/www1/artemis/security/jwt/JWTCookieService.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/jwt/JWTCookieService.java b/src/main/java/de/tum/in/www1/artemis/security/jwt/JWTCookieService.java index f16565a28e9f..bc58f81579c9 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/jwt/JWTCookieService.java +++ b/src/main/java/de/tum/in/www1/artemis/security/jwt/JWTCookieService.java @@ -17,6 +17,8 @@ public class JWTCookieService { private static final String CYPRESS_PROFILE = "cypress"; + private static final String DEVELOPMENT_PROFILE = "dev"; + private final TokenProvider tokenProvider; private final Environment environment; @@ -56,12 +58,13 @@ public ResponseCookie buildLogoutCookie() { */ private ResponseCookie buildJWTCookie(String jwt, Duration duration) { + // TODO - Remove cypress workaround once cypress uses https and find a better solution for testing locally in Safari Collection activeProfiles = Arrays.asList(environment.getActiveProfiles()); - boolean isCypress = activeProfiles.contains(CYPRESS_PROFILE); + boolean isSecure = !activeProfiles.contains(CYPRESS_PROFILE) && !activeProfiles.contains(DEVELOPMENT_PROFILE); return ResponseCookie.from(JWT_COOKIE_NAME, jwt).httpOnly(true) // Must be httpOnly .sameSite("Lax") // Must be Lax to allow navigation links to Artemis to work - .secure(!isCypress) // Must be secure - TODO - Remove cypress workaround once cypress uses https + .secure(isSecure) // Must be secure .path("/") // Must be "/" to be sent in ALL request .maxAge(duration) // Duration should match the duration of the jwt .build(); // Build cookie From 21e611bde4b188bfc199f6d0c5b4fa397a6bd0f5 Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Thu, 14 Sep 2023 10:59:12 +0200 Subject: [PATCH 46/47] Development: Execute architecture tests during java style action (#7160) --- .github/workflows/test.yml | 10 ++++++ build.gradle | 13 +++++-- .../artemis/AbstractArchitectureTest.java | 13 ++++--- ...ava => AuthorizationArchitectureTest.java} | 31 +--------------- .../AuthorizationEndpointTest.java | 36 +++++++++++++++++++ 5 files changed, 65 insertions(+), 38 deletions(-) rename src/test/java/de/tum/in/www1/artemis/authorization/{AuthorizationTest.java => AuthorizationArchitectureTest.java} (73%) create mode 100644 src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationEndpointTest.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cd3fd6437fd..5daa40581328 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -209,6 +209,16 @@ jobs: - name: Java Documentation run: ./gradlew checkstyleMain -x webapp if: success() || failure() + - name: Java Architecture Tests + run: ./gradlew test -DincludeTags='ArchitectureTest' -x webapp + if: success() || failure() + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() # run this step even if previous step failed + with: + name: Java Architecture Tests + path: build/test-results/test/*.xml + reporter: java-junit client-tests: runs-on: ubuntu-latest diff --git a/build.gradle b/build.gradle index 19452c131ffa..b59c9016479f 100644 --- a/build.gradle +++ b/build.gradle @@ -96,10 +96,17 @@ modernizer { } // Execute the test cases: ./gradlew test - +// Execute only architecture tests: ./gradlew test -DincludeTags='ArchitectureTest' test { - useJUnitPlatform() - exclude "**/*IT*", "**/*IntTest*" + if (System.getProperty("includeTags")) { + useJUnitPlatform { + includeTags System.getProperty("includeTags") + } + } else { + useJUnitPlatform() + exclude "**/*IT*", "**/*IntTest*" + } + testLogging { events "FAILED", "SKIPPED" } diff --git a/src/test/java/de/tum/in/www1/artemis/AbstractArchitectureTest.java b/src/test/java/de/tum/in/www1/artemis/AbstractArchitectureTest.java index 1f0a80ac3352..ded1b8c42ab4 100644 --- a/src/test/java/de/tum/in/www1/artemis/AbstractArchitectureTest.java +++ b/src/test/java/de/tum/in/www1/artemis/AbstractArchitectureTest.java @@ -3,12 +3,14 @@ import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.core.importer.ImportOption; -public abstract class AbstractArchitectureTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { +@Tag("ArchitectureTest") +public abstract class AbstractArchitectureTest { protected static final String ARTEMIS_PACKAGE = "de.tum.in.www1.artemis"; @@ -20,10 +22,11 @@ public abstract class AbstractArchitectureTest extends AbstractSpringIntegration @BeforeAll static void loadClasses() { - testClasses = new ClassFileImporter().withImportOption(new ImportOption.OnlyIncludeTests()).importPackages(ARTEMIS_PACKAGE); - productionClasses = new ClassFileImporter().withImportOption(new ImportOption.DoNotIncludeTests()).importPackages(ARTEMIS_PACKAGE); - allClasses = new ClassFileImporter().importPackages(ARTEMIS_PACKAGE); - + if (allClasses == null) { + testClasses = new ClassFileImporter().withImportOption(new ImportOption.OnlyIncludeTests()).importPackages(ARTEMIS_PACKAGE); + productionClasses = new ClassFileImporter().withImportOption(new ImportOption.DoNotIncludeTests()).importPackages(ARTEMIS_PACKAGE); + allClasses = new ClassFileImporter().importPackages(ARTEMIS_PACKAGE); + } ensureClassSetsNonEmpty(); ensureAllClassesFound(); } diff --git a/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationTest.java b/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationArchitectureTest.java similarity index 73% rename from src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationTest.java rename to src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationArchitectureTest.java index 6974f06d5f6a..49d7c38acc90 100644 --- a/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationArchitectureTest.java @@ -2,34 +2,16 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; -import java.lang.reflect.InvocationTargetException; -import java.util.Map; -import java.util.stream.Collectors; - import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import com.tngtech.archunit.lang.ArchRule; import de.tum.in.www1.artemis.AbstractArchitectureTest; import de.tum.in.www1.artemis.security.annotations.*; -/** - * Contains the one automatic test covering all rest endpoints for authorization tests. - */ -class AuthorizationTest extends AbstractArchitectureTest { - - @Autowired - private ApplicationContext applicationContext; - - @Autowired - private AuthorizationTestService authorizationTestService; +class AuthorizationArchitectureTest extends AbstractArchitectureTest { private static final String ARTEMIS_PACKAGE = "de.tum.in.www1.artemis"; @@ -39,17 +21,6 @@ class AuthorizationTest extends AbstractArchitectureTest { private static final String REST_OPEN_PACKAGE = REST_BASE_PACKAGE + ".open"; - @Test - void testEndpoints() throws InvocationTargetException, IllegalAccessException { - var requestMappingHandlerMapping = applicationContext.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class); - Map endpointMap = requestMappingHandlerMapping.getHandlerMethods(); - // Filter out endpoints that should not be tested. - endpointMap = endpointMap.entrySet().stream().filter(entry -> authorizationTestService.validEndpointToTest(entry.getValue(), false)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - authorizationTestService.testEndpoints(endpointMap); - } - @Test void testNoPreAuthorizeOnRestControllers() { ArchRule rule = noClasses().that().areAnnotatedWith(RestController.class).should().beAnnotatedWith(PreAuthorize.class).because( diff --git a/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationEndpointTest.java b/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationEndpointTest.java new file mode 100644 index 000000000000..365a07916d17 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/authorization/AuthorizationEndpointTest.java @@ -0,0 +1,36 @@ +package de.tum.in.www1.artemis.authorization; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; + +/** + * Contains the one automatic test covering all rest endpoints for authorization tests. + */ +class AuthorizationEndpointTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private AuthorizationTestService authorizationTestService; + + @Test + void testEndpoints() { + var requestMappingHandlerMapping = applicationContext.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class); + Map endpointMap = requestMappingHandlerMapping.getHandlerMethods(); + // Filter out endpoints that should not be tested. + endpointMap = endpointMap.entrySet().stream().filter(entry -> authorizationTestService.validEndpointToTest(entry.getValue(), false)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + authorizationTestService.testEndpoints(endpointMap); + } +} From dfe461c601842f8dfe190b12446f2d6f1d2739d2 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Thu, 14 Sep 2023 11:55:12 +0200 Subject: [PATCH 47/47] Development: Remove --legacy-peer-deps and fix postgres file link (#7198) --- docker/artemis-migration-check-postgres.yml | 4 ++-- docker/cypress-E2E-tests-mysql.yml | 2 +- docker/cypress-E2E-tests-postgres.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/artemis-migration-check-postgres.yml b/docker/artemis-migration-check-postgres.yml index 0d801be844d2..5f7cf661c39f 100644 --- a/docker/artemis-migration-check-postgres.yml +++ b/docker/artemis-migration-check-postgres.yml @@ -17,8 +17,8 @@ services: condition: service_healthy postgresql: extends: - file: ./postgresql.yml - service: postgresql + file: ./postgres.yml + service: postgres migration-check: image: alpine container_name: migration-check diff --git a/docker/cypress-E2E-tests-mysql.yml b/docker/cypress-E2E-tests-mysql.yml index 96f9fe67989f..99e96f8b7c87 100644 --- a/docker/cypress-E2E-tests-mysql.yml +++ b/docker/cypress-E2E-tests-mysql.yml @@ -45,7 +45,7 @@ services: environment: CYPRESS_DB_TYPE: "MySQL" SORRY_CYPRESS_PROJECT_ID: "artemis-mysql" - command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci --legacy-peer-deps && npm run cypress:setup && (npm run cypress:record:mysql & sleep 60 && npm run cypress:record:mysql & wait)" + command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:mysql & sleep 60 && npm run cypress:record:mysql & wait)" networks: artemis: diff --git a/docker/cypress-E2E-tests-postgres.yml b/docker/cypress-E2E-tests-postgres.yml index a42d2232adc7..0c2ed641ee70 100644 --- a/docker/cypress-E2E-tests-postgres.yml +++ b/docker/cypress-E2E-tests-postgres.yml @@ -46,7 +46,7 @@ services: environment: CYPRESS_DB_TYPE: "Postgres" SORRY_CYPRESS_PROJECT_ID: "artemis-postgres" - command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci --legacy-peer-deps && npm run cypress:setup && (npm run cypress:record:postgres & sleep 60 && npm run cypress:record:postgres & wait)" + command: sh -c "cd /app/artemis/src/test/cypress && chmod 777 /root && npm ci && npm run cypress:setup && (npm run cypress:record:postgres & sleep 60 && npm run cypress:record:postgres & wait)" networks: artemis: