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 09/67] 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 10/67] 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 11/67] 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 12/67] 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 13/67] 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 @@