diff --git a/.github/workflows/analysis-of-endpoint-connections.yml b/.github/workflows/analysis-of-endpoint-connections.yml deleted file mode 100644 index f74dff1b7b95..000000000000 --- a/.github/workflows/analysis-of-endpoint-connections.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Analysis of Endpoint Connections - -on: - workflow_dispatch: - pull_request: - types: - - opened - - synchronize - paths: - - 'src/main/java/**' - - 'src/main/webapp/**' - -# Keep in sync with build.yml and test.yml and codeql-analysis.yml -env: - CI: true - node: 20 - java: 21 - -jobs: - Parse-rest-calls-and-endpoints: - timeout-minutes: 10 - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '${{ env.java }}' - distribution: 'temurin' - cache: 'gradle' - - - name: Set up node.js - uses: actions/setup-node@v4 - with: - node-version: '${{ env.node }}' - - - name: Parse client sided REST-API calls - run: | - npm install - tsc -p supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/tsconfig.analysisOfEndpointConnections.json - node supporting_scripts/analysis-of-endpoint-connections/src/main/typeScript/AnalysisOfEndpointConnectionsClient.js - - - name: Parse server sided Endpoints - run: ./gradlew :supporting_scripts:analysis-of-endpoint-connections:runEndpointParser - - - name: Upload parsing results - uses: actions/upload-artifact@v4 - with: - name: REST API Parsing Results - path: | - supporting_scripts/analysis-of-endpoint-connections/endpoints.json - supporting_scripts/analysis-of-endpoint-connections/restCalls.json - - Analysis-of-endpoint-connections: - needs: Parse-rest-calls-and-endpoints - timeout-minutes: 10 - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '${{ env.java }}' - cache: 'gradle' - - - name: Download JSON files - uses: actions/download-artifact@v4 - with: - name: REST API Parsing Results - path: supporting_scripts/analysis-of-endpoint-connections/ - - - name: Analyze endpoints - run: | - ./gradlew :supporting_scripts:analysis-of-endpoint-connections:runEndpointAnalysis - continue-on-error: true - id: endpointAnalysis - - - name: Analyze rest calls - run: | - ./gradlew :supporting_scripts:analysis-of-endpoint-connections:runRestCallAnalysis - continue-on-error: true - id: restCallAnalysis - - - name: Upload analysis results - uses: actions/upload-artifact@v4 - with: - name: Endpoint and REST Call Analysis Results - path: | - supporting_scripts/analysis-of-endpoint-connections/endpointAnalysisResult.json - supporting_scripts/analysis-of-endpoint-connections/restCallAnalysisResult.json - - - name: Check if any step failed - run: | - if [ "${{ steps.endpointAnalysis.outcome }}" != "success" ] && - [ "${{ steps.restCallAnalysis.outcome }}" != "success" ]; then - echo "Endpoints and REST calls could not be matched." - exit 1 - fi - if [ "${{ steps.endpointAnalysis.outcome }}" == "success" ] && - [ "${{ steps.restCallAnalysis.outcome }}" != "success" ]; then - echo "REST calls could not be matched." - exit 1 - fi - if [ "${{ steps.endpointAnalysis.outcome }}" != "success" ] && - [ "${{ steps.restCallAnalysis.outcome }}" == "success" ]; then - echo "Endpoints could not be matched." - exit 1 - fi diff --git a/.gitignore b/.gitignore index 8f71a8ae13d5..75cb003dda0c 100644 --- a/.gitignore +++ b/.gitignore @@ -219,4 +219,3 @@ data-exports/ # Supporting scripts config ############################## /supporting_scripts/**/*.ini -/supporting_scripts/analysis-of-endpoint-connections/build/**/* diff --git a/build.gradle b/build.gradle index e06f841bcaa4..a5dea9df1210 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ plugins { id "com.github.ben-manes.versions" version "0.51.0" id "com.github.andygoossens.modernizer" version "${modernizer_plugin_version}" id "com.gorylenko.gradle-git-properties" version "2.4.2" - id "org.owasp.dependencycheck" version "11.1.0" + id "org.owasp.dependencycheck" version "11.1.1" id "com.adarshr.test-logger" version "4.0.0" } @@ -96,9 +96,10 @@ spotless { @Override String apply(String s, File file) throws Exception { if (s =~ /\nimport .*\*;/) { - throw new AssertionError("Do not use wildcard imports. spotlessApply cannot resolve this issue.\n" + + throw new IllegalArgumentException("Do not use wildcard imports. spotlessApply cannot resolve this issue.\n" + "The following file violates this rule: " + file.getName()) } + return s // Ensure a value is returned after processing } })) } @@ -180,13 +181,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 } } } @@ -212,18 +213,11 @@ repositories { maven { url "https://build.shibboleth.net/maven/releases" } - // TODO: remove this when spring cloud is available in the official maven repository - maven { - url "https://repo.spring.io/milestone" - } } ext["jackson.version"] = fasterxml_version ext["junit-jupiter.version"] = junit_version -ext { qDoxVersionReusable = "com.thoughtworks.qdox:qdox:2.2.0" } -ext { springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:${spring_boot_version}" } - dependencies { // Note: jenkins-client is not well maintained and includes dependencies to libraries with critical security issues (e.g. CVE-2020-10683 for dom4j@1.6.1) @@ -297,7 +291,7 @@ dependencies { implementation "org.apache.sshd:sshd-sftp:${sshd_version}" // https://mvnrepository.com/artifact/net.sourceforge.plantuml/plantuml - implementation "net.sourceforge.plantuml:plantuml:1.2024.7" + implementation "net.sourceforge.plantuml:plantuml:1.2024.8" implementation "org.jasypt:jasypt:1.9.3" implementation "me.xdrop:fuzzywuzzy:1.4.0" implementation("org.yaml:snakeyaml") { @@ -307,7 +301,7 @@ dependencies { } } - implementation qDoxVersionReusable + implementation "com.thoughtworks.qdox:qdox:2.2.0" implementation "io.sentry:sentry-logback:${sentry_version}" implementation "io.sentry:sentry-spring-boot-starter-jakarta:${sentry_version}" @@ -328,10 +322,6 @@ dependencies { implementation "org.jsoup:jsoup:1.18.3" implementation "commons-codec:commons-codec:1.17.1" // needed for spring security saml2 - // TODO: decide if we want to use OpenAPI and Swagger v3 -// implementation 'io.swagger.core.v3:swagger-annotations:2.2.23' -// implementation "org.springdoc:springdoc-openapi-ui:1.8.0" - // use the latest version to avoid security vulnerabilities implementation "org.springframework:spring-webmvc:${spring_framework_version}" @@ -393,7 +383,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-aop:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-data-jpa:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-security:${spring_boot_version}" - implementation(springBootStarterWeb) { + implementation("org.springframework.boot:spring-boot-starter-web:${spring_boot_version}") { exclude module: "spring-boot-starter-undertow" } implementation "org.springframework.boot:spring-boot-starter-tomcat:${spring_boot_version}" 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/gradle.properties b/gradle.properties index c13a53d70950..59ef45b1f6d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ npm_version=10.8.0 jhipster_dependencies_version=8.7.2 spring_boot_version=3.4.0 spring_framework_version=6.2.0 -spring_cloud_version=4.2.0-RC1 +spring_cloud_version=4.2.0 spring_security_version=6.4.1 # TODO: upgrading to 6.6.0 currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code hibernate_version=6.4.10.Final @@ -19,7 +19,7 @@ jwt_version=0.12.6 jaxb_runtime_version=4.0.5 hazelcast_version=5.5.0 fasterxml_version=2.18.2 -jgit_version=7.0.0.202409031743-r +jgit_version=7.1.0.202411261347-r sshd_version=2.14.0 checkstyle_version=10.20.2 jplag_version=5.1.0 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/settings.gradle b/settings.gradle index c99752089fcd..4079e5a4cc6e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,9 +14,6 @@ pluginManagement { rootProject.name = 'Artemis' -// needed for rest call and endpoint analysis -include 'supporting_scripts:analysis-of-endpoint-connections' - // needed for programming exercise templates DirectoryScanner.removeDefaultExclude "**/.gitattributes" -DirectoryScanner.removeDefaultExclude "**/.gitignore" \ No newline at end of file +DirectoryScanner.removeDefaultExclude "**/.gitignore" 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/AnswerMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/AnswerMessageResource.java index 50f7d2e6f3b8..941a97623f70 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/AnswerMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/AnswerMessageResource.java @@ -51,7 +51,7 @@ public ResponseEntity createAnswerMessage(@PathVariable Long courseI long start = System.nanoTime(); AnswerPost createdAnswerMessage = answerMessageService.createAnswerMessage(courseId, answerMessage); // creation of answerMessage should not trigger alert - log.info("createAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("createAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses" + courseId + "/answer-messages/" + createdAnswerMessage.getId())).body(createdAnswerMessage); } @@ -70,7 +70,7 @@ public ResponseEntity updateAnswerMessage(@PathVariable Long courseI log.debug("PUT updateAnswerMessage invoked for course {} with message {}", courseId, answerMessage.getContent()); long start = System.nanoTime(); AnswerPost updatedAnswerMessage = answerMessageService.updateAnswerMessage(courseId, answerMessageId, answerMessage); - log.info("updateAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("updateAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedAnswerMessage, null, HttpStatus.OK); } @@ -88,7 +88,7 @@ public ResponseEntity deleteAnswerMessage(@PathVariable Long courseId, @Pa log.debug("PUT deleteAnswerMessage invoked for course {} on message {}", courseId, answerMessageId); long start = System.nanoTime(); answerMessageService.deleteAnswerMessageById(courseId, answerMessageId); - log.info("deleteAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("deleteAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); // deletion of answerMessages should not trigger alert return ResponseEntity.ok().build(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java index 75c68fbec7a1..5e0484884a9f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java @@ -94,7 +94,7 @@ public ResponseEntity createMessage(@PathVariable Long courseId, @Valid @R sendToUserPost.setConversation(sendToUserPost.getConversation().copy()); sendToUserPost.getConversation().setConversationParticipants(Collections.emptySet()); - log.info("createMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("createMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/messages/" + sendToUserPost.getId())).body(sendToUserPost); } @@ -141,7 +141,7 @@ else if (postContextFilter.courseWideChannelIds() != null) { } private void logDuration(List posts, Principal principal, long timeNanoStart) { - if (log.isInfoEnabled()) { + if (log.isDebugEnabled()) { long answerPosts = posts.stream().mapToLong(post -> post.getAnswers().size()).sum(); long reactions = posts.stream().mapToLong(post -> post.getReactions().size()).sum(); long answerReactions = posts.stream().flatMap(post -> post.getAnswers().stream()).mapToLong(answerPost -> answerPost.getReactions().size()).sum(); @@ -165,7 +165,7 @@ public ResponseEntity updateMessage(@PathVariable Long courseId, @PathVari log.debug("PUT updateMessage invoked for course {} with post {}", courseId, messagePost.getContent()); long start = System.nanoTime(); Post updatedMessagePost = conversationMessagingService.updateMessage(courseId, messageId, messagePost); - log.info("updateMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("updateMessage took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedMessagePost, null, HttpStatus.OK); } @@ -184,7 +184,7 @@ public ResponseEntity deleteMessage(@PathVariable Long courseId, @PathVari long start = System.nanoTime(); conversationMessagingService.deleteMessageById(courseId, messageId); // deletion of message posts should not trigger entity deletion alert - log.info("deleteMessage took {}", TimeLogUtil.formatDurationFrom(start)); + log.debug("deleteMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.ok().build(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/NotificationResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/NotificationResource.java index 911ea5cbfbff..964bdc25e644 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/NotificationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/NotificationResource.java @@ -75,7 +75,7 @@ public NotificationResource(NotificationRepository notificationRepository, UserR public ResponseEntity> getAllNotificationsForCurrentUserFilteredBySettings(Pageable pageable) { long start = System.nanoTime(); User currentUser = userRepository.getUserWithGroupsAndAuthorities(); - log.info("REST request to get notifications page {} with size {} for current user {} filtered by settings", pageable.getPageNumber(), pageable.getPageSize(), + log.debug("REST request to get notifications page {} with size {} for current user {} filtered by settings", pageable.getPageNumber(), pageable.getPageSize(), currentUser.getLogin()); var tutorialGroupIds = tutorialGroupService.findAllForNotifications(currentUser); var notificationSettings = notificationSettingRepository.findAllNotificationSettingsForRecipientWithId(currentUser.getId()); @@ -97,7 +97,7 @@ public ResponseEntity> getAllNotificationsForCurrentUserFilte deactivatedTitles, tutorialGroupIds, TITLES_TO_NOT_LOAD_NOTIFICATION, pageable); } HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); - log.info("Load notifications for user {} done in {}", currentUser.getLogin(), TimeLogUtil.formatDurationFrom(start)); + log.debug("Load notifications for user {} done in {}", currentUser.getLogin(), TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(page.getContent(), headers, HttpStatus.OK); } } 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/core/config/StompErrorLogFilter.java b/src/main/java/de/tum/cit/aet/artemis/core/config/StompErrorLogFilter.java index fab3273a0497..c71cbac05f6d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/StompErrorLogFilter.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/StompErrorLogFilter.java @@ -1,7 +1,10 @@ package de.tum.cit.aet.artemis.core.config; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.filter.Filter; +import org.slf4j.Marker; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; import ch.qos.logback.core.spi.FilterReply; /** @@ -17,29 +20,33 @@ * The purpose of this filter is to reduce noise in the logs by eliminating * repetitive or irrelevant error messages caused by client disconnections. */ -public class StompErrorLogFilter extends Filter { +public class StompErrorLogFilter extends TurboFilter { /** - * Decides whether a log message should be suppressed or passed through. + * Determines whether a log message should be allowed, denied, or processed normally. * *

- * This method checks if the log message originates from the - * StompBrokerRelayMessageHandler logger and contains the specific error message - * about the 60000ms connection TTL timeout. If both conditions are met, - * the log message is suppressed (denied). All other log messages are allowed. + * This method checks the logger name, log level, and message format to identify + * and suppress specific error messages. If the message matches the criteria + * (e.g., the logger is from {@code StompBrokerRelayMessageHandler} and the error + * contains details about a 60000ms connection TTL timeout), the log is denied. + * Otherwise, the log message is processed normally. * - * @param event the logging event containing the log message and metadata - * @return {@code FilterReply.DENY} if the message matches the specific error to suppress, - * otherwise {@code FilterReply.NEUTRAL} to allow the message through. + * @param marker The marker associated with the log message (can be null). + * @param logger The logger that created the log message. + * @param level The log level (e.g., ERROR, WARN, INFO). + * @param format The log message format string. + * @param params Parameters for the format string (if any). + * @param t Throwable associated with the log event (if any). + * @return {@link FilterReply#DENY} if the message matches the suppression criteria, + * otherwise {@link FilterReply#NEUTRAL} to process the message normally. */ @Override - public FilterReply decide(ILoggingEvent event) { - String loggerName = event.getLoggerName(); - String message = event.getFormattedMessage(); + public FilterReply decide(Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) { // Check if the logger and message match the specific error to suppress - if ("org.springframework.messaging.simp.stomp.StompBrokerRelayMessageHandler".equals(loggerName) && message.contains("Did not receive data from") - && message.contains("connection TTL. The connection will now be closed.")) { + if ("org.springframework.messaging.simp.stomp.StompBrokerRelayMessageHandler".equals(logger.getName()) && Level.ERROR.equals(level) && format != null + && format.contains("Did not receive data from") && format.contains("connection TTL. The connection will now be closed.")) { return FilterReply.DENY; // Suppress this specific log message } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/messaging/InstanceMessageReceiveService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/messaging/InstanceMessageReceiveService.java index cb1566339db9..ef85f1d650fe 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/messaging/InstanceMessageReceiveService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/messaging/InstanceMessageReceiveService.java @@ -337,7 +337,7 @@ public void processStudentExamIndividualWorkingTimeChangeDuringConduction(Long s } public void processScheduleParticipantScore(Long exerciseId, Long participantId, Long resultIdToBeDeleted) { - log.info("Received schedule participant score for exercise {} and participant {} (result to be deleted: {})", exerciseId, participantId, resultIdToBeDeleted); + log.debug("Received schedule participant score for exercise {} and participant {} (result to be deleted: {})", exerciseId, participantId, resultIdToBeDeleted); participantScoreScheduleService.scheduleTask(exerciseId, participantId, resultIdToBeDeleted); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index 543ad85964da..3dbea43bf367 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -166,7 +166,7 @@ public ResponseEntity getAccount() { // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); - log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); + log.debug("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } 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/GitService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/GitService.java index dab2a60def8c..438b9d06b3bd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/GitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/GitService.java @@ -392,7 +392,7 @@ public Repository getOrCheckoutRepository(VcsRepositoryUri sourceRepoUri, VcsRep // Clone repository. try { var gitUriAsString = getGitUriAsString(sourceRepoUri); - log.info("Cloning from {} to {}", gitUriAsString, localPath); + log.debug("Cloning from {} to {}", gitUriAsString, localPath); cloneInProgressOperations.put(localPath, localPath); // make sure the directory to copy into is empty FileUtils.deleteDirectory(localPath.toFile()); 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 @@ -203,7 +209,8 @@ else if (triggeredByPushTo.equals(RepositoryType.TESTS)) { programmingExercise.getId(), 0, priority, null, repositoryInfo, jobTimingInfo, buildConfig, null); queue.add(buildJobQueueItem); - log.info("Added build job {} to the queue", buildJobId); + log.info("Added build job {} for exercise {} and participation {} with priority {} to the queue", buildJobId, programmingExercise.getShortName(), participation.getId(), + priority); dockerImageCleanupInfo.put(buildConfig.dockerImage(), jobTimingInfo.submissionDate()); } @@ -310,6 +317,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 +328,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/service/localvc/GitPublickeyAuthenticatorService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java index c57390fef402..165154b565e5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java @@ -119,7 +119,7 @@ private boolean authenticateBuildAgent(PublicKey providedKey, ServerSession sess if (matchingAgent.isPresent()) { var agent = matchingAgent.get().buildAgent(); - log.info("Authenticating build agent {} on address {}", agent.displayName(), agent.memberAddress()); + log.debug("Authenticating build agent {} on address {}", agent.displayName(), agent.memberAddress()); session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, true); return true; } 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) { + + } + } + +
+ + + + + + + + + + + + + + + + + + + + +
+