diff --git a/build.gradle b/build.gradle index e06f841bcaa4..fc7aa6be7e86 100644 --- a/build.gradle +++ b/build.gradle @@ -180,13 +180,13 @@ jacocoTestCoverageVerification { counter = "INSTRUCTION" value = "COVEREDRATIO" // TODO: in the future the following value should become higher than 0.92 - minimum = 0.893 + minimum = 0.892 } limit { counter = "CLASS" value = "MISSEDCOUNT" // TODO: in the future the following value should become less than 10 - maximum = 64 + maximum = 65 } } } diff --git a/docs/user/exercises/programming-exercise-setup.inc b/docs/user/exercises/programming-exercise-setup.inc index 8a2dc7cf9f78..563f65f48361 100644 --- a/docs/user/exercises/programming-exercise-setup.inc +++ b/docs/user/exercises/programming-exercise-setup.inc @@ -404,7 +404,8 @@ Edit Maximum Build Duration ^^^^^^^^^^^^^^^^^^^^^^^^^^^ **This option is only available when using** :ref:`integrated code lifecycle` -This section is optional. In most cases, the preconfigured build script does not need to be changed. + +This section is optional. In most cases, the default maximum build duration does not need to be changed. The maximum build duration is the time limit for the build plan to execute. If the build plan exceeds this time limit, it will be terminated. The default value is 120 seconds. You can change the maximum build duration by using the slider. @@ -412,6 +413,29 @@ You can change the maximum build duration by using the slider. .. figure:: programming/timeout-slider.png :align: center +Edit Container Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**This option is only available when using** :ref:`integrated code lifecycle` + +This section is optional. In most cases, the default container configuration does not need to be changed. + +Currently, instructors can only change whether the container has internet access and add additional environment variables. +Disabling internet access can be useful if instructors want to prevent students from downloading additional dependencies during the build process. +If internet access is disabled, the container cannot access the internet during the build process. Thus, it will not be able to download additional dependencies. +The dependencies must then be included/cached in the docker image. + +Additional environment variables can be added to the container configuration. This can be useful if the build process requires additional environment variables to be set. + +.. figure:: programming/docker-flags-edit.png + :align: center + +We plan to add more options to the container configuration in the future. + +.. warning:: + - Disabling internet access is not currently supported for Swift and Haskell exercises. + + .. _configure_static_code_analysis_tools: Configure static code analysis diff --git a/docs/user/exercises/programming/docker-flags-edit.png b/docs/user/exercises/programming/docker-flags-edit.png new file mode 100644 index 000000000000..06a030f69f18 Binary files /dev/null and b/docs/user/exercises/programming/docker-flags-edit.png differ diff --git a/jest.config.js b/jest.config.js index 2d9bdaf6311a..8f3838cd5088 100644 --- a/jest.config.js +++ b/jest.config.js @@ -105,10 +105,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 87.72, - branches: 73.83, - functions: 82.29, - lines: 87.78, + statements: 87.69, + branches: 73.79, + functions: 82.27, + lines: 87.74, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java index 9a41fc6fdc20..34d139aa6f19 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java @@ -15,7 +15,8 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record BuildConfig(String buildScript, String dockerImage, String commitHashToBuild, String assignmentCommitHash, String testCommitHash, String branch, ProgrammingLanguage programmingLanguage, ProjectType projectType, boolean scaEnabled, boolean sequentialTestRunsEnabled, boolean testwiseCoverageEnabled, - List resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath) implements Serializable { + List resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath, DockerRunConfig dockerRunConfig) + implements Serializable { @Override public String dockerImage() { diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java new file mode 100644 index 000000000000..bb10c5ddf313 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java @@ -0,0 +1,6 @@ +package de.tum.cit.aet.artemis.buildagent.dto; + +import java.util.Map; + +public record DockerFlagsDTO(String network, Map env) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java new file mode 100644 index 000000000000..2b45273e13fd --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.buildagent.dto; + +import java.io.Serializable; +import java.util.List; + +public record DockerRunConfig(boolean isNetworkDisabled, List env) implements Serializable { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java index cfe5a1ab01e3..b68cc7a0c001 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java @@ -85,12 +85,13 @@ public BuildJobContainerService(DockerClient dockerClient, HostConfig hostConfig /** * Configure a container with the Docker image, the container name, optional proxy config variables, and set the command that runs when the container starts. * - * @param containerName the name of the container to be created - * @param image the Docker image to use for the container - * @param buildScript the build script to be executed in the container + * @param containerName the name of the container to be created + * @param image the Docker image to use for the container + * @param buildScript the build script to be executed in the container + * @param exerciseEnvVars the environment variables provided by the instructor * @return {@link CreateContainerResponse} that can be used to start the container */ - public CreateContainerResponse configureContainer(String containerName, String image, String buildScript) { + public CreateContainerResponse configureContainer(String containerName, String image, String buildScript, List exerciseEnvVars) { List envVars = new ArrayList<>(); if (useSystemProxy) { envVars.add("HTTP_PROXY=" + httpProxy); @@ -98,6 +99,9 @@ public CreateContainerResponse configureContainer(String containerName, String i envVars.add("NO_PROXY=" + noProxy); } envVars.add("SCRIPT=" + buildScript); + if (exerciseEnvVars != null && !exerciseEnvVars.isEmpty()) { + envVars.addAll(exerciseEnvVars); + } return dockerClient.createContainerCmd(image).withName(containerName).withHostConfig(hostConfig).withEnv(envVars) // Command to run when the container starts. This is the command that will be executed in the container's main process, which runs in the foreground and blocks the // container from exiting until it finishes. @@ -121,11 +125,23 @@ public void startContainer(String containerId) { /** * Run the script in the container and wait for it to finish before returning. * - * @param containerId the id of the container in which the script should be run - * @param buildJobId the id of the build job that is currently being executed + * @param containerId the id of the container in which the script should be run + * @param buildJobId the id of the build job that is currently being executed + * @param isNetworkDisabled whether the network should be disabled for the container */ + public void runScriptInContainer(String containerId, String buildJobId, boolean isNetworkDisabled) { + if (isNetworkDisabled) { + log.info("disconnecting container with id {} from network", containerId); + try { + dockerClient.disconnectFromNetworkCmd().withContainerId(containerId).withNetworkId("bridge").exec(); + } + catch (Exception e) { + log.error("Failed to disconnect container with id {} from network: {}", containerId, e.getMessage()); + buildLogsMap.appendBuildLogEntry(buildJobId, "Failed to disconnect container from default network 'bridge': " + e.getMessage()); + throw new LocalCIException("Failed to disconnect container from default network 'bridge': " + e.getMessage()); + } + } - public void runScriptInContainer(String containerId, String buildJobId) { log.info("Started running the build script for build job in container with id {}", containerId); // The "sh script.sh" execution command specified here is run inside the container as an additional process. This command runs in the background, independent of the // container's @@ -448,9 +464,4 @@ private Container getContainerForName(String containerName) { List containers = dockerClient.listContainersCmd().withShowAll(true).exec(); return containers.stream().filter(container -> container.getNames()[0].equals("/" + containerName)).findFirst().orElse(null); } - - private String getParentFolderPath(String path) { - Path parentPath = Paths.get(path).normalize().getParent(); - return parentPath != null ? parentPath.toString() : ""; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java index 7c789cfafb28..c5e042b7f20e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java @@ -232,10 +232,18 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName) index++; } - CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript()); + List envVars = null; + boolean isNetworkDisabled = false; + if (buildJob.buildConfig().dockerRunConfig() != null) { + envVars = buildJob.buildConfig().dockerRunConfig().env(); + isNetworkDisabled = buildJob.buildConfig().dockerRunConfig().isNetworkDisabled(); + } + + CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript(), + envVars); return runScriptAndParseResults(buildJob, containerName, container.getId(), assignmentRepoUri, testsRepoUri, solutionRepoUri, auxiliaryRepositoriesUris, - assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash); + assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash, isNetworkDisabled); } /** @@ -270,7 +278,7 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName) private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String containerName, String containerId, VcsRepositoryUri assignmentRepositoryUri, VcsRepositoryUri testRepositoryUri, VcsRepositoryUri solutionRepositoryUri, VcsRepositoryUri[] auxiliaryRepositoriesUris, Path assignmentRepositoryPath, Path testsRepositoryPath, Path solutionRepositoryPath, Path[] auxiliaryRepositoriesPaths, @Nullable String assignmentRepoCommitHash, - @Nullable String testRepoCommitHash) { + @Nullable String testRepoCommitHash, boolean isNetworkDisabled) { long timeNanoStart = System.nanoTime(); @@ -292,7 +300,7 @@ private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); log.debug(msg); - buildJobContainerService.runScriptInContainer(containerId, buildJob.id()); + buildJobContainerService.runScriptInContainer(containerId, buildJob.id(), isNetworkDisabled); msg = "~~~~~~~~~~~~~~~~~~~~ Finished Executing Build Script for Build job " + buildJob.id() + " ~~~~~~~~~~~~~~~~~~~~"; buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/GroupNotificationFactory.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/GroupNotificationFactory.java index 62af1c1ea18a..5c73ce127e07 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/GroupNotificationFactory.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/GroupNotificationFactory.java @@ -242,16 +242,18 @@ public static GroupNotification createAnnouncementNotification(Post post, User a GroupNotification notification; title = NotificationConstants.NEW_ANNOUNCEMENT_POST_TITLE; text = NotificationConstants.NEW_ANNOUNCEMENT_POST_TEXT; + var imageUrl = post.getAuthor().getImageUrl() == null ? "" : post.getAuthor().getImageUrl(); placeholderValues = createPlaceholdersNewAnnouncementPost(course.getTitle(), post.getTitle(), Jsoup.parse(post.getContent()).text(), post.getCreationDate().toString(), - post.getAuthor().getName()); + post.getAuthor().getName(), imageUrl, post.getAuthor().getId().toString(), post.getId().toString()); notification = new GroupNotification(course, title, text, true, placeholderValues, author, groupNotificationType); notification.setTransientAndStringTarget(createCoursePostTarget(post, course)); return notification; } @NotificationPlaceholderCreator(values = { NEW_ANNOUNCEMENT_POST }) - public static String[] createPlaceholdersNewAnnouncementPost(String courseTitle, String postTitle, String postContent, String postCreationDate, String postAuthorName) { - return new String[] { courseTitle, postTitle, postContent, postCreationDate, postAuthorName }; + public static String[] createPlaceholdersNewAnnouncementPost(String courseTitle, String postTitle, String postContent, String postCreationDate, String postAuthorName, + String imageUrl, String authorId, String postId) { + return new String[] { courseTitle, postTitle, postContent, postCreationDate, postAuthorName, imageUrl, authorId, postId }; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java index 71dc52ae4e93..301cb1bf955b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java @@ -321,9 +321,11 @@ public static SingleUserNotification createNotification(AnswerPost answerPost, N } Conversation conversation = answerPost.getPost().getConversation(); + var imageUrl = answerPost.getAuthor().getImageUrl() != null ? answerPost.getAuthor().getImageUrl() : ""; var placeholders = createPlaceholdersNewReply(conversation.getCourse().getTitle(), answerPost.getPost().getContent(), answerPost.getPost().getCreationDate().toString(), answerPost.getPost().getAuthor().getName(), answerPost.getContent(), answerPost.getCreationDate().toString(), answerPost.getAuthor().getName(), - conversation.getHumanReadableNameForReceiver(answerPost.getAuthor())); + conversation.getHumanReadableNameForReceiver(answerPost.getAuthor()), imageUrl, answerPost.getAuthor().getId().toString(), answerPost.getId().toString(), + answerPost.getPost().getId().toString()); String messageReplyTextType = MESSAGE_REPLY_IN_CONVERSATION_TEXT; @@ -340,8 +342,9 @@ public static SingleUserNotification createNotification(AnswerPost answerPost, N @NotificationPlaceholderCreator(values = { NEW_REPLY_FOR_EXERCISE_POST, NEW_REPLY_FOR_LECTURE_POST, NEW_REPLY_FOR_COURSE_POST, NEW_REPLY_FOR_EXAM_POST, CONVERSATION_NEW_REPLY_MESSAGE, CONVERSATION_USER_MENTIONED }) public static String[] createPlaceholdersNewReply(String courseTitle, String postContent, String postCreationData, String postAuthorName, String answerPostContent, - String answerPostCreationDate, String authorName, String conversationName) { - return new String[] { courseTitle, postContent, postCreationData, postAuthorName, answerPostContent, answerPostCreationDate, authorName, conversationName }; + String answerPostCreationDate, String authorName, String conversationName, String imageUrl, String userId, String postingId, String parentPostId) { + return new String[] { courseTitle, postContent, postCreationData, postAuthorName, answerPostContent, answerPostCreationDate, authorName, conversationName, imageUrl, userId, + postingId, parentPostId }; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationApiType.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationApiType.java new file mode 100644 index 000000000000..f191a4a08645 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationApiType.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain.push_notification; + +import java.util.Arrays; + +public enum PushNotificationApiType { + + DEFAULT((short) 0), IOS_V2((short) 1); + + private final short databaseKey; + + PushNotificationApiType(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static PushNotificationApiType fromDatabaseKey(short databaseKey) { + return Arrays.stream(PushNotificationApiType.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java index b9a911ce6194..8c0aafae5cea 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java @@ -6,6 +6,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.IdClass; import jakarta.persistence.JoinColumn; @@ -34,6 +35,10 @@ public class PushNotificationDeviceConfiguration { @Column(name = "device_type") private PushNotificationDeviceType deviceType; + @Enumerated + @Column(name = "api_type") + private PushNotificationApiType apiType; + @Column(name = "expiration_date") private Date expirationDate; @@ -53,6 +58,16 @@ public PushNotificationDeviceConfiguration(String token, PushNotificationDeviceT this.owner = owner; } + public PushNotificationDeviceConfiguration(String token, PushNotificationDeviceType deviceType, Date expirationDate, byte[] secretKey, User owner, + PushNotificationApiType apiType) { + this.token = token; + this.deviceType = deviceType; + this.expirationDate = expirationDate; + this.secretKey = secretKey; + this.owner = owner; + this.apiType = apiType; + } + public PushNotificationDeviceConfiguration() { // needed for JPA } @@ -97,6 +112,10 @@ public void setOwner(User owner) { this.owner = owner; } + public PushNotificationApiType getApiType() { + return apiType; + } + @Override public boolean equals(Object object) { if (this == object) { diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PushNotificationRegisterBody.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PushNotificationRegisterBody.java index 985039d9fba8..7455e2e7d530 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PushNotificationRegisterBody.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PushNotificationRegisterBody.java @@ -1,6 +1,11 @@ package de.tum.cit.aet.artemis.communication.dto; +import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationApiType; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceType; -public record PushNotificationRegisterBody(String token, PushNotificationDeviceType deviceType) { +public record PushNotificationRegisterBody(String token, PushNotificationDeviceType deviceType, PushNotificationApiType apiType) { + + public PushNotificationRegisterBody(String token, PushNotificationDeviceType deviceType) { + this(token, deviceType, PushNotificationApiType.DEFAULT); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java index cf5cc2c67cc8..91cc23d2f6f3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java @@ -84,8 +84,10 @@ public ConversationNotification createNotification(Post createdMessage, Conversa } default -> throw new IllegalStateException("Unexpected value: " + conversation); } + var imageUrl = createdMessage.getAuthor().getImageUrl() == null ? "" : createdMessage.getAuthor().getImageUrl(); String[] placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), - conversationName, createdMessage.getAuthor().getName(), conversationType); + conversationName, createdMessage.getAuthor().getName(), conversationType, imageUrl, createdMessage.getAuthor().getId().toString(), + createdMessage.getId().toString()); ConversationNotification notification = createConversationMessageNotification(course.getId(), createdMessage, notificationType, notificationText, true, placeholders); save(notification, mentionedUsers, placeholders, createdMessage); return notification; @@ -93,8 +95,8 @@ public ConversationNotification createNotification(Post createdMessage, Conversa @NotificationPlaceholderCreator(values = { CONVERSATION_NEW_MESSAGE }) public static String[] createPlaceholdersNewMessageChannelText(String courseTitle, String messageContent, String messageCreationDate, String conversationName, - String authorName, String conversationType) { - return new String[] { courseTitle, messageContent, messageCreationDate, conversationName, authorName, conversationType }; + String authorName, String conversationType, String imageUrl, String userId, String postId) { + return new String[] { courseTitle, messageContent, messageCreationDate, conversationName, authorName, conversationType, imageUrl, userId, postId }; } private void save(ConversationNotification notification, Set mentionedUsers, String[] placeHolders, Post createdMessage) { diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/GroupNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/GroupNotificationService.java index 6e48ec307044..4ee87d9c0173 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/GroupNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/GroupNotificationService.java @@ -120,6 +120,7 @@ private void notifyGroupsWithNotificationType(GroupNotificationType[] groups, No * @param author is the user who initiated the process of the notifications. Can be null if not specified * @param onlySave whether the notification should only be saved and not sent to users */ + @SuppressWarnings("unchecked") private void notifyGroupsWithNotificationType(GroupNotificationType[] groups, NotificationType notificationType, Object notificationSubject, Object typeSpecificInformation, User author, boolean onlySave) { for (GroupNotificationType group : groups) { diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java index 3892421894ee..22c2a1d54413 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/PushNotificationService.java @@ -155,11 +155,11 @@ public void sendNotification(Notification notification, Set users, Object } final String date = Instant.now().toString(); - var notificationData = new PushNotificationData(notification.getTransientPlaceholderValuesAsArray(), notification.getTarget(), type.name(), date, - Constants.PUSH_NOTIFICATION_VERSION); try { - final String payload = mapper.writeValueAsString(notificationData); + var notificationData = new PushNotificationData(notification.getTransientPlaceholderValuesAsArray(), notification.getTarget(), type.name(), date, + Constants.PUSH_NOTIFICATION_VERSION); + var payload = mapper.writeValueAsString(notificationData); final byte[] initializationVector = new byte[16]; List notificationRequests = userDeviceConfigurations.stream().flatMap(deviceConfiguration -> { @@ -170,7 +170,8 @@ public void sendNotification(Notification notification, Set users, Object String ivAsString = Base64.getEncoder().encodeToString(initializationVector); Optional payloadCiphertext = encrypt(payload, key, initializationVector); - return payloadCiphertext.stream().map(s -> new RelayNotificationRequest(ivAsString, s, deviceConfiguration.getToken())); + return payloadCiphertext.stream() + .map(s -> new RelayNotificationRequest(ivAsString, s, deviceConfiguration.getToken(), deviceConfiguration.getApiType().getDatabaseKey())); }).toList(); sendNotificationRequestsToEndpoint(notificationRequests, relayServerBaseUrl.get()); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/RelayNotificationRequest.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/RelayNotificationRequest.java index 3cbe5695ccc8..91f43d2b9088 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/RelayNotificationRequest.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/push_notifications/RelayNotificationRequest.java @@ -1,4 +1,4 @@ package de.tum.cit.aet.artemis.communication.service.notifications.push_notifications; -public record RelayNotificationRequest(String initializationVector, String payloadCipherText, String token) { +public record RelayNotificationRequest(String initializationVector, String payloadCipherText, String token, short apiType) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/PushNotificationResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/PushNotificationResource.java index b64fdf4c6af5..3078e9f3d6cd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/PushNotificationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/PushNotificationResource.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationApiType; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceConfiguration; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceConfigurationId; import de.tum.cit.aet.artemis.communication.dto.PushNotificationRegisterBody; @@ -100,10 +101,12 @@ public ResponseEntity register(@Valid @RequestBody return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + PushNotificationApiType apiType = pushNotificationRegisterBody.apiType() != null ? pushNotificationRegisterBody.apiType() : PushNotificationApiType.DEFAULT; + User user = userRepository.getUser(); PushNotificationDeviceConfiguration deviceConfiguration = new PushNotificationDeviceConfiguration(pushNotificationRegisterBody.token(), - pushNotificationRegisterBody.deviceType(), expirationDate, newKey.getEncoded(), user); + pushNotificationRegisterBody.deviceType(), expirationDate, newKey.getEncoded(), user, apiType); pushNotificationDeviceConfigurationRepository.save(deviceConfiguration); var encodedKey = Base64.getEncoder().encodeToString(newKey.getEncoded()); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 6ddd70dad841..843a9034d46c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -39,6 +39,8 @@ public final class Constants { public static final int QUIZ_GRACE_PERIOD_IN_SECONDS = 5; + public static final int MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH = 1000; + /** * This constant determines how many seconds after the exercise due dates submissions will still be considered rated. * Submissions after the grace period exceeded will be flagged as illegal. @@ -376,6 +378,11 @@ public final class Constants { */ public static final int PUSH_NOTIFICATION_VERSION = 1; + /** + * The value of the version field we send with each push notification to the native clients (Android & iOS). + */ + public static final int PUSH_NOTIFICATION_MINOR_VERSION = 2; + /** * The directory in the docker container in which the build script is executed */ diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java index 40184805ade5..fe337ea23d5f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java @@ -1421,17 +1421,17 @@ public void cleanupExam(Long examId, Principal principal) { * considering the use of Compass. * * @param exam The exam entity for which the student exams and exercises need to be updated and rescheduled. The student exams must be already loaded. - * @param originalExamDuration The original duration of the exam, in minutes, before any changes. - * @param workingTimeChange The amount of time, in minutes, to add or subtract from the exam's original duration and the student's working time. This value can be positive + * @param originalExamDuration The original duration of the exam, in seconds, before any changes. + * @param workingTimeChange The amount of time, in seconds, to add or subtract from the exam's original duration and the student's working time. This value can be positive * (to extend time) or negative (to reduce time). */ - public void updateStudentExamsAndRescheduleExercises(Exam exam, Integer originalExamDuration, Integer workingTimeChange) { + public void updateStudentExamsAndRescheduleExercises(Exam exam, int originalExamDuration, int workingTimeChange) { var now = now(); User instructor = userRepository.getUser(); var studentExams = exam.getStudentExams(); for (var studentExam : studentExams) { - Integer originalStudentWorkingTime = studentExam.getWorkingTime(); + int originalStudentWorkingTime = studentExam.getWorkingTime(); int originalTimeExtension = originalStudentWorkingTime - originalExamDuration; // NOTE: take the original working time extensions into account if (originalTimeExtension == 0) { diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java index 8223ba8e54a9..ddebe7b74068 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java @@ -313,7 +313,7 @@ public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody */ @PatchMapping("courses/{courseId}/exams/{examId}/working-time") @EnforceAtLeastInstructor - public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody Integer workingTimeChange) { + public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody int workingTimeChange) { log.debug("REST request to update the working time of exam with id {}", examId); examAccessService.checkCourseAndExamAccessForInstructorElseThrow(courseId, examId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java index a5ada6708999..d28e21bb3ad1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java @@ -60,7 +60,7 @@ public class ProgrammingExerciseBuildConfig extends DomainObject { @Column(name = "timeout_seconds") private int timeoutSeconds; - @Column(name = "docker_flags") + @Column(name = "docker_flags", columnDefinition = "longtext") private String dockerFlags; @OneToOne(mappedBy = "buildConfig") diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java new file mode 100644 index 000000000000..5ccf7f2045a6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java @@ -0,0 +1,90 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import jakarta.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.buildagent.dto.DockerFlagsDTO; +import de.tum.cit.aet.artemis.buildagent.dto.DockerRunConfig; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; + +@Profile(PROFILE_CORE) +@Service +public class ProgrammingExerciseBuildConfigService { + + private static final Logger log = org.slf4j.LoggerFactory.getLogger(ProgrammingExerciseBuildConfigService.class); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Converts a JSON string representing Docker flags (in JSON format) + * into a {@link DockerRunConfig} instance. + * + *

+ * The JSON string is expected to represent a {@link DockerFlagsDTO} object. + * Example JSON input: + * + *

+     * {"network":"none","env":{"key1":"value1","key2":"value2"}}
+     * 
+ * + * @param buildConfig the build config containing the Docker flags + * @return a {@link DockerRunConfig} object initialized with the parsed flags, or {@code null} if the JSON string is empty + */ + @Nullable + public DockerRunConfig getDockerRunConfig(ProgrammingExerciseBuildConfig buildConfig) { + DockerFlagsDTO dockerFlagsDTO = parseDockerFlags(buildConfig); + + return getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + } + + DockerRunConfig getDockerRunConfigFromParsedFlags(DockerFlagsDTO dockerFlagsDTO) { + if (dockerFlagsDTO == null) { + return null; + } + List env = new ArrayList<>(); + boolean isNetworkDisabled = dockerFlagsDTO.network() != null && dockerFlagsDTO.network().equals("none"); + + if (dockerFlagsDTO.env() != null) { + for (Map.Entry entry : dockerFlagsDTO.env().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + env.add(key + "=" + value); + } + } + + return new DockerRunConfig(isNetworkDisabled, env); + } + + /** + * Parses the JSON string representing Docker flags into DockerFlagsDTO. (see {@link DockerFlagsDTO}) + * + * @return a list of key-value pairs, or {@code null} if the JSON string is empty + * @throws IllegalArgumentException if the JSON string is invalid + */ + @Nullable + DockerFlagsDTO parseDockerFlags(ProgrammingExerciseBuildConfig buildConfig) { + if (StringUtils.isBlank(buildConfig.getDockerFlags())) { + return null; + } + + try { + return objectMapper.readValue(buildConfig.getDockerFlags(), DockerFlagsDTO.class); + } + catch (Exception e) { + log.error("Failed to parse DockerRunConfig from JSON string: {}. Using default settings.", buildConfig.getDockerFlags()); + throw new IllegalArgumentException("Failed to parse DockerRunConfig from JSON string: " + buildConfig.getDockerFlags(), e); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 20b546ad4a48..c60fbc5b9d34 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.programming.service; import static de.tum.cit.aet.artemis.core.config.Constants.ALLOWED_CHECKOUT_DIRECTORY; +import static de.tum.cit.aet.artemis.core.config.Constants.MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.SOLUTION; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.TEMPLATE; @@ -46,6 +47,8 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.buildagent.dto.DockerFlagsDTO; +import de.tum.cit.aet.artemis.buildagent.dto.DockerRunConfig; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -185,6 +188,8 @@ public class ProgrammingExerciseService { private final CompetencyProgressApi competencyProgressApi; + private final ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService; + public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerciseRepository, GitService gitService, Optional versionControlService, Optional continuousIntegrationService, Optional continuousIntegrationTriggerService, TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, @@ -199,7 +204,8 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc ProgrammingSubmissionService programmingSubmissionService, Optional irisSettingsService, Optional aeolusTemplateService, Optional buildScriptGenerationService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProfileService profileService, ExerciseService exerciseService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi, + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { this.programmingExerciseRepository = programmingExerciseRepository; this.gitService = gitService; this.versionControlService = versionControlService; @@ -233,6 +239,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc this.exerciseService = exerciseService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; this.competencyProgressApi = competencyProgressApi; + this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; } /** @@ -372,6 +379,7 @@ public void validateNewProgrammingExerciseSettings(ProgrammingExercise programmi programmingExercise.validateProgrammingSettings(); programmingExercise.validateSettingsForFeedbackRequest(); validateCustomCheckoutPaths(programmingExercise); + validateDockerFlags(programmingExercise); auxiliaryRepositoryService.validateAndAddAuxiliaryRepositoriesOfProgrammingExercise(programmingExercise, programmingExercise.getAuxiliaryRepositories()); submissionPolicyService.validateSubmissionPolicyCreation(programmingExercise); @@ -1072,4 +1080,40 @@ public ProgrammingExercise loadProgrammingExerciseWithAuxiliaryRepositories(long final Set fetchOptions = Set.of(AuxiliaryRepositories); return programmingExerciseRepository.findByIdWithDynamicFetchElseThrow(exerciseId, fetchOptions); } + + /** + * Validates the network access feature for the given programming language. + * Currently, SWIFT and HASKELL do not support disabling the network access feature. + * + * @param programmingExercise the programming exercise to validate + */ + public void validateDockerFlags(ProgrammingExercise programmingExercise) { + ProgrammingExerciseBuildConfig buildConfig = programmingExercise.getBuildConfig(); + DockerFlagsDTO dockerFlagsDTO; + try { + dockerFlagsDTO = programmingExerciseBuildConfigService.parseDockerFlags(buildConfig); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Error while parsing the docker flags", "Exercise", "dockerFlagsParsingError"); + } + + if (dockerFlagsDTO == null) { + return; + } + + if (dockerFlagsDTO.env() != null) { + for (var entry : dockerFlagsDTO.env().entrySet()) { + if (entry.getKey().length() > MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH || entry.getValue().length() > MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH) { + throw new BadRequestAlertException("The environment variables are too long. Max " + MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH + " chars", "Exercise", + "envVariablesTooLong"); + } + } + } + + DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + + if (List.of(ProgrammingLanguage.SWIFT, ProgrammingLanguage.HASKELL).contains(programmingExercise.getProgrammingLanguage()) && dockerRunConfig.isNetworkDisabled()) { + throw new BadRequestAlertException("This programming language does not support disabling the network access feature", "Exercise", "networkAccessNotSupported"); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index d25304141c24..0e081a93728b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -174,7 +174,7 @@ private static List removeUnnecessaryInformation(List versionControlService, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, LocalCIBuildConfigurationService localCIBuildConfigurationService, GitService gitService, ExerciseDateService exerciseDateService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService, + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { this.hazelcastInstance = hazelcastInstance; this.aeolusTemplateService = aeolusTemplateService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; @@ -119,6 +124,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; this.exerciseDateService = exerciseDateService; this.buildScriptProviderService = buildScriptProviderService; + this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; } @PostConstruct @@ -310,6 +316,8 @@ private BuildConfig getBuildConfig(ProgrammingExerciseParticipation participatio dockerImage = programmingLanguageConfiguration.getImage(programmingExercise.getProgrammingLanguage(), Optional.ofNullable(programmingExercise.getProjectType())); } + DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.getDockerRunConfig(buildConfig); + List resultPaths = getTestResultPaths(windfile); resultPaths = buildScriptProviderService.replaceResultPathsPlaceholders(resultPaths, buildConfig); @@ -319,7 +327,7 @@ private BuildConfig getBuildConfig(ProgrammingExerciseParticipation participatio return new BuildConfig(buildScript, dockerImage, commitHashToBuild, assignmentCommitHash, testCommitHash, branch, programmingLanguage, projectType, staticCodeAnalysisEnabled, sequentialTestRunsEnabled, testwiseCoverageEnabled, resultPaths, buildConfig.getTimeoutSeconds(), - buildConfig.getAssignmentCheckoutPath(), buildConfig.getTestCheckoutPath(), buildConfig.getSolutionCheckoutPath()); + buildConfig.getAssignmentCheckoutPath(), buildConfig.getTestCheckoutPath(), buildConfig.getSolutionCheckoutPath(), dockerRunConfig); } private ProgrammingExerciseBuildConfig loadBuildConfig(ProgrammingExercise programmingExercise) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index 748645568dc6..da74833808c0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -78,6 +78,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseExportService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportFromFileService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportService; +import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeatureService; import de.tum.cit.aet.artemis.programming.service.SubmissionPolicyService; @@ -130,13 +131,15 @@ public class ProgrammingExerciseExportImportResource { private final Optional athenaModuleService; + private final ProgrammingExerciseService programmingExerciseService; + public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository programmingExerciseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, CourseService courseService, ProgrammingExerciseImportService programmingExerciseImportService, ProgrammingExerciseExportService programmingExerciseExportService, Optional programmingLanguageFeatureService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, SubmissionPolicyService submissionPolicyService, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ExamAccessService examAccessService, CourseRepository courseRepository, ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService, ConsistencyCheckService consistencyCheckService, - Optional athenaModuleService, CompetencyProgressApi competencyProgressApi) { + Optional athenaModuleService, CompetencyProgressApi competencyProgressApi, ProgrammingExerciseService programmingExerciseService) { this.programmingExerciseRepository = programmingExerciseRepository; this.userRepository = userRepository; this.courseService = courseService; @@ -153,6 +156,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro this.consistencyCheckService = consistencyCheckService; this.athenaModuleService = athenaModuleService; this.competencyProgressApi = competencyProgressApi; + this.programmingExerciseService = programmingExerciseService; } /** @@ -199,6 +203,7 @@ public ResponseEntity importProgrammingExercise(@PathVariab newExercise.validateGeneralSettings(); newExercise.validateProgrammingSettings(); newExercise.validateSettingsForFeedbackRequest(); + programmingExerciseService.validateDockerFlags(newExercise); validateStaticCodeAnalysisSettings(newExercise); final User user = userRepository.getUserWithGroupsAndAuthorities(); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 0a39cabc4e06..0eaab82ce448 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -329,6 +329,9 @@ public ResponseEntity updateProgrammingExercise(@RequestBod // Verify that the checkout directories have not been changed. This is required since the buildScript and result paths are determined during the creation of the exercise. programmingExerciseService.validateCheckoutDirectoriesUnchanged(programmingExerciseBeforeUpdate, updatedProgrammingExercise); + // Verify that the programming language supports the selected network access option + programmingExerciseService.validateDockerFlags(updatedProgrammingExercise); + // Verify that a theia image is provided when the online IDE is enabled if (updatedProgrammingExercise.isAllowOnlineIde() && updatedProgrammingExercise.getBuildConfig().getTheiaImage() == null) { throw new BadRequestAlertException("You need to provide a Theia image when the online IDE is enabled", ENTITY_NAME, "noTheiaImageProvided"); diff --git a/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml new file mode 100644 index 000000000000..193a6370c0ed --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20241114122713_changelog.xml b/src/main/resources/config/liquibase/changelog/20241114122713_changelog.xml new file mode 100644 index 000000000000..a723075a6cde --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241114122713_changelog.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index e8f58b18d024..4f682ca9b8e0 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -32,10 +32,12 @@ + + diff --git a/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts b/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts index 1b218cb534f5..760d8e6567fb 100644 --- a/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts +++ b/src/main/webapp/app/course/plagiarism-cases/instructor-view/plagiarism-cases-instructor-view.component.ts @@ -6,6 +6,7 @@ import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/Plagiarism import { Exercise, getIcon } from 'app/entities/exercise.model'; import { downloadFile } from 'app/shared/util/download.util'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-plagiarism-cases-instructor-view', @@ -24,6 +25,7 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { constructor( private plagiarismCasesService: PlagiarismCasesService, private route: ActivatedRoute, + private alertService: AlertService, ) {} ngOnInit(): void { @@ -37,23 +39,31 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { plagiarismCasesForInstructor$.subscribe({ next: (res: HttpResponse) => { this.plagiarismCases = res.body!; - this.groupedPlagiarismCases = this.plagiarismCases.reduce((acc: { [exerciseId: number]: PlagiarismCase[] }, plagiarismCase) => { - const caseExerciseId = plagiarismCase.exercise?.id; - if (caseExerciseId === undefined) { - return acc; - } - - // Group initialization - if (!acc[caseExerciseId]) { - acc[caseExerciseId] = []; - this.exercisesWithPlagiarismCases.push(plagiarismCase.exercise!); - } - - // Grouping - acc[caseExerciseId].push(plagiarismCase); + this.groupedPlagiarismCases = this.plagiarismCases.reduce( + ( + acc: { + [exerciseId: number]: PlagiarismCase[]; + }, + plagiarismCase, + ) => { + const caseExerciseId = plagiarismCase.exercise?.id; + if (caseExerciseId === undefined) { + return acc; + } + + // Group initialization + if (!acc[caseExerciseId]) { + acc[caseExerciseId] = []; + this.exercisesWithPlagiarismCases.push(plagiarismCase.exercise!); + } + + // Grouping + acc[caseExerciseId].push(plagiarismCase); - return acc; - }, {}); + return acc; + }, + {}, + ); }, }); } @@ -131,20 +141,48 @@ export class PlagiarismCasesInstructorViewComponent implements OnInit { } /** - * export the plagiarism cases in CSV format + * set placeholder for undefined values and sanitize the operators away + * @param value to be sanitized or replaced with - + * @private + */ + private sanitizeCSVField(value: any): string { + if (value === null || value === undefined) { + // used as placeholder for null or if the passed value does not exist + return '-'; + } + // sanitize the operators away in case they appear in the values + return String(value).replace(/;/g, '";"'); + } + + /** + * export the cases in CSV format */ exportPlagiarismCases(): void { - const blobParts: string[] = ['Student Login,Exercise,Verdict, Verdict Date\n']; - this.plagiarismCases.forEach((plagiarismCase) => { - const exerciseTitleCSVSanitized = plagiarismCase.exercise?.title?.replace(',', '","'); + const headers = ['Student Login', 'Matr. Nr.', 'Exercise', 'Verdict', 'Verdict Date', 'Verdict By']; + const blobParts: string[] = [headers.join(';') + '\n']; + this.plagiarismCases.reduce((acc, plagiarismCase) => { + const fields = [ + this.sanitizeCSVField(plagiarismCase.student?.login), + this.sanitizeCSVField(plagiarismCase.student?.visibleRegistrationNumber), + this.sanitizeCSVField(plagiarismCase.exercise?.title), + ]; if (plagiarismCase.verdict) { - blobParts.push( - `${plagiarismCase.student?.login},${exerciseTitleCSVSanitized},${plagiarismCase.verdict},${plagiarismCase.verdictDate},${plagiarismCase.verdictBy!.name}\n`, + fields.push( + this.sanitizeCSVField(plagiarismCase.verdict), + this.sanitizeCSVField(plagiarismCase.verdictDate), + this.sanitizeCSVField(plagiarismCase.verdictBy?.name), ); } else { - blobParts.push(`${plagiarismCase.student?.login},${exerciseTitleCSVSanitized}, No verdict yet, -, -\n`); + fields.push('No verdict yet', '-', '-'); } - }); - downloadFile(new Blob(blobParts, { type: 'text/csv' }), 'plagiarism-cases.csv'); + acc.push(fields.join(';') + '\n'); + return acc; + }, blobParts); + + try { + downloadFile(new Blob(blobParts, { type: 'text/csv' }), 'plagiarism-cases.csv'); + } catch (error) { + this.alertService.error('artemisApp.plagiarism.plagiarismCases.export.error'); + } } } diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts index 1b54bd14ff8a..70f4cfe76710 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts +++ b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/student-exam-timeline.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { StudentExam } from 'app/entities/student-exam.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -8,7 +8,7 @@ import { ExamNavigationBarComponent } from 'app/exam/participate/exam-navigation import { SubmissionService } from 'app/exercises/shared/submission/submission.service'; import dayjs from 'dayjs/esm'; import { SubmissionVersion } from 'app/entities/submission-version.model'; -import { Observable, Subscription, forkJoin, map, mergeMap, toArray } from 'rxjs'; +import { Observable, Subscription, forkJoin, map, mergeMap, tap, toArray } from 'rxjs'; import { ProgrammingSubmission } from 'app/entities/programming/programming-submission.model'; import { Submission } from 'app/entities/submission.model'; import { FileUploadSubmission } from 'app/entities/file-upload-submission.model'; @@ -57,6 +57,7 @@ export class StudentExamTimelineComponent implements OnInit, AfterViewInit, OnDe private submissionService: SubmissionService, private submissionVersionService: SubmissionVersionService, private programmingExerciseParticipationService: ProgrammingExerciseParticipationService, + private cdr: ChangeDetectorRef, ) {} ngOnInit(): void { @@ -203,7 +204,8 @@ export class StudentExamTimelineComponent implements OnInit, AfterViewInit, OnDe ); } }); - return forkJoin([...submissionObservables]); + + return forkJoin([...submissionObservables]).pipe(tap(() => this.cdr.detectChanges())); } /** diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html index 8460ca15d6fd..bb59ed44df0d 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html @@ -16,7 +16,57 @@ /> @if (!isAeolus()) { -
+ @if (isLanguageSupported) { +
+ +
+ @if (isNetworkDisabled) { + + } + } + +
+ + + + + + + + + + + + + + + + + + + + +
+