diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46631245f3c4..9dec61fcb449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,118 +31,100 @@ on: types: - created -# Keep in sync with codeql-analysis.yml and test.yml and analysis-of-endpoint-connections.yml -env: - CI: true - node: 22 - java: 21 - RAW_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }} +# Keep this filename in sync with the filename environment variable (PR_AUTO_BUILD_FILE_NAME) in the testserver-deployment.yml workflow jobs: - build: - name: Build .war artifact + define-inputs: + name: Define Inputs runs-on: ubuntu-latest + outputs: + release_upload: ${{ steps.set-upload-release.outputs.release_upload }} + release_url: ${{ steps.set-upload-release.outputs.release_url }} + release_path: ${{ steps.set-upload-release.outputs.release_path }} + release_name: ${{ steps.set-upload-release.outputs.release_name }} + release_type: ${{ steps.set-upload-release.outputs.release_type }} + docker_build: ${{ steps.set-docker-build.outputs.docker_build }} + docker_ref: ${{ steps.set-docker-ref.outputs.docker_ref }} + docker_build_tag: ${{ steps.set-docker-tag.outputs.docker_build_tag }} steps: - - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '${{ env.node }}' - cache: 'npm' - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '${{ env.java }}' - cache: 'gradle' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - name: Production Build - run: ./gradlew -Pprod -Pwar clean bootWar - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: Artemis.war - path: build/libs/Artemis-*.war - - name: Upload Release Artifact - if: github.event_name == 'release' && github.event.action == 'created' - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: build/libs/Artemis-${{ github.event.release.tag_name }}.war - asset_name: Artemis.war - asset_content_type: application/x-webarchive + - name: Set Upload Release Artifact Outputs + id: set-upload-release + run: | + # If event is release created, set the release_upload flag and the release artifact details + if [[ "${{ github.event_name }}" == "release" && "${{ github.event.action }}" == "created" ]]; then + echo "release_upload=true" >> $GITHUB_OUTPUT + echo "release_url=${{ github.event.release.upload_url }}" >> $GITHUB_OUTPUT + echo "release_path=build/libs/Artemis-${{ github.event.release.tag_name }}.war" >> $GITHUB_OUTPUT + echo "release_name=Artemis.war" >> $GITHUB_OUTPUT + echo "release_type=application/x-webarchive" >> $GITHUB_OUTPUT + else + echo "release_upload=false" >> $GITHUB_OUTPUT + fi - docker: - name: Build and Push Docker Image - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'ls1intum/Artemis' }} - runs-on: ubuntu-latest - steps: - - name: Compute Tag - uses: actions/github-script@v7 - id: compute-tag - with: - result-encoding: string - script: | - if (context.eventName === "pull_request") { - return "pr-" + context.issue.number; - } - if (context.eventName === "release") { - return "latest"; - } - if (context.eventName === "push") { - if (context.ref.startsWith("refs/tags/")) { - return context.ref.slice(10); + - name: Set Docker Build Flag + id: set-docker-build + run: | + if [[ ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'ls1intum/Artemis' }} ]]; then + echo "docker_build=true" >> $GITHUB_OUTPUT + else + echo "docker_build=false" >> $GITHUB_OUTPUT + fi + + - name: Set Docker ref + if: ${{ steps.set-docker-build.outputs.docker_build == 'true' }} + id: set-docker-ref + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # Checkout pull request HEAD commit instead of merge commit + # this is done to include the correct branch and git information inside the build + echo "docker_ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "push" ]]; then + echo "docker_ref=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Compute Docker Tag + if: ${{ steps.set-docker-build.outputs.docker_build == 'true' }} + uses: actions/github-script@v7 + id: compute-tag + with: + result-encoding: string + script: | + if (context.eventName === "pull_request") { + return "pr-" + context.issue.number; + } + if (context.eventName === "release") { + return "latest"; } - if (context.ref === "refs/heads/develop") { - return "develop"; + if (context.eventName === "push") { + if (context.ref.startsWith("refs/tags/")) { + return context.ref.slice(10); + } + if (context.ref === "refs/heads/develop") { + return "develop"; + } } - } - return "FALSE"; - - name: Git Checkout for PRs - if: ${{ github.event_name == 'pull_request' }} - # Checkout pull request HEAD commit instead of merge commit - # this is done to include the correct branch and git information inside the build - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - - name: Git Checkout for push actions - if: ${{ github.event_name == 'push' }} - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - - name: Git Checkout for push actions - if: ${{ github.event_name == 'release' }} - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - # Build and Push to GitHub Container Registry - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - if: ${{ steps.compute-tag.outputs.result != 'FALSE' }} - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and Push to GitHub Container Registry - uses: docker/build-push-action@v5 - if: ${{ steps.compute-tag.outputs.result != 'FALSE' }} - with: - # beware that the linux/arm64 build from the registry is using an amd64 compiled .war file as - # the GitHub runners don't support arm64 and QEMU takes too long for emulating the build - platforms: linux/amd64,linux/arm64 - file: ./docker/artemis/Dockerfile - context: . - tags: ghcr.io/ls1intum/artemis:${{ steps.compute-tag.outputs.result }} - push: true - cache-from: type=gha - cache-to: type=gha,mode=min + return "FALSE"; + + - name: Set Docker Tag + id: set-docker-tag + run: | + if [[ ${{ steps.compute-tag.outputs.result != 'FALSE' }} ]]; then + echo "docker_build_tag=${{ steps.compute-tag.outputs.result }}" >> $GITHUB_OUTPUT + fi - # TODO: Push to Docker Hub (develop + tag) - # TODO: Push to Chair Harbour (??) + call-build-workflow: + name: Call Build Workflow + needs: define-inputs + uses: ./.github/workflows/reusable-build.yml + with: + build_war: true + release_upload: ${{ needs.define-inputs.outputs.release_upload == 'true' }} + release_url: ${{ needs.define-inputs.outputs.release_url }} + release_path: ${{ needs.define-inputs.outputs.release_path }} + release_name: ${{ needs.define-inputs.outputs.release_name }} + release_type: ${{ needs.define-inputs.outputs.release_type }} + docker: ${{ needs.define-inputs.outputs.docker_build == 'true' }} + docker_ref: ${{ needs.define-inputs.outputs.docker_ref }} + docker_build_tag: ${{ needs.define-inputs.outputs.docker_build_tag }} diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml new file mode 100644 index 000000000000..6960bf6c5b45 --- /dev/null +++ b/.github/workflows/reusable-build.yml @@ -0,0 +1,193 @@ +name: Build + +on: + workflow_call: + inputs: + # Build job inputs + build_war: + description: "Whether to build and upload the .war artifact." + required: false + default: false + type: boolean + build_ref: + description: "Branch name, tag, or commit SHA to use for the build job. If not provided, it falls back to the default behavior of actions/checkout." + required: false + default: '' + type: string + + # Upload Release Artifact job inputs + release_upload: + description: "Whether to upload the release artifact." + required: false + default: false + type: boolean + release_url: + description: "URL to upload the release artifact to." + required: false + default: '' + type: string + release_path: + description: "Path to the release artifact." + required: false + default: '' + type: string + release_name: + description: "Name of the release artifact." + required: false + default: '' + type: string + release_type: + description: "Content type of the release artifact." + required: false + default: '' + type: string + + # Docker job inputs + docker: + description: "Whether to build and push a Docker image." + required: false + default: false + type: boolean + docker_ref: + description: "Branch name, tag, or commit SHA to use for the Docker job. If not provided, it falls back to the default behavior of actions/checkout." + required: false + default: '' + type: string + docker_build_tag: + description: "Tag to use when building Docker image." + required: false + default: '' + type: string + +# Keep in sync with codeql-analysis.yml and test.yml and analysis-of-endpoint-connections.yml +env: + CI: true + node: 22 + java: 21 + +jobs: + validate-inputs: + name: Validate Inputs + runs-on: ubuntu-latest + steps: + - name: Validate Inputs + run: | + # Check release related inputs + if [[ "${{ github.event.inputs.release_upload }}" ]]; then + # List of required release inputs + missing_inputs=() + + # Check each required input + [[ -z "${{ inputs.release_url }}" || "${{ inputs.release_url }}" == '' ]] && missing_inputs+=("release_url") + [[ -z "${{ inputs.release_path }}" || "${{ inputs.release_path }}" == '' ]] && missing_inputs+=("release_path") + [[ -z "${{ inputs.release_name }}" || "${{ inputs.release_name }}" == '' ]] && missing_inputs+=("release_name") + [[ -z "${{ inputs.release_type }}" || "${{ inputs.release_type }}" == '' ]] && missing_inputs+=("release_type") + + if [[ "${#missing_inputs[@]}" -gt 0 ]]; then + echo "::error::Release upload is set to true, but the following inputs are missing: ${missing_inputs[*]}" + exit 1 + fi + fi + + # Check Docker related inputs + if [[ "${{ github.event.inputs.docker }}" ]]; then + # Check whether all Docker inputs are set + if [[ "${{ github.event.inputs.docker_build_tag }}" == '' ]]; then + echo "::error::Docker build is set to true, but Docker build tag is not set." + exit 1 + fi + fi + + + build: + name: Build .war artifact + if: ${{ inputs.build_war }} + needs: validate-inputs + runs-on: ubuntu-latest + steps: + # Git Checkout + - name: Git Checkout to the specific ref (if build_ref is set) + uses: actions/checkout@v4 + if: ${{ inputs.build_ref != '' }} + with: + ref: ${{ inputs.build_ref }} + - name: Git Checkout (default) + uses: actions/checkout@v4 + if: ${{ inputs.build_ref == '' }} + # Setup Node.js, Java and Gradle + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '${{ env.node }}' + cache: 'npm' + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '${{ env.java }}' + cache: 'gradle' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + # Build + - name: Production Build + run: ./gradlew -Pprod -Pwar clean bootWar + # Upload Artifact + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: Artemis.war + path: build/libs/Artemis-*.war + # Upload Artifact (Release) + - name: Upload Release Artifact + if: ${{ inputs.release_upload }} + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ inputs.release_url }} + asset_path: ${{ inputs.release_path }} + asset_name: ${{ inputs.release_name }} + asset_content_type: ${{ inputs.release_type }} + + docker: + name: Build and Push Docker Image + if: ${{ inputs.docker }} + needs: validate-inputs + runs-on: ubuntu-latest + steps: + # Git Checkout + - name: Git Checkout to the specific ref (if docker_ref is set) + uses: actions/checkout@v4 + if: ${{ inputs.docker_ref != '' }} + with: + ref: ${{ inputs.docker_ref }} + - name: Git Checkout (default) + uses: actions/checkout@v4 + if: ${{ inputs.docker_ref == '' }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # Build and Push to GitHub Container Registry + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and Push to GitHub Container Registry + uses: docker/build-push-action@v5 + with: + # beware that the linux/arm64 build from the registry is using an amd64 compiled .war file as + # the GitHub runners don't support arm64 and QEMU takes too long for emulating the build + platforms: linux/amd64,linux/arm64 + file: ./docker/artemis/Dockerfile + context: . + tags: ghcr.io/ls1intum/artemis:${{ inputs.docker_build_tag }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=min + + # TODO: Push to Docker Hub (develop + tag) + + # TODO: Push to Chair Harbour (??) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba23a4d736bc..4442571b230c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -119,7 +119,7 @@ jobs: name: Coverage Report Server Tests path: build/reports/jacoco/test/html - name: Append Per-Module Coverage to Job Summary - if: success() || failure() + if: success() run: | AGGREGATED_REPORT_FILE=./module_coverage_report.md python3 ./supporting_scripts/code-coverage/per_module_cov_report/parse_module_coverage.py build/reports/jacoco $AGGREGATED_REPORT_FILE diff --git a/.github/workflows/testserver-deployment.yml b/.github/workflows/testserver-deployment.yml new file mode 100644 index 000000000000..5a36abd9f66f --- /dev/null +++ b/.github/workflows/testserver-deployment.yml @@ -0,0 +1,165 @@ +name: Deploy to a test-server + +on: + workflow_dispatch: + inputs: + branch_name: + description: "Which branch to deploy" + required: true + type: string + environment_name: + description: "Which environment to deploy (e.g. artemis-test7.artemis.cit.tum.de, etc.)." + required: true + type: string + triggered_by: + description: "Username that triggered deployment (not required, shown if triggered via GitHub UI, logged if triggered via GitHub app)" + required: false + type: string + + +concurrency: ${{ github.event.inputs.environment_name }} + +env: + CI: true + # Keep filename in sync with the workflow responsible for automatic builds on PRs + PR_AUTO_BUILD_FILE_NAME: "build.yml" + RAW_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ github.event.inputs.branch_name }} + +jobs: + # Log the inputs for debugging + log-inputs: + name: Log Inputs + runs-on: ubuntu-latest + steps: + - name: Print Inputs + run: | + echo "Branch: ${{ github.event.inputs.branch_name }}" + echo "Environment: ${{ github.event.inputs.environment_name }}" + echo "Triggered by: ${{ github.event.inputs.triggered_by }}" + echo "RAW_URL: ${{ env.RAW_URL }}" + + determine-build-context: + name: Determine Build Context + runs-on: ubuntu-latest + needs: log-inputs + outputs: + pr_number: ${{ steps.get_pr.outputs.pr_number }} + pr_head_sha: ${{ steps.get_pr.outputs.pr_head_sha }} + tag: ${{ steps.get_pr.outputs.tag }} + steps: + - name: Check if a PR exists for the branch + id: get_pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME=${{ github.event.inputs.branch_name }} + echo "Checking if PR exists for branch: $BRANCH_NAME targeting 'develop'." + + PR_DETAILS=$(gh api repos/${{ github.repository }}/pulls \ + --paginate \ + --jq ".[] | select(.head.ref == \"$BRANCH_NAME\" and .base.ref == \"develop\") | {number: .number, sha: .head.sha}") + + PR_NUMBER=$(echo "$PR_DETAILS" | jq -r ".number") + PR_HEAD_SHA=$(echo "$PR_DETAILS" | jq -r ".sha") + + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "Found PR: $PR_NUMBER from branch: $BRANCH_NAME targeting 'develop' with Head: $PR_HEAD_SHA." + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "pr_head_sha=$PR_HEAD_SHA" >> $GITHUB_OUTPUT + echo "tag=pr-$PR_NUMBER" >> $GITHUB_OUTPUT + else + echo "No PR found for branch: $BRANCH_NAME targeting 'develop'." + echo "pr_number=" >> $GITHUB_OUTPUT + echo "pr_head_sha=" >> $GITHUB_OUTPUT + + # Fetch the latest commit SHA of the branch + LATEST_SHA=$(gh api repos/${{ github.repository }}/git/refs/heads/$BRANCH_NAME --jq '.object.sha') + + if [ -z "$LATEST_SHA" ]; then + echo "::error::Could not find the latest commit SHA for branch $BRANCH_NAME." + exit 1 + fi + + echo "Latest SHA for branch $BRANCH_NAME is $LATEST_SHA." + # Set tag as branch-SHA + echo "tag=branch-$LATEST_SHA" >> $GITHUB_OUTPUT + fi + + + # Build the Docker image (branch without PR) + conditional-build: + if: ${{ needs.determine-build-context.outputs.pr_number == '' }} + needs: determine-build-context + uses: ./.github/workflows/reusable-build.yml + with: + docker: true + docker_ref: ${{ github.event.inputs.branch_name }} + docker_build_tag: ${{ needs.determine-build-context.outputs.tag }} + + # Check if the build has run successfully (PR) + check-existing-build: + name: Check Existing Build + if: ${{ needs.determine-build-context.outputs.pr_number != '' }} + needs: determine-build-context + runs-on: ubuntu-latest + steps: + - name: Get latest successful build for branch + id: check_build + uses: octokit/request-action@v2.x + with: + route: GET /repos/${{ github.repository }}/actions/workflows/build.yml/runs?event=pull_request&status=success&head_sha=${{ needs.determine-build-context.outputs.pr_head_sha }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Fail if no successful build found + if: ${{ steps.check_build.conclusion == 'success' && fromJSON(steps.check_build.outputs.data).total_count == 0 }} + run: | + echo "::error::No successful build found for branch '${{ github.event.inputs.branch_name }}' with SHA '${{ needs.determine-build-context.outputs.pr_head_sha }}'." + exit 1 + + # Deploy to the test-server + deploy: + needs: [ determine-build-context, conditional-build, check-existing-build ] + # Run if either the conditional-build or check-existing-build job was successful + # Use always() since one of the jobs will always skip + if: always() && (needs.conditional-build.result == 'success' || needs.check-existing-build.result == 'success') + name: Deploy to Test-Server + runs-on: ubuntu-latest + environment: + name: ${{ github.event.inputs.environment_name }} + url: ${{ vars.DEPLOYMENT_URL }} + + env: + GATEWAY_USER: "jump" + GATEWAY_HOST: "gateway.artemis.in.tum.de:2010" + GATEWAY_HOST_PUBLIC_KEY: "[gateway.artemis.in.tum.de]:2010 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtTLiKRILjKZ+Qg4ReWKsG7mLDXkzHfeY5nalSQUNQ4" + + steps: + # Download artemis-server-cli from GH without cloning the Repo + - name: Fetch Artemis CLI + run: | + wget ${{ env.RAW_URL }}/artemis-server-cli + chmod +x artemis-server-cli + + # Configure SSH Key + - name: Setup SSH Keys and known_hosts + env: + SSH_AUTH_SOCK: /tmp/ssh_agent.sock + GATEWAY_SSH_KEY: "${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}" + DEPLOYMENT_SSH_KEY: "${{ secrets.DEPLOYMENT_SSH_KEY }}" + run: | + mkdir -p ~/.ssh + ssh-agent -a $SSH_AUTH_SOCK > /dev/null + ssh-add - <<< $GATEWAY_SSH_KEY + ssh-add - <<< $DEPLOYMENT_SSH_KEY + cat - <<< $GATEWAY_HOST_PUBLIC_KEY >> ~/.ssh/known_hosts + + - name: Deploy Artemis with Docker + env: + SSH_AUTH_SOCK: /tmp/ssh_agent.sock + DEPLOYMENT_USER: ${{ vars.DEPLOYMENT_USER }} + DEPLOYMENT_HOSTS: ${{ vars.DEPLOYMENT_HOSTS }} + TAG: ${{ needs.determine-build-context.outputs.tag }} + BRANCH_NAME: ${{ github.event.inputs.branch_name }} + DEPLOYMENT_FOLDER: ${{ vars.DEPLOYMENT_FOLDER }} + run: | + ./artemis-server-cli docker-deploy "$DEPLOYMENT_USER@$DEPLOYMENT_HOSTS" -g "$GATEWAY_USER@$GATEWAY_HOST" -t $TAG -b $BRANCH_NAME -d $DEPLOYMENT_FOLDER -y diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index f343cd9d29bb..8ba8ed672b07 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -49,6 +49,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | Bash | yes | yes | +----------------------+----------+---------+ + | MATLAB | yes | no | + +----------------------+----------+---------+ | Ruby | yes | yes | +----------------------+----------+---------+ @@ -97,6 +99,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ | Bash | no | no | no | no | n/a | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ + | MATLAB | no | no | no | no | n/a | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ | Ruby | no | no | no | no | n/a | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index ceb43a8d5ec6..a76db0977c6e 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -83,10 +83,10 @@ ext { ? ModuleCoverageThresholds.collect {element -> element.key} : includedModules as ArrayList - // we want to ignore some generated files in the domain folders ignoredDirectories = [ "**/$BasePath/**/domain/**/*_*", "**/$BasePath/core/config/migration/entries/**", + "**/org/gradle/**", "**/gradle-wrapper.jar/**" ] } diff --git a/src/main/java/de/tum/cit/aet/artemis/ArtemisApp.java b/src/main/java/de/tum/cit/aet/artemis/ArtemisApp.java index 11b1e7c67636..b56b0f82990d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/ArtemisApp.java +++ b/src/main/java/de/tum/cit/aet/artemis/ArtemisApp.java @@ -18,13 +18,14 @@ import org.springframework.boot.info.GitProperties; import org.springframework.core.env.Environment; +import de.tum.cit.aet.artemis.core.config.LicenseConfiguration; import de.tum.cit.aet.artemis.core.config.ProgrammingLanguageConfiguration; import de.tum.cit.aet.artemis.core.config.TheiaConfiguration; import tech.jhipster.config.DefaultProfileUtil; import tech.jhipster.config.JHipsterConstants; @SpringBootApplication -@EnableConfigurationProperties({ LiquibaseProperties.class, ProgrammingLanguageConfiguration.class, TheiaConfiguration.class }) +@EnableConfigurationProperties({ LiquibaseProperties.class, ProgrammingLanguageConfiguration.class, TheiaConfiguration.class, LicenseConfiguration.class }) public class ArtemisApp { private static final Logger log = LoggerFactory.getLogger(ArtemisApp.class); 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 891de1f8af4c..89c137972c66 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 @@ -108,7 +108,7 @@ public CreateContainerResponse configureContainer(String containerName, String i // container from exiting until it finishes. // It waits until the script that is running the tests (see below execCreateCmdResponse) is completed, and until the result files are extracted which is indicated // by the creation of a file "stop_container.txt" in the container's root directory. - .withCmd("sh", "-c", "while [ ! -f " + LOCALCI_WORKING_DIRECTORY + "/stop_container.txt ]; do sleep 0.5; done") + .withEntrypoint().withCmd("sh", "-c", "while [ ! -f " + LOCALCI_WORKING_DIRECTORY + "/stop_container.txt ]; do sleep 0.5; done") // .withCmd("tail", "-f", "/dev/null") // Activate for debugging purposes instead of the above command to get a running container that you can peek into using // "docker exec -it /bin/bash". .exec(); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/LicenseConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/LicenseConfiguration.java new file mode 100644 index 000000000000..6ebe6709d2d1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/LicenseConfiguration.java @@ -0,0 +1,30 @@ +package de.tum.cit.aet.artemis.core.config; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import jakarta.annotation.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; + +@Profile(PROFILE_CORE) +@ConfigurationProperties(prefix = "artemis.licenses") +public class LicenseConfiguration { + + private final MatLabLicense matlab; + + public record MatLabLicense(String licenseServer) { + } + + public LicenseConfiguration(MatLabLicense matlab) { + this.matlab = matlab; + } + + @Nullable + public String getMatlabLicenseServer() { + if (matlab == null) { + return null; + } + return matlab.licenseServer(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java new file mode 100644 index 000000000000..36432e696de9 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java @@ -0,0 +1,97 @@ +package de.tum.cit.aet.artemis.iris.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.RewritingVariant; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; +import de.tum.cit.aet.artemis.iris.service.websocket.IrisWebsocketService; + +/** + * Service to handle the rewriting subsystem of Iris. + */ +@Service +@Profile(PROFILE_IRIS) +public class IrisRewritingService { + + private final PyrisPipelineService pyrisPipelineService; + + private final LLMTokenUsageService llmTokenUsageService; + + private final CourseRepository courseRepository; + + private final IrisWebsocketService websocketService; + + private final PyrisJobService pyrisJobService; + + private final UserRepository userRepository; + + public IrisRewritingService(PyrisPipelineService pyrisPipelineService, LLMTokenUsageService llmTokenUsageService, CourseRepository courseRepository, + IrisWebsocketService websocketService, PyrisJobService pyrisJobService, UserRepository userRepository) { + this.pyrisPipelineService = pyrisPipelineService; + this.llmTokenUsageService = llmTokenUsageService; + this.courseRepository = courseRepository; + this.websocketService = websocketService; + this.pyrisJobService = pyrisJobService; + this.userRepository = userRepository; + } + + /** + * Executes the rewriting pipeline on Pyris + * + * @param user the user for which the pipeline should be executed + * @param course the course for which the pipeline should be executed + * @param variant the rewriting variant to be used + * @param toBeRewritten the text to be rewritten + */ + public void executeRewritingPipeline(User user, Course course, RewritingVariant variant, String toBeRewritten) { + // @formatter:off + pyrisPipelineService.executePipeline( + "rewriting", + variant.toString(), + Optional.empty(), + pyrisJobService.createTokenForJob(token -> new RewritingJob(token, course.getId(), user.getId())), + executionDto -> new PyrisRewritingPipelineExecutionDTO(executionDto, toBeRewritten), + stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisRewritingStatusUpdateDTO(stages, null, null)) + ); + // @formatter:on + } + + /** + * Takes a status update from Pyris containing a new rewriting result and sends it to the client via websocket + * + * @param job Job related to the status update + * @param statusUpdate the status update containing text recommendations + * @return the same job that was passed in + */ + public RewritingJob handleStatusUpdate(RewritingJob job, PyrisRewritingStatusUpdateDTO statusUpdate) { + Course course = courseRepository.findByIdForUpdateElseThrow(job.courseId()); + if (statusUpdate.tokens() != null && !statusUpdate.tokens().isEmpty()) { + llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> builder.withCourse(course.getId()).withUser(job.userId())); + } + + var user = userRepository.findById(job.userId()).orElseThrow(); + websocketService.send(user.getLogin(), websocketTopic(job.courseId()), statusUpdate); + + return job; + } + + private static String websocketTopic(long courseId) { + return "rewriting/" + courseId; + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java index ca42f85363bc..229e9cf1da5d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisDTOService.java @@ -59,6 +59,10 @@ public PyrisDTOService(GitService gitService, RepositoryService repositoryServic public PyrisProgrammingExerciseDTO toPyrisProgrammingExerciseDTO(ProgrammingExercise exercise) { var templateRepositoryContents = getFilteredRepositoryContents(exercise.getTemplateParticipation()); var solutionRepositoryContents = getFilteredRepositoryContents(exercise.getSolutionParticipation()); + + // var templateRepositoryContents = new HashMap(); + // var solutionRepositoryContents = new HashMap(); + Optional testRepo = Optional.empty(); try { testRepo = Optional.ofNullable(gitService.getOrCheckoutRepository(exercise.getVcsTestRepositoryUri(), true)); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java index ad1a63e60c58..258be6263b7e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java @@ -10,10 +10,12 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.iris.service.IrisCompetencyGenerationService; +import de.tum.cit.aet.artemis.iris.service.IrisRewritingService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageState; import de.tum.cit.aet.artemis.iris.service.pyris.job.CompetencyExtractionJob; @@ -21,6 +23,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TrackedSessionBasedPyrisJob; import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; @@ -41,16 +44,19 @@ public class PyrisStatusUpdateService { private final IrisCompetencyGenerationService competencyGenerationService; + private final IrisRewritingService rewritingService; + private static final Logger log = LoggerFactory.getLogger(PyrisStatusUpdateService.class); public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseChatSessionService irisExerciseChatSessionService, IrisTextExerciseChatSessionService irisTextExerciseChatSessionService, IrisCourseChatSessionService courseChatSessionService, - IrisCompetencyGenerationService competencyGenerationService) { + IrisCompetencyGenerationService competencyGenerationService, IrisRewritingService rewritingService) { this.pyrisJobService = pyrisJobService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; this.courseChatSessionService = courseChatSessionService; this.competencyGenerationService = competencyGenerationService; + this.rewritingService = rewritingService; } /** @@ -105,6 +111,18 @@ public void handleStatusUpdate(CompetencyExtractionJob job, PyrisCompetencyStatu removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } + /** + * Handles the status update of a rewriting job and forwards it to + * {@link IrisRewritingService#handleStatusUpdate(RewritingJob, PyrisRewritingStatusUpdateDTO)} + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(RewritingJob job, PyrisRewritingStatusUpdateDTO statusUpdate) { + var updatedJob = rewritingService.handleStatusUpdate(job, statusUpdate); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); + } + /** * Removes the job from the job service if the status update indicates that the job is terminated; updates it to distribute changes otherwise. * A job is terminated if all stages are in a terminal state. diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java index 22fcaa3c1f9a..f0b401145bfd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyExtractionPipelineExecutionDTO.java @@ -6,7 +6,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; /** - * DTO to execute the Iris competency extraction pipeline on Pyris + * DTO to execute the Iris rewriting pipeline on Pyris * * @param execution The pipeline execution details * @param courseDescription The description of the course diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java new file mode 100644 index 000000000000..1e7cd96bcaa5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.data; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.RewritingVariant; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewriteTextRequestDTO(String toBeRewritten, RewritingVariant variant) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java new file mode 100644 index 000000000000..6aae602ab5fe --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java @@ -0,0 +1,15 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; + +/** + * DTO to execute the Iris rewriting pipeline on Pyris + * + * @param execution The pipeline execution details + * @param toBeRewritten The text to be rewritten + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewritingPipelineExecutionDTO(PyrisPipelineExecutionDTO execution, String toBeRewritten) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java new file mode 100644 index 000000000000..084b65417cd3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.LLMRequest; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +/** + * DTO for the Iris rewriting feature. + * Pyris sends callback updates back to Artemis during rewriting of the text. These updates contain the current status of the rewriting process, + * which are then forwarded to the user via Websockets. + * + * @param stages List of stages of the generation process + * @param result The result of the rewriting process so far + * @param tokens List of token usages send by Pyris for tracking the token usage and cost + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisRewritingStatusUpdateDTO(List stages, String result, List tokens) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java new file mode 100644 index 000000000000..aeaaf20db46a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java @@ -0,0 +1,5 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting; + +public enum RewritingVariant { + FAQ, PROBLEM_STATEMENT +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java new file mode 100644 index 000000000000..37016865cb8c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.Course; + +/** + * A Pyris job to rewrite a text. + * + * @param jobId the job id + * @param courseId the course in which the rewriting is being done + * @param userId the user who started the job + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record RewritingJob(String jobId, long courseId, long userId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return course.getId().equals(courseId); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java new file mode 100644 index 000000000000..b3a437d964c6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java @@ -0,0 +1,66 @@ +package de.tum.cit.aet.artemis.iris.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastTutorInCourse; +import de.tum.cit.aet.artemis.iris.service.IrisRewritingService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisRewriteTextRequestDTO; + +/** + * REST controller for managing Markdown Rewritings. + */ +@Profile(PROFILE_IRIS) +@RestController +@RequestMapping("api/") +public class IrisRewritingResource { + + private static final Logger log = LoggerFactory.getLogger(IrisRewritingResource.class); + + private static final String ENTITY_NAME = "rewriting"; + + private final UserRepository userRepository; + + private final CourseRepository courseRepository; + + private final Optional irisRewritingService; + + public IrisRewritingResource(UserRepository userRepository, CourseRepository courseRepository, Optional irisRewritingService) { + this.userRepository = userRepository; + this.courseRepository = courseRepository; + this.irisRewritingService = irisRewritingService; + + } + + /** + * POST /courses/{courseId}/rewrite-text : Rewrite a given text. + * + * @param request the request containing the text to be rewritten and the corresponding variant + * @param courseId the id of the course + * @return the ResponseEntity with status 200 (OK) + */ + @EnforceAtLeastTutorInCourse + @PostMapping("courses/{courseId}/rewrite-text") + public ResponseEntity rewriteText(@RequestBody PyrisRewriteTextRequestDTO request, @PathVariable Long courseId) { + var rewritingService = irisRewritingService.orElseThrow(); + var user = userRepository.getUserWithGroupsAndAuthorities(); + var course = courseRepository.findByIdElseThrow(courseId); + rewritingService.executeRewritingPipeline(user, course, request.variant(), request.toBeRewritten()); + log.debug("REST request to rewrite text: {}", request.toBeRewritten()); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java index 73e4520c32a6..3dabd0970310 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java @@ -23,11 +23,13 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.job.CompetencyExtractionJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.RewritingJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; /** @@ -149,6 +151,31 @@ public ResponseEntity respondInTextExerciseChat(@PathVariable String runId return ResponseEntity.ok().build(); } + /** + * POST public/pyris/pipelines/rewriting/runs/:runId/status : Send the rewritten text in a status update + *

+ * Uses custom token based authentication. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @throws ConflictException if the run ID in the URL does not match the run ID in the request body + * @throws AccessForbiddenException if the token is invalid + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + */ + @PostMapping("pipelines/rewriting/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setRewritingJobStatus(@PathVariable String runId, @RequestBody PyrisRewritingStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, RewritingJob.class); + if (!Objects.equals(job.jobId(), runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); + } + + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); + + return ResponseEntity.ok().build(); + } + /** * {@code POST /api/public/pyris/webhooks/ingestion/runs/{runId}/status} : Set the status of an Ingestion job. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java index f81c09b6efe4..5f4a1451720a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java @@ -48,6 +48,7 @@ public enum ProgrammingLanguage { JAVA, JAVASCRIPT, KOTLIN, + MATLAB, OCAML, PYTHON, R, diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/LicenseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/LicenseService.java new file mode 100644 index 000000000000..4999dccc4d30 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/LicenseService.java @@ -0,0 +1,60 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Map; +import java.util.Objects; + +import jakarta.annotation.Nullable; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.config.LicenseConfiguration; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.domain.ProjectType; + +/** + * Provides licensing information for proprietary software to build jobs via environment variables. + */ +@Profile(PROFILE_CORE) +@Service +public class LicenseService { + + private final LicenseConfiguration licenseConfiguration; + + public LicenseService(LicenseConfiguration licenseConfiguration) { + this.licenseConfiguration = licenseConfiguration; + } + + /** + * Checks whether a required license is configured for the specified exercise type. + * If no license is required this returns true. + * + * @param programmingLanguage the programming language of the exercise type + * @param projectType the project type of the exercise type + * @return whether a required license is configured + */ + public boolean isLicensed(ProgrammingLanguage programmingLanguage, @Nullable ProjectType projectType) { + if (programmingLanguage == ProgrammingLanguage.MATLAB && projectType == null) { + return licenseConfiguration.getMatlabLicenseServer() != null; + } + + return true; + } + + /** + * Returns environment variables required to run programming exercise tests. + * + * @param programmingLanguage the programming language of the exercise + * @param projectType the project type of the exercise + * @return environment variables for the specified exercise type + */ + public Map getEnvironment(ProgrammingLanguage programmingLanguage, @Nullable ProjectType projectType) { + if (programmingLanguage == ProgrammingLanguage.MATLAB && projectType == null) { + return Map.of("MLM_LICENSE_FILE", Objects.requireNonNull(licenseConfiguration.getMatlabLicenseServer())); + } + + return Map.of(); + } +} 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 index 5ccf7f2045a6..bc8a6a93dc4b 100644 --- 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 @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,7 +18,10 @@ 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.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.domain.ProjectType; @Profile(PROFILE_CORE) @Service @@ -27,6 +31,12 @@ public class ProgrammingExerciseBuildConfigService { private final ObjectMapper objectMapper = new ObjectMapper(); + private final LicenseService licenseService; + + public ProgrammingExerciseBuildConfigService(LicenseService licenseService) { + this.licenseService = licenseService; + } + /** * Converts a JSON string representing Docker flags (in JSON format) * into a {@link DockerRunConfig} instance. @@ -46,25 +56,60 @@ public class ProgrammingExerciseBuildConfigService { public DockerRunConfig getDockerRunConfig(ProgrammingExerciseBuildConfig buildConfig) { DockerFlagsDTO dockerFlagsDTO = parseDockerFlags(buildConfig); - return getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + String network; + Map exerciseEnvironment; + if (dockerFlagsDTO != null) { + network = dockerFlagsDTO.network(); + exerciseEnvironment = dockerFlagsDTO.env(); + } + else { + network = null; + exerciseEnvironment = null; + } + + ProgrammingExercise exercise = buildConfig.getProgrammingExercise(); + if (exercise == null) { + return createDockerRunConfig(network, exerciseEnvironment); + } + + ProgrammingLanguage programmingLanguage = exercise.getProgrammingLanguage(); + ProjectType projectType = exercise.getProjectType(); + Map environment = addLanguageSpecificEnvironment(exerciseEnvironment, programmingLanguage, projectType); + + return createDockerRunConfig(network, environment); + } + + @Nullable + private Map addLanguageSpecificEnvironment(@Nullable Map exerciseEnvironment, ProgrammingLanguage language, ProjectType projectType) { + Map licenseEnvironment = licenseService.getEnvironment(language, projectType); + if (licenseEnvironment.isEmpty()) { + return exerciseEnvironment; + } + + Map env = new HashMap<>(licenseEnvironment); + if (exerciseEnvironment != null) { + env.putAll(exerciseEnvironment); + } + + return env; } - DockerRunConfig getDockerRunConfigFromParsedFlags(DockerFlagsDTO dockerFlagsDTO) { - if (dockerFlagsDTO == null) { + DockerRunConfig createDockerRunConfig(String network, Map environmentMap) { + if (network == null && environmentMap == null) { return null; } - List env = new ArrayList<>(); - boolean isNetworkDisabled = dockerFlagsDTO.network() != null && dockerFlagsDTO.network().equals("none"); + List environmentStrings = new ArrayList<>(); + boolean isNetworkDisabled = network != null && network.equals("none"); - if (dockerFlagsDTO.env() != null) { - for (Map.Entry entry : dockerFlagsDTO.env().entrySet()) { + if (environmentMap != null) { + for (Map.Entry entry : environmentMap.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); - env.add(key + "=" + value); + environmentStrings.add(key + "=" + value); } } - return new DockerRunConfig(isNetworkDisabled, env); + return new DockerRunConfig(isNetworkDisabled, environmentStrings); } /** 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 886fa2622c8d..a5d448daa272 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 @@ -1102,7 +1102,7 @@ public void validateDockerFlags(ProgrammingExercise programmingExercise) { } } - DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.createDockerRunConfig(dockerFlagsDTO.network(), dockerFlagsDTO.env()); 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/ProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingLanguageFeatureService.java index e6049a4221d2..22f29fa2b041 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingLanguageFeatureService.java @@ -1,14 +1,17 @@ package de.tum.cit.aet.artemis.programming.service; -import java.util.HashMap; +import java.util.EnumMap; import java.util.Map; +import jakarta.annotation.Nullable; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.actuate.info.Info; import org.springframework.boot.actuate.info.InfoContributor; import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.domain.ProjectType; /** * This service provides information about features the different ProgrammingLanguages support. @@ -18,7 +21,16 @@ public abstract class ProgrammingLanguageFeatureService implements InfoContribut private static final Logger log = LoggerFactory.getLogger(ProgrammingLanguageFeatureService.class); - protected final Map programmingLanguageFeatures = new HashMap<>(); + private final LicenseService licenseService; + + private final Map programmingLanguageFeatures; + + protected ProgrammingLanguageFeatureService(LicenseService licenseService) { + this.licenseService = licenseService; + this.programmingLanguageFeatures = getEnabledFeatures(); + } + + protected abstract Map getSupportedProgrammingLanguageFeatures(); /** * Get the ProgrammingLanguageFeature configured for the given ProgrammingLanguage. @@ -37,12 +49,44 @@ public ProgrammingLanguageFeature getProgrammingLanguageFeatures(ProgrammingLang return programmingLanguageFeature; } - public Map getProgrammingLanguageFeatures() { - return programmingLanguageFeatures; - } - @Override public void contribute(Info.Builder builder) { - builder.withDetail("programmingLanguageFeatures", getProgrammingLanguageFeatures().values()); + builder.withDetail("programmingLanguageFeatures", programmingLanguageFeatures.values()); + } + + private Map getEnabledFeatures() { + var features = new EnumMap(ProgrammingLanguage.class); + for (var programmingLanguageFeatureEntry : getSupportedProgrammingLanguageFeatures().entrySet()) { + var language = programmingLanguageFeatureEntry.getKey(); + var feature = programmingLanguageFeatureEntry.getValue(); + if (feature.projectTypes().isEmpty()) { + if (isProjectTypeUsable(language, null)) { + features.put(language, feature); + } + } + else { + var filteredProjectTypes = feature.projectTypes().stream().filter((projectType -> isProjectTypeUsable(language, projectType))).toList(); + if (!filteredProjectTypes.isEmpty()) { + // @formatter:off + var filteredFeature = new ProgrammingLanguageFeature( + feature.programmingLanguage(), + feature.sequentialTestRuns(), + feature.staticCodeAnalysis(), + feature.plagiarismCheckSupported(), + feature.packageNameRequired(), + feature.checkoutSolutionRepositoryAllowed(), + filteredProjectTypes, + feature.auxiliaryRepositoriesSupported() + ); + // @formatter:on + features.put(language, filteredFeature); + } + } + } + return features; + } + + private boolean isProjectTypeUsable(ProgrammingLanguage programmingLanguage, @Nullable ProjectType projectType) { + return licenseService.isLicensed(programmingLanguage, projectType); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java index 184f61c3d476..baad5929c94e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java @@ -32,9 +32,9 @@ public TemplateUpgradePolicyService(JavaTemplateUpgradeService javaRepositoryUpg public TemplateUpgradeService getUpgradeService(ProgrammingLanguage programmingLanguage) { return switch (programmingLanguage) { case JAVA -> javaRepositoryUpgradeService; - case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, RUBY -> + case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, MATLAB, RUBY -> defaultRepositoryUpgradeService; - case SQL, MATLAB, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); + case SQL, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); }; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java index 2a6541586ac7..01ff763c0154 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java @@ -219,9 +219,9 @@ enum RepositoryCheckoutPath implements CustomizableCheckoutPath { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, RUBY -> + case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, MATLAB, RUBY -> "assignment"; - case SQL, MATLAB, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); + case SQL, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } }, @@ -231,8 +231,8 @@ public String forProgrammingLanguage(ProgrammingLanguage language) { public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT -> ""; - case C, VHDL, ASSEMBLER, OCAML, C_SHARP, GO, BASH, RUBY -> "tests"; - case SQL, MATLAB, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); + case C, VHDL, ASSEMBLER, OCAML, C_SHARP, GO, BASH, MATLAB, RUBY -> "tests"; + case SQL, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } }, diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java index 357cf05d97f8..7035d1235ccb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java @@ -7,11 +7,15 @@ import static de.tum.cit.aet.artemis.programming.domain.ProjectType.MAVEN_MAVEN; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.PLAIN_MAVEN; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.service.LicenseService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeatureService; @@ -22,10 +26,17 @@ @Profile("gitlabci") public class GitLabCIProgrammingLanguageFeatureService extends ProgrammingLanguageFeatureService { - public GitLabCIProgrammingLanguageFeatureService() { + protected GitLabCIProgrammingLanguageFeatureService(LicenseService licenseService) { + super(licenseService); + } + + @Override + protected Map getSupportedProgrammingLanguageFeatures() { + EnumMap programmingLanguageFeatures = new EnumMap<>(ProgrammingLanguage.class); programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, false, false, false, true, false, List.of(PLAIN_MAVEN, MAVEN_MAVEN), false)); programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false)); programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false)); + return programmingLanguageFeatures; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java index cf0322807d8d..74cd0bd1e183 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java @@ -26,11 +26,15 @@ import static de.tum.cit.aet.artemis.programming.domain.ProjectType.PLAIN_GRADLE; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.PLAIN_MAVEN; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.service.LicenseService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeatureService; @@ -38,8 +42,14 @@ @Profile(PROFILE_JENKINS) public class JenkinsProgrammingLanguageFeatureService extends ProgrammingLanguageFeatureService { - public JenkinsProgrammingLanguageFeatureService() { + protected JenkinsProgrammingLanguageFeatureService(LicenseService licenseService) { + super(licenseService); + } + + @Override + protected Map getSupportedProgrammingLanguageFeatures() { // Must be extended once a new programming language is added + EnumMap programmingLanguageFeatures = new EnumMap<>(ProgrammingLanguage.class); programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false)); programmingLanguageFeatures.put(BASH, new ProgrammingLanguageFeature(BASH, false, false, false, false, false, List.of(), false)); programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false)); @@ -58,5 +68,6 @@ public JenkinsProgrammingLanguageFeatureService() { // Jenkins is not supporting XCODE at the moment programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, true, true, true, false, List.of(PLAIN), false)); programmingLanguageFeatures.put(TYPESCRIPT, new ProgrammingLanguageFeature(TYPESCRIPT, false, false, true, false, false, List.of(), false)); + return programmingLanguageFeatures; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java index 433961c222da..3a10c46e8f9a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java @@ -12,6 +12,7 @@ import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVA; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVASCRIPT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.MATLAB; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.OCAML; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; @@ -28,11 +29,15 @@ import static de.tum.cit.aet.artemis.programming.domain.ProjectType.PLAIN_GRADLE; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.PLAIN_MAVEN; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.service.LicenseService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeatureService; @@ -43,8 +48,14 @@ @Profile(PROFILE_LOCALCI) public class LocalCIProgrammingLanguageFeatureService extends ProgrammingLanguageFeatureService { - public LocalCIProgrammingLanguageFeatureService() { + protected LocalCIProgrammingLanguageFeatureService(LicenseService licenseService) { + super(licenseService); + } + + @Override + protected Map getSupportedProgrammingLanguageFeatures() { // Must be extended once a new programming language is added + EnumMap programmingLanguageFeatures = new EnumMap<>(ProgrammingLanguage.class); programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), true)); programmingLanguageFeatures.put(ASSEMBLER, new ProgrammingLanguageFeature(ASSEMBLER, false, false, false, false, false, List.of(), true)); programmingLanguageFeatures.put(BASH, new ProgrammingLanguageFeature(BASH, false, false, false, false, false, List.of(), true)); @@ -57,6 +68,7 @@ public LocalCIProgrammingLanguageFeatureService() { new ProgrammingLanguageFeature(JAVA, true, true, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN), true)); programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), true)); programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, false, false, true, true, false, List.of(), true)); + programmingLanguageFeatures.put(MATLAB, new ProgrammingLanguageFeature(MATLAB, false, false, false, false, false, List.of(), true)); programmingLanguageFeatures.put(OCAML, new ProgrammingLanguageFeature(OCAML, false, false, false, false, true, List.of(), true)); programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, true, true, false, false, List.of(), true)); programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), true)); @@ -65,5 +77,6 @@ public LocalCIProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), true)); programmingLanguageFeatures.put(TYPESCRIPT, new ProgrammingLanguageFeature(TYPESCRIPT, false, false, true, false, false, List.of(), true)); programmingLanguageFeatures.put(VHDL, new ProgrammingLanguageFeature(VHDL, false, false, false, false, false, List.of(), true)); + return programmingLanguageFeatures; } } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index a79232b10d65..04634254d4ac 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -105,6 +105,8 @@ artemis: default: "ghcr.io/ls1intum/artemis-javascript-docker:v1.0.0" go: default: "ghcr.io/ls1intum/artemis-go-docker:v1.0.0" + matlab: + default: "mathworks/matlab:r2024b" ruby: default: "ghcr.io/ls1intum/artemis-ruby-docker:v1.0.0" diff --git a/src/main/resources/templates/aeolus/matlab/default.sh b/src/main/resources/templates/aeolus/matlab/default.sh new file mode 100644 index 000000000000..1c44cf9a481a --- /dev/null +++ b/src/main/resources/templates/aeolus/matlab/default.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -e + +test () { + echo '⚙️ executing test' + cd "${testWorkingDirectory}" + + sudo mkdir test-results + sudo chown matlab:matlab test-results + sudo rm /etc/sudoers.d/matlab + + matlab -batch testRunner + +} + +main () { + test +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/matlab/default.yaml b/src/main/resources/templates/aeolus/matlab/default.yaml new file mode 100644 index 000000000000..e2a61f4b62f2 --- /dev/null +++ b/src/main/resources/templates/aeolus/matlab/default.yaml @@ -0,0 +1,18 @@ +api: v0.0.1 +metadata: + name: "MATLAB" + id: matlab +actions: + - name: test + script: | + cd "${testWorkingDirectory}" + + sudo mkdir test-results + sudo chown matlab:matlab test-results + sudo rm /etc/sudoers.d/matlab + + matlab -batch testRunner + results: + - name: Test Results + path: "${testWorkingDirectory}/test-results/results.xml" + type: junit diff --git a/src/main/resources/templates/matlab/exercise/.gitattributes b/src/main/resources/templates/matlab/exercise/.gitattributes new file mode 100644 index 000000000000..7871ff363b73 --- /dev/null +++ b/src/main/resources/templates/matlab/exercise/.gitattributes @@ -0,0 +1,28 @@ +* text=auto + +*.fig binary +*.mat binary +*.mdl binary diff merge=mlAutoMerge +*.mdlp binary +*.mex* binary +*.mlapp binary +*.mldatx binary +*.mlproj binary +*.mlx binary +*.p binary +*.sfx binary +*.sldd binary +*.slreqx binary merge=mlAutoMerge +*.slmx binary merge=mlAutoMerge +*.sltx binary +*.slxc binary +*.slx binary merge=mlAutoMerge +*.slxp binary + +## Other common binary file types +*.docx binary +*.exe binary +*.jpg binary +*.pdf binary +*.png binary +*.xlsx binary diff --git a/src/main/resources/templates/matlab/exercise/.gitignore b/src/main/resources/templates/matlab/exercise/.gitignore new file mode 100644 index 000000000000..ce3b7fbfc90e --- /dev/null +++ b/src/main/resources/templates/matlab/exercise/.gitignore @@ -0,0 +1,36 @@ +# Autosave files +*.asv +*.m~ +*.autosave +*.slx.r* +*.mdl.r* + +# Derived content-obscured files +*.p + +# Compiled MEX files +*.mex* + +# Packaged app and toolbox files +*.mlappinstall +*.mltbx + +# Deployable archives +*.ctf + +# Generated helpsearch folders +helpsearch*/ + +# Code generation folders +slprj/ +sccprj/ +codegen/ + +# Cache files +*.slxc + +# Cloud based storage dotfile +.MATLABDriveTag + +# buildtool cache folder +.buildtool/ diff --git a/src/main/resources/templates/matlab/exercise/averageGradeByStudent.m b/src/main/resources/templates/matlab/exercise/averageGradeByStudent.m new file mode 100644 index 000000000000..847a4cbb6fd9 --- /dev/null +++ b/src/main/resources/templates/matlab/exercise/averageGradeByStudent.m @@ -0,0 +1,3 @@ +function avg = averageGradeByStudent(grades) + % TODO: Task 2 +end diff --git a/src/main/resources/templates/matlab/exercise/finalGrade.m b/src/main/resources/templates/matlab/exercise/finalGrade.m new file mode 100644 index 000000000000..863131c288d8 --- /dev/null +++ b/src/main/resources/templates/matlab/exercise/finalGrade.m @@ -0,0 +1,3 @@ +function g = finalGrade(grades,weights) + % TODO: Task 3 +end diff --git a/src/main/resources/templates/matlab/exercise/medianGradeByAssignment.m b/src/main/resources/templates/matlab/exercise/medianGradeByAssignment.m new file mode 100644 index 000000000000..7ebeb2e39b3a --- /dev/null +++ b/src/main/resources/templates/matlab/exercise/medianGradeByAssignment.m @@ -0,0 +1,3 @@ +function avg = medianGradeByAssignment(grades) + % TODO: Task 1 +end diff --git a/src/main/resources/templates/matlab/readme b/src/main/resources/templates/matlab/readme new file mode 100644 index 000000000000..0a5ee1f0f265 --- /dev/null +++ b/src/main/resources/templates/matlab/readme @@ -0,0 +1,49 @@ +# Grading Statistics + +In this exercise, you will analyze student performance based on a matrix of +grades. Each row in the matrix represents a student, and each column represents +an assignment. + +Your task is to implement three functions that compute statistical measures for +this data. Each function has a specific purpose, described below: + +## Task 1: Median Grade by Assignment +Compute the median grade for each assignment across all students. The result +should be a row vector where each element corresponds to the median of a +specific assignment (column). + +- **Function to Implement**: `medianGradeByAssignment()` +- **Input**: A matrix `grades` where rows are students and columns are assignments. +- **Output**: A row vector of medians, one for each assignment. + +[task][Median Grade by Assignment](testMedianGradeByAssignment) + +--- + +## Task 2: Average Grade by Student +Calculate the arithmetic mean grade for each student across all their assignments. +The result should be a row vector where each element corresponds to a student's +arithmetic mean grade. + +- **Function to Implement**: `averageGradeByStudent()` +- **Input**: A matrix `grades` where rows are students and columns are assignments. +- **Output**: A row vector of averages, one for each student. + +[task][Average Grade by Student](testAverageGradeByStudent) + +--- + +## Task 3: Final Grade +Determine the weighted final grade for each student. In addition to the +`grades` matrix, you are provided with a row vector `weights` (summing to 1) +that represents the weight of each assignment. Compute the weighted average for +each student and round the result to 1 decimal place. The result should be a +row vector where each element corresponds to a student’s final grade. + +- **Function to Implement**: `finalGrade()` +- **Input**: + 1. A matrix `grades` where rows are students and columns are assignments. + 2. A row vector `weights` with the same number of elements as columns in the `grades` matrix. +- **Output**: A row vector of weighted final grades, rounded to 1 decimal place. + +[task][Final Grade](testFinalGrade) diff --git a/src/main/resources/templates/matlab/solution/.gitattributes b/src/main/resources/templates/matlab/solution/.gitattributes new file mode 100644 index 000000000000..7871ff363b73 --- /dev/null +++ b/src/main/resources/templates/matlab/solution/.gitattributes @@ -0,0 +1,28 @@ +* text=auto + +*.fig binary +*.mat binary +*.mdl binary diff merge=mlAutoMerge +*.mdlp binary +*.mex* binary +*.mlapp binary +*.mldatx binary +*.mlproj binary +*.mlx binary +*.p binary +*.sfx binary +*.sldd binary +*.slreqx binary merge=mlAutoMerge +*.slmx binary merge=mlAutoMerge +*.sltx binary +*.slxc binary +*.slx binary merge=mlAutoMerge +*.slxp binary + +## Other common binary file types +*.docx binary +*.exe binary +*.jpg binary +*.pdf binary +*.png binary +*.xlsx binary diff --git a/src/main/resources/templates/matlab/solution/.gitignore b/src/main/resources/templates/matlab/solution/.gitignore new file mode 100644 index 000000000000..ce3b7fbfc90e --- /dev/null +++ b/src/main/resources/templates/matlab/solution/.gitignore @@ -0,0 +1,36 @@ +# Autosave files +*.asv +*.m~ +*.autosave +*.slx.r* +*.mdl.r* + +# Derived content-obscured files +*.p + +# Compiled MEX files +*.mex* + +# Packaged app and toolbox files +*.mlappinstall +*.mltbx + +# Deployable archives +*.ctf + +# Generated helpsearch folders +helpsearch*/ + +# Code generation folders +slprj/ +sccprj/ +codegen/ + +# Cache files +*.slxc + +# Cloud based storage dotfile +.MATLABDriveTag + +# buildtool cache folder +.buildtool/ diff --git a/src/main/resources/templates/matlab/solution/averageGradeByStudent.m b/src/main/resources/templates/matlab/solution/averageGradeByStudent.m new file mode 100644 index 000000000000..6bc13004967a --- /dev/null +++ b/src/main/resources/templates/matlab/solution/averageGradeByStudent.m @@ -0,0 +1,3 @@ +function avg = averageGradeByStudent(grades) + avg = mean(grades,2).'; +end diff --git a/src/main/resources/templates/matlab/solution/finalGrade.m b/src/main/resources/templates/matlab/solution/finalGrade.m new file mode 100644 index 000000000000..4fdfe1c2b8bb --- /dev/null +++ b/src/main/resources/templates/matlab/solution/finalGrade.m @@ -0,0 +1,3 @@ +function g = finalGrade(grades,weights) + g = round((grades * weights.').',1); +end diff --git a/src/main/resources/templates/matlab/solution/medianGradeByAssignment.m b/src/main/resources/templates/matlab/solution/medianGradeByAssignment.m new file mode 100644 index 000000000000..31d5fe2b5800 --- /dev/null +++ b/src/main/resources/templates/matlab/solution/medianGradeByAssignment.m @@ -0,0 +1,3 @@ +function avg = medianGradeByAssignment(grades) + avg = median(grades); +end diff --git a/src/main/resources/templates/matlab/test/.gitattributes b/src/main/resources/templates/matlab/test/.gitattributes new file mode 100644 index 000000000000..7871ff363b73 --- /dev/null +++ b/src/main/resources/templates/matlab/test/.gitattributes @@ -0,0 +1,28 @@ +* text=auto + +*.fig binary +*.mat binary +*.mdl binary diff merge=mlAutoMerge +*.mdlp binary +*.mex* binary +*.mlapp binary +*.mldatx binary +*.mlproj binary +*.mlx binary +*.p binary +*.sfx binary +*.sldd binary +*.slreqx binary merge=mlAutoMerge +*.slmx binary merge=mlAutoMerge +*.sltx binary +*.slxc binary +*.slx binary merge=mlAutoMerge +*.slxp binary + +## Other common binary file types +*.docx binary +*.exe binary +*.jpg binary +*.pdf binary +*.png binary +*.xlsx binary diff --git a/src/main/resources/templates/matlab/test/.gitignore b/src/main/resources/templates/matlab/test/.gitignore new file mode 100644 index 000000000000..24cee4ed66ae --- /dev/null +++ b/src/main/resources/templates/matlab/test/.gitignore @@ -0,0 +1,39 @@ +# Test results +results.xml + +# Autosave files +*.asv +*.m~ +*.autosave +*.slx.r* +*.mdl.r* + +# Derived content-obscured files +*.p + +# Compiled MEX files +*.mex* + +# Packaged app and toolbox files +*.mlappinstall +*.mltbx + +# Deployable archives +*.ctf + +# Generated helpsearch folders +helpsearch*/ + +# Code generation folders +slprj/ +sccprj/ +codegen/ + +# Cache files +*.slxc + +# Cloud based storage dotfile +.MATLABDriveTag + +# buildtool cache folder +.buildtool/ diff --git a/src/main/resources/templates/matlab/test/testRunner.m b/src/main/resources/templates/matlab/test/testRunner.m new file mode 100644 index 000000000000..1e409e2de0dd --- /dev/null +++ b/src/main/resources/templates/matlab/test/testRunner.m @@ -0,0 +1,12 @@ +import matlab.unittest.plugins.XMLPlugin + +addpath("../${studentParentWorkingDirectoryName}") + +runner = testrunner; + +plugin = XMLPlugin.producingJUnitFormat("test-results/results.xml"); +addPlugin(runner,plugin); + +suite = testsuite("tests"); + +run(runner,suite); diff --git a/src/main/resources/templates/matlab/test/tests/GradeTest.m b/src/main/resources/templates/matlab/test/tests/GradeTest.m new file mode 100644 index 000000000000..3511c887274f --- /dev/null +++ b/src/main/resources/templates/matlab/test/tests/GradeTest.m @@ -0,0 +1,45 @@ +classdef GradeTest < matlab.unittest.TestCase + properties + grades + end + + methods (TestClassSetup) + % Shared setup for the entire test class + end + + methods (TestMethodSetup) + % Setup for each test + + function setupGrades(testCase) + testCase.grades = [1.3 3.3 4.0 4.7 + 2.7 1.7 4.0 1.7 + 1.7 3.7 3.0 4.3 + 4.3 2.3 1.7 3.3 + 2.3 3.7 2.0 5.0]; + end + end + + methods (Test) + % Test methods + + function testMedianGradeByAssignment(testCase) + actual = medianGradeByAssignment(testCase.grades); + expected = [2.3 3.3 3.0 4.3]; + testCase.assertEqual(actual,expected,"median is incorrect",AbsTol=0.0001); + end + + function testAverageGradeByStudent(testCase) + actual = averageGradeByStudent(testCase.grades); + expected = [3.325 2.525 3.175 2.9 3.25]; + testCase.assertEqual(actual,expected,"average is incorrect",AbsTol=0.0001); + end + + function testFinalGrade(testCase) + weights = [0.1 0.1 0.5 0.3]; + actual = finalGrade(testCase.grades,weights); + expected = [3.9 3.0 3.3 2.5 3.1]; + testCase.assertEqual(actual,expected,"final grades are incorrect",AbsTol=0.0001); + end + end + +end diff --git a/src/main/webapp/app/app.routes.ts b/src/main/webapp/app/app.routes.ts index 12e05fc20795..1ee5b6003bfe 100644 --- a/src/main/webapp/app/app.routes.ts +++ b/src/main/webapp/app/app.routes.ts @@ -183,7 +183,7 @@ const routes: Routes = [ // ===== EXAM ===== { path: 'course-management/:courseId/exams', - loadChildren: () => import('./exam/manage/exam-management.module').then((m) => m.ArtemisExamManagementModule), + loadChildren: () => import('./exam/manage/exam-management.route').then((m) => m.examManagementRoute), }, { path: 'courses/:courseId/exams/:examId/grading-system', diff --git a/src/main/webapp/app/course/manage/course-management.route.ts b/src/main/webapp/app/course/manage/course-management.route.ts index eb8a2f8ccf7b..fc55b6daf8c3 100644 --- a/src/main/webapp/app/course/manage/course-management.route.ts +++ b/src/main/webapp/app/course/manage/course-management.route.ts @@ -76,7 +76,7 @@ export const courseManagementState: Routes = [ }, { path: ':courseId/exams', - loadChildren: () => import('../../exam/manage/exam-management.module').then((m) => m.ArtemisExamManagementModule), + loadChildren: () => import('../../exam/manage/exam-management.route').then((m) => m.examManagementRoute), }, { path: ':courseId/tutorial-groups-checklist', diff --git a/src/main/webapp/app/entities/programming/programming-exercise.model.ts b/src/main/webapp/app/entities/programming/programming-exercise.model.ts index 608d5e14e041..b07d272ff26a 100644 --- a/src/main/webapp/app/entities/programming/programming-exercise.model.ts +++ b/src/main/webapp/app/entities/programming/programming-exercise.model.ts @@ -23,6 +23,7 @@ export enum ProgrammingLanguage { JAVA = 'JAVA', JAVASCRIPT = 'JAVASCRIPT', KOTLIN = 'KOTLIN', + MATLAB = 'MATLAB', OCAML = 'OCAML', PYTHON = 'PYTHON', R = 'R', diff --git a/src/main/webapp/app/exam/exam-scores/exam-scores.route.ts b/src/main/webapp/app/exam/exam-scores/exam-scores.route.ts deleted file mode 100644 index ac35ee9c1370..000000000000 --- a/src/main/webapp/app/exam/exam-scores/exam-scores.route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Route, Routes } from '@angular/router'; - -import { Authority } from 'app/shared/constants/authority.constants'; -import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; - -export const examScoresRoute: Route[] = [ - { - path: ':examId/scores', - loadComponent: () => import('app/exam/exam-scores/exam-scores.component').then((m) => m.ExamScoresComponent), - }, -]; - -const EXAM_SCORES_ROUTES = [...examScoresRoute]; - -export const examScoresState: Routes = [ - { - path: '', - children: EXAM_SCORES_ROUTES, - data: { - authorities: [Authority.ADMIN, Authority.INSTRUCTOR], - pageTitle: 'artemisApp.examScores.title', - }, - canActivate: [UserRouteAccessService], - }, -]; diff --git a/src/main/webapp/app/exam/manage/exam-management.module.ts b/src/main/webapp/app/exam/manage/exam-management.module.ts deleted file mode 100644 index 6118a2fd855e..000000000000 --- a/src/main/webapp/app/exam/manage/exam-management.module.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { ExamManagementComponent } from 'app/exam/manage/exam-management.component'; -import { examManagementState } from 'app/exam/manage/exam-management.route'; -import { ExamUpdateComponent } from 'app/exam/manage/exams/exam-update.component'; -import { ExamDetailComponent } from 'app/exam/manage/exams/exam-detail.component'; -import { ExerciseGroupsComponent } from 'app/exam/manage/exercise-groups/exercise-groups.component'; -import { ExerciseGroupUpdateComponent } from 'app/exam/manage/exercise-groups/exercise-group-update.component'; -import { ExamStudentsComponent } from 'app/exam/manage/students/exam-students.component'; -import { StudentExamsComponent } from 'app/exam/manage/student-exams/student-exams.component'; -import { StudentExamDetailComponent } from 'app/exam/manage/student-exams/student-exam-detail.component'; -import { StudentsUploadImagesModule } from 'app/exam/manage/students/upload-images/students-upload-images.module'; -import { ExamStudentsAttendanceCheckComponent } from 'app/exam/manage/students/verify-attendance-check/exam-students-attendance-check.component'; -import { ArtemisTextExerciseModule } from 'app/exercises/text/manage/text-exercise/text-exercise.module'; -import { ArtemisFileUploadExerciseManagementModule } from 'app/exercises/file-upload/manage/file-upload-exercise-management.module'; -import { ArtemisProgrammingExerciseManagementModule } from 'app/exercises/programming/manage/programming-exercise-management.module'; -import { ArtemisQuizManagementModule } from 'app/exercises/quiz/manage/quiz-management.module'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { ArtemisDataTableModule } from 'app/shared/data-table/data-table.module'; -import { NgxDatatableModule } from '@siemens/ngx-datatable'; -import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; -import { DurationPipe } from 'app/shared/pipes/artemis-duration.pipe'; -import { StudentExamStatusComponent } from 'app/exam/manage/student-exams/student-exam-status/student-exam-status.component'; -import { StudentExamSummaryComponent } from 'app/exam/manage/student-exams/student-exam-summary.component'; -import { ArtemisParticipationSummaryModule } from 'app/exam/participate/summary/exam-result-summary.module'; -import { ExamExerciseRowButtonsComponent } from 'app/exercises/shared/exam-exercise-row-buttons/exam-exercise-row-buttons.component'; -import { ArtemisProgrammingExerciseStatusModule } from 'app/exercises/programming/manage/status/programming-exercise-status.module'; -import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { ExamChecklistComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist.component'; -import { ExamChecklistExerciseGroupTableComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component'; -import { ArtemisTutorParticipationGraphModule } from 'app/shared/dashboards/tutor-participation-graph/tutor-participation-graph.module'; -import { ProgrammingExerciseGroupCellComponent } from './exercise-groups/programming-exercise-cell/programming-exercise-group-cell.component'; -import { FileUploadExerciseGroupCellComponent } from './exercise-groups/file-upload-exercise-cell/file-upload-exercise-group-cell.component'; -import { ModelingExerciseGroupCellComponent } from './exercise-groups/modeling-exercise-cell/modeling-exercise-group-cell.component'; -import { QuizExerciseGroupCellComponent } from './exercise-groups/quiz-exercise-cell/quiz-exercise-group-cell.component'; -import { ArtemisTextSubmissionAssessmentModule } from 'app/exercises/text/assess/text-submission-assessment.module'; -import { StudentExamDetailTableRowComponent } from 'app/exam/manage/student-exams/student-exam-detail-table-row/student-exam-detail-table-row.component'; -import { ExampleSubmissionsModule } from 'app/exercises/shared/example-submission/example-submissions.module'; -import { BarChartModule } from '@swimlane/ngx-charts'; -import { UserImportModule } from 'app/shared/user-import/user-import.module'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; -import { ExamStatusComponent } from 'app/exam/manage/exam-status.component'; -import { ArtemisExamModePickerModule } from 'app/exam/manage/exams/exam-mode-picker/exam-mode-picker.module'; -import { ExamImportComponent } from 'app/exam/manage/exams/exam-import/exam-import.component'; -import { ArtemisHeaderExercisePageWithDetailsModule } from 'app/exercises/shared/exercise-headers/exercise-headers.module'; -import { ExamExerciseImportComponent } from 'app/exam/manage/exams/exam-exercise-import/exam-exercise-import.component'; - -import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; -import { StudentExamTimelineComponent } from './student-exams/student-exam-timeline/student-exam-timeline.component'; -import { TitleChannelNameModule } from 'app/shared/form/title-channel-name/title-channel-name.module'; -import { ExamEditWorkingTimeDialogComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component'; -import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; -import { ExamLiveAnnouncementCreateModalComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-modal.component'; -import { ExamLiveAnnouncementCreateButtonComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-button.component'; - -import { ArtemisExamNavigationBarModule } from 'app/exam/participate/exam-navigation-bar/exam-navigation-bar.module'; -import { ArtemisExamSubmissionComponentsModule } from 'app/exam/participate/exercises/exam-submission-components.module'; -import { MatSliderModule } from '@angular/material/slider'; -import { ProgrammingExerciseExamDiffComponent } from './student-exams/student-exam-timeline/programming-exam-diff/programming-exercise-exam-diff.component'; -import { ArtemisProgrammingExerciseModule } from 'app/exercises/programming/shared/programming-exercise.module'; -import { DetailModule } from 'app/detail-overview-list/detail.module'; -import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; -import { NoDataComponent } from 'app/shared/no-data-component'; -import { SafeHtmlPipe } from 'app/shared/pipes/safe-html.pipe'; -import { GradeStepBoundsPipe } from 'app/shared/pipes/grade-step-bounds.pipe'; -import { examScoresState } from 'app/exam/exam-scores/exam-scores.route'; -import { GitDiffLineStatComponent } from 'app/exercises/programming/git-diff-report/git-diff-line-stat.component'; - -const ENTITY_STATES = [...examManagementState, ...examScoresState]; - -@NgModule({ - // TODO: For better modularization we could define an exercise module with the corresponding exam routes - providers: [ArtemisDurationFromSecondsPipe, SafeHtmlPipe, GradeStepBoundsPipe], - imports: [ - RouterModule.forChild(ENTITY_STATES), - ArtemisTextExerciseModule, - ArtemisSharedModule, - FormDateTimePickerModule, - ArtemisSharedComponentModule, - ArtemisMarkdownEditorModule, - NgxDatatableModule, - ArtemisDataTableModule, - ArtemisFileUploadExerciseManagementModule, - ArtemisProgrammingExerciseManagementModule, - ArtemisQuizManagementModule, - ArtemisParticipationSummaryModule, - ArtemisProgrammingExerciseStatusModule, - ArtemisMarkdownModule, - ArtemisTutorParticipationGraphModule, - ArtemisTextSubmissionAssessmentModule, - ExampleSubmissionsModule, - UserImportModule, - ArtemisExamSharedModule, - ArtemisExamModePickerModule, - ArtemisHeaderExercisePageWithDetailsModule, - BarChartModule, - StudentsUploadImagesModule, - TitleChannelNameModule, - ArtemisExamNavigationBarModule, - ArtemisExamSubmissionComponentsModule, - MatSliderModule, - ArtemisProgrammingExerciseModule, - DetailModule, - NoDataComponent, - GitDiffLineStatComponent, - SafeHtmlPipe, - GradeStepBoundsPipe, - BonusComponent, - ExamManagementComponent, - ExamUpdateComponent, - ExamDetailComponent, - ExerciseGroupsComponent, - ExerciseGroupUpdateComponent, - ExamExerciseRowButtonsComponent, - ExamStudentsComponent, - ExamStudentsAttendanceCheckComponent, - StudentExamStatusComponent, - StudentExamsComponent, - StudentExamDetailComponent, - DurationPipe, - StudentExamSummaryComponent, - ExamChecklistComponent, - ExamChecklistExerciseGroupTableComponent, - ExamStatusComponent, - ProgrammingExerciseGroupCellComponent, - FileUploadExerciseGroupCellComponent, - ModelingExerciseGroupCellComponent, - QuizExerciseGroupCellComponent, - StudentExamDetailTableRowComponent, - ExamImportComponent, - ExamExerciseImportComponent, - ExamEditWorkingTimeComponent, - ExamEditWorkingTimeDialogComponent, - ExamLiveAnnouncementCreateModalComponent, - ExamLiveAnnouncementCreateButtonComponent, - StudentExamTimelineComponent, - ProgrammingExerciseExamDiffComponent, - ], -}) -export class ArtemisExamManagementModule {} diff --git a/src/main/webapp/app/exam/manage/exam-management.route.ts b/src/main/webapp/app/exam/manage/exam-management.route.ts index 6b8cd746e9ae..47113ae86d0f 100644 --- a/src/main/webapp/app/exam/manage/exam-management.route.ts +++ b/src/main/webapp/app/exam/manage/exam-management.route.ts @@ -63,6 +63,15 @@ export const examManagementRoute: Routes = [ }, canActivate: [UserRouteAccessService], }, + { + path: ':examId/scores', + loadComponent: () => import('app/exam/exam-scores/exam-scores.component').then((m) => m.ExamScoresComponent), + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.examScores.title', + }, + canActivate: [UserRouteAccessService], + }, { path: ':examId', loadComponent: () => import('app/exam/manage/exams/exam-detail.component').then((m) => m.ExamDetailComponent), @@ -996,12 +1005,3 @@ export const examManagementRoute: Routes = [ canActivate: [UserRouteAccessService], }, ]; - -const EXAM_MANAGEMENT_ROUTES = [...examManagementRoute]; - -export const examManagementState: Routes = [ - { - path: '', - children: EXAM_MANAGEMENT_ROUTES, - }, -]; diff --git a/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts b/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts index cbf58cbb97b2..5ad8f10f0e3b 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts @@ -14,7 +14,7 @@ import { faAward, faClipboard, faEye, faFlaskVial, faHeartBroken, faListAlt, faT import { AlertService } from 'app/core/util/alert.service'; import { GradingSystemService } from 'app/grading-system/grading-system.service'; import { GradeType } from 'app/entities/grading-scale.model'; -import { DetailOverviewSection, DetailType } from 'app/detail-overview-list/detail-overview-list.component'; +import { DetailOverviewListComponent, DetailOverviewSection, DetailType } from 'app/detail-overview-list/detail-overview-list.component'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; import { scrollToTopOfPage } from 'app/shared/util/utils'; import { ExerciseType } from 'app/entities/exercise.model'; @@ -23,12 +23,12 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; import { CourseExamArchiveButtonComponent } from 'app/shared/components/course-exam-archive-button/course-exam-archive-button.component'; import { ExamChecklistComponent } from './exam-checklist-component/exam-checklist.component'; -import { DetailOverviewListComponent } from 'app/detail-overview-list/detail-overview-list.component'; @Component({ selector: 'jhi-exam-detail', templateUrl: './exam-detail.component.html', imports: [TranslateDirective, RouterLink, FaIconComponent, DeleteButtonDirective, CourseExamArchiveButtonComponent, ExamChecklistComponent, DetailOverviewListComponent], + providers: [ArtemisDurationFromSecondsPipe], }) export class ExamDetailComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); diff --git a/src/main/webapp/app/exam/manage/exams/exam-mode-picker/exam-mode-picker.module.ts b/src/main/webapp/app/exam/manage/exams/exam-mode-picker/exam-mode-picker.module.ts deleted file mode 100644 index 50ab25c83469..000000000000 --- a/src/main/webapp/app/exam/manage/exams/exam-mode-picker/exam-mode-picker.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { ExamModePickerComponent } from 'app/exam/manage/exams/exam-mode-picker/exam-mode-picker.component'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; - -@NgModule({ - imports: [ArtemisSharedModule, ExamModePickerComponent], - exports: [ExamModePickerComponent], -}) -export class ArtemisExamModePickerModule {} diff --git a/src/main/webapp/app/exam/manage/students/upload-images/students-upload-images.module.ts b/src/main/webapp/app/exam/manage/students/upload-images/students-upload-images.module.ts deleted file mode 100644 index cbad103c8eef..000000000000 --- a/src/main/webapp/app/exam/manage/students/upload-images/students-upload-images.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { StudentsUploadImagesButtonComponent } from 'app/exam/manage/students/upload-images/students-upload-images-button.component'; -import { StudentsUploadImagesDialogComponent } from 'app/exam/manage/students/upload-images/students-upload-images-dialog.component'; -import { NgModule } from '@angular/core'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; - -@NgModule({ - imports: [ArtemisSharedComponentModule, ArtemisSharedCommonModule, StudentsUploadImagesDialogComponent, StudentsUploadImagesButtonComponent], - exports: [StudentsUploadImagesDialogComponent, StudentsUploadImagesButtonComponent], -}) -export class StudentsUploadImagesModule {} diff --git a/src/main/webapp/app/exam/participate/events/exam-live-events.module.ts b/src/main/webapp/app/exam/participate/events/exam-live-events.module.ts deleted file mode 100644 index cc805d9c46f0..000000000000 --- a/src/main/webapp/app/exam/participate/events/exam-live-events.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ArtemisExamTimerModule } from 'app/exam/participate/timer/exam-timer.module'; -import { ExamLiveEventsButtonComponent } from 'app/exam/participate/events/exam-live-events-button.component'; -import { ExamLiveEventsOverlayComponent } from 'app/exam/participate/events/exam-live-events-overlay.component'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; - -@NgModule({ - imports: [CommonModule, ArtemisSharedCommonModule, ArtemisExamTimerModule, ArtemisExamSharedModule, ExamLiveEventsButtonComponent, ExamLiveEventsOverlayComponent], - exports: [ExamLiveEventsButtonComponent, ExamLiveEventsOverlayComponent], -}) -export class ArtemisExamLiveEventsModule {} diff --git a/src/main/webapp/app/exam/participate/exam-bar/exam-bar.component.ts b/src/main/webapp/app/exam/participate/exam-bar/exam-bar.component.ts index e4c0b2afacf5..b2846149bacb 100644 --- a/src/main/webapp/app/exam/participate/exam-bar/exam-bar.component.ts +++ b/src/main/webapp/app/exam/participate/exam-bar/exam-bar.component.ts @@ -1,18 +1,18 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ArtemisExamTimerModule } from 'app/exam/participate/timer/exam-timer.module'; -import { ArtemisExamLiveEventsModule } from 'app/exam/participate/events/exam-live-events.module'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { faDoorClosed } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; import { Exam } from 'app/entities/exam/exam.model'; import { StudentExam } from 'app/entities/student-exam.model'; +import { ExamTimerComponent } from 'app/exam/participate/timer/exam-timer.component'; +import { ExamLiveEventsButtonComponent } from 'app/exam/participate/events/exam-live-events-button.component'; @Component({ selector: 'jhi-exam-bar', - imports: [CommonModule, ArtemisSharedCommonModule, ArtemisExamTimerModule, ArtemisExamLiveEventsModule], + imports: [CommonModule, ArtemisSharedCommonModule, ExamTimerComponent, ExamLiveEventsButtonComponent], templateUrl: './exam-bar.component.html', styleUrl: './exam-bar.component.scss', }) diff --git a/src/main/webapp/app/exam/participate/exam-navigation-bar/exam-navigation-bar.module.ts b/src/main/webapp/app/exam/participate/exam-navigation-bar/exam-navigation-bar.module.ts deleted file mode 100644 index e56eeac54ab9..000000000000 --- a/src/main/webapp/app/exam/participate/exam-navigation-bar/exam-navigation-bar.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ExamNavigationBarComponent } from 'app/exam/participate/exam-navigation-bar/exam-navigation-bar.component'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ArtemisExamTimerModule } from 'app/exam/participate/timer/exam-timer.module'; -import { ArtemisExamLiveEventsModule } from 'app/exam/participate/events/exam-live-events.module'; - -@NgModule({ - imports: [CommonModule, ArtemisSharedCommonModule, ArtemisExamTimerModule, ArtemisExamLiveEventsModule, ExamNavigationBarComponent], - exports: [ExamNavigationBarComponent], -}) -export class ArtemisExamNavigationBarModule {} diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.ts b/src/main/webapp/app/exam/participate/exam-participation.component.ts index 69dde679bdbb..60601da9796f 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.component.ts @@ -15,7 +15,7 @@ import { Submission } from 'app/entities/submission.model'; import { Exam } from 'app/entities/exam/exam.model'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; -import { BehaviorSubject, Observable, Subject, Subscription, of, throwError } from 'rxjs'; +import { BehaviorSubject, Observable, Subject, Subscription, combineLatest, of, throwError } from 'rxjs'; import { catchError, distinctUntilChanged, filter, map, tap, throttleTime, timeout } from 'rxjs/operators'; import { InitializationState } from 'app/entities/participation/participation.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; @@ -210,8 +210,12 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC * loads the exam from the server and initializes the view */ ngOnInit(): void { - this.route.parent?.parent?.params.subscribe((params) => { - this.courseId = parseInt(params['courseId'], 10); + combineLatest({ + parentParams: this.route.parent?.parent?.params ?? of({ courseId: undefined }), + currentParams: this.route.params, + }).subscribe(({ parentParams, currentParams }) => { + const courseId = currentParams['courseId'] || parentParams['courseId']; + this.courseId = parseInt(courseId, 10); }); this.route.params.subscribe((params) => { this.examId = parseInt(params['examId'], 10); diff --git a/src/main/webapp/app/exam/participate/exam-participation.module.ts b/src/main/webapp/app/exam/participate/exam-participation.module.ts deleted file mode 100644 index 980a151486e4..000000000000 --- a/src/main/webapp/app/exam/participate/exam-participation.module.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ExamParticipationComponent } from 'app/exam/participate/exam-participation.component'; -import { ExamParticipationCoverComponent } from './exam-cover/exam-participation-cover.component'; -import { examParticipationState } from 'app/exam/participate/exam-participation.route'; -import { ArtemisQuizQuestionTypesModule } from 'app/exercises/quiz/shared/questions/artemis-quiz-question-types.module'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { ArtemisModelingEditorModule } from 'app/exercises/modeling/shared/modeling-editor.module'; -import { ArtemisFullscreenModule } from 'app/shared/fullscreen/fullscreen.module'; -import { ArtemisProgrammingAssessmentModule } from 'app/exercises/programming/assess/programming-assessment.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisProgrammingParticipationModule } from 'app/exercises/programming/participate/programming-participation.module'; -import { ArtemisCodeEditorModule } from 'app/exercises/programming/shared/code-editor/code-editor.module'; -import { ArtemisResultModule } from 'app/exercises/shared/result/result.module'; -import { ArtemisProgrammingExerciseActionsModule } from 'app/exercises/programming/shared/actions/programming-exercise-actions.module'; -import { ArtemisParticipationSummaryModule } from 'app/exam/participate/summary/exam-result-summary.module'; -import { ArtemisExerciseButtonsModule } from 'app/overview/exercise-details/exercise-buttons.module'; -import { ArtemisHeaderExercisePageWithDetailsModule } from 'app/exercises/shared/exercise-headers/exercise-headers.module'; -import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { ExamExerciseOverviewPageComponent } from 'app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component'; -import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; -import { ArtemisExamNavigationBarModule } from 'app/exam/participate/exam-navigation-bar/exam-navigation-bar.module'; -import { ArtemisExamTimerModule } from 'app/exam/participate/timer/exam-timer.module'; -import { ArtemisExamSubmissionComponentsModule } from 'app/exam/participate/exercises/exam-submission-components.module'; -import { ExamExerciseUpdateHighlighterModule } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; -import { ArtemisExamLiveEventsModule } from 'app/exam/participate/events/exam-live-events.module'; -import { ExamStartInformationComponent } from 'app/exam/participate/exam-start-information/exam-start-information.component'; -import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module'; -import { ExamNavigationSidebarComponent } from 'app/exam/participate/exam-navigation-sidebar/exam-navigation-sidebar.component'; -import { ExamBarComponent } from 'app/exam/participate/exam-bar/exam-bar.component'; -import { TestRunRibbonComponent } from 'app/exam/manage/test-runs/test-run-ribbon.component'; - -const ENTITY_STATES = [...examParticipationState]; - -@NgModule({ - imports: [ - RouterModule.forChild(ENTITY_STATES), - ArtemisSharedCommonModule, - ArtemisHeaderExercisePageWithDetailsModule, - ArtemisSharedModule, - ArtemisModelingEditorModule, - ArtemisQuizQuestionTypesModule, - ArtemisFullscreenModule, - ArtemisSharedComponentModule, - ArtemisProgrammingParticipationModule, - ArtemisCodeEditorModule, - ArtemisResultModule, - ArtemisProgrammingExerciseActionsModule, - ArtemisExerciseButtonsModule, - ArtemisProgrammingAssessmentModule, - ArtemisParticipationSummaryModule, - ArtemisMarkdownModule, - SubmissionResultStatusModule, - ArtemisExamNavigationBarModule, - ArtemisExamTimerModule, - ArtemisExamSubmissionComponentsModule, - ExamExerciseUpdateHighlighterModule, - ArtemisExamSharedModule, - ArtemisExamLiveEventsModule, - ExamStartInformationComponent, - ArtemisSidebarModule, - ExamNavigationSidebarComponent, - ExamBarComponent, - ExamParticipationComponent, - ExamParticipationCoverComponent, - ExamExerciseOverviewPageComponent, - TestRunRibbonComponent, - ], -}) -export class ArtemisExamParticipationModule {} diff --git a/src/main/webapp/app/exam/participate/exam-participation.route.ts b/src/main/webapp/app/exam/participate/exam-participation.route.ts index dfced7d039e0..6debe5bf4e5b 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.route.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.route.ts @@ -53,12 +53,3 @@ export const examParticipationRoute: Routes = [ canActivate: [UserRouteAccessService], }, ]; - -const EXAM_PARTICIPATION_ROUTES = [...examParticipationRoute]; - -export const examParticipationState: Routes = [ - { - path: '', - children: EXAM_PARTICIPATION_ROUTES, - }, -]; diff --git a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts index 6e56677edc85..b0539ddd97c3 100644 --- a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts +++ b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts @@ -4,13 +4,13 @@ import { ArtemisSharedComponentModule } from 'app/shared/components/shared-compo import { InformationBox, InformationBoxComponent, InformationBoxContent } from 'app/shared/information-box/information-box.component'; import { Exam } from 'app/entities/exam/exam.model'; import { StudentExam } from 'app/entities/student-exam.model'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; import dayjs from 'dayjs/esm'; import { SafeHtml } from '@angular/platform-browser'; +import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; @Component({ selector: 'jhi-exam-start-information', - imports: [ArtemisSharedModule, ArtemisSharedComponentModule, InformationBoxComponent, ArtemisExamSharedModule], + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, InformationBoxComponent, StudentExamWorkingTimeComponent], templateUrl: './exam-start-information.component.html', }) export class ExamStartInformationComponent implements OnInit { diff --git a/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts b/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts deleted file mode 100644 index e915682f2001..000000000000 --- a/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ExamExerciseUpdateHighlighterComponent } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component'; - -@NgModule({ - imports: [ArtemisSharedCommonModule, ExamExerciseUpdateHighlighterComponent], - exports: [ExamExerciseUpdateHighlighterComponent], -}) -export class ExamExerciseUpdateHighlighterModule {} diff --git a/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts b/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts deleted file mode 100644 index 5fbbb9aee4bc..000000000000 --- a/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FileUploadExamSubmissionComponent } from 'app/exam/participate/exercises/file-upload/file-upload-exam-submission.component'; -import { QuizExamSubmissionComponent } from 'app/exam/participate/exercises/quiz/quiz-exam-submission.component'; -import { ProgrammingExamSubmissionComponent } from 'app/exam/participate/exercises/programming/programming-exam-submission.component'; -import { TextExamSubmissionComponent } from 'app/exam/participate/exercises/text/text-exam-submission.component'; -import { ModelingExamSubmissionComponent } from 'app/exam/participate/exercises/modeling/modeling-exam-submission.component'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { ExamExerciseUpdateHighlighterModule } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisQuizQuestionTypesModule } from 'app/exercises/quiz/shared/questions/artemis-quiz-question-types.module'; -import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; -import { ArtemisExerciseButtonsModule } from 'app/overview/exercise-details/exercise-buttons.module'; -import { ArtemisProgrammingExerciseActionsModule } from 'app/exercises/programming/shared/actions/programming-exercise-actions.module'; -import { ArtemisCodeEditorModule } from 'app/exercises/programming/shared/code-editor/code-editor.module'; -import { ArtemisFullscreenModule } from 'app/shared/fullscreen/fullscreen.module'; -import { ArtemisModelingEditorModule } from 'app/exercises/modeling/shared/modeling-editor.module'; -import { ArtemisProgrammingSubmissionPolicyStatusModule } from 'app/exercises/programming/participate/programming-submission-policy-status.module'; -import { ExerciseSaveButtonComponent } from './exercise-save-button/exercise-save-button.component'; - -@NgModule({ - imports: [ - CommonModule, - ArtemisSharedModule, - ArtemisMarkdownModule, - ArtemisSharedComponentModule, - ArtemisQuizQuestionTypesModule, - SubmissionResultStatusModule, - ArtemisExerciseButtonsModule, - ArtemisProgrammingExerciseActionsModule, - ArtemisCodeEditorModule, - ArtemisFullscreenModule, - ArtemisModelingEditorModule, - ArtemisProgrammingSubmissionPolicyStatusModule, - ExamExerciseUpdateHighlighterModule, - ExerciseSaveButtonComponent, - FileUploadExamSubmissionComponent, - QuizExamSubmissionComponent, - ProgrammingExamSubmissionComponent, - TextExamSubmissionComponent, - ModelingExamSubmissionComponent, - ], - exports: [FileUploadExamSubmissionComponent, QuizExamSubmissionComponent, ProgrammingExamSubmissionComponent, TextExamSubmissionComponent, ModelingExamSubmissionComponent], -}) -export class ArtemisExamSubmissionComponentsModule {} diff --git a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts index 3f2988b91922..f50d7b278a6e 100644 --- a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts @@ -185,7 +185,7 @@ export class ExamResultSummaryComponent implements OnInit { this.isTestExam = this.studentExam.exam!.testExam!; this.testRunConduction = this.isTestRun && this.route.snapshot.url[3]?.toString() === 'conduction'; this.testExamConduction = this.isTestExam && !this.studentExam.submitted; - this.courseId = Number(this.route.parent?.parent?.snapshot.paramMap.get('courseId')); + this.courseId = Number(this.route.snapshot?.paramMap?.get('courseId') || this.route.parent?.parent?.snapshot.paramMap.get('courseId')); if (!this.studentExam?.id) { throw new Error('studentExam.id should be present to fetch grade info'); } diff --git a/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts b/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts deleted file mode 100644 index f9c47c383be0..000000000000 --- a/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { ExamResultSummaryComponent } from 'app/exam/participate/summary/exam-result-summary.component'; -import { ProgrammingExamSummaryComponent } from 'app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component'; -import { ModelingExamSummaryComponent } from 'app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component'; -import { FileUploadExamSummaryComponent } from 'app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component'; -import { TextExamSummaryComponent } from 'app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component'; -import { QuizExamSummaryComponent } from 'app/exam/participate/summary/exercises/quiz-exam-summary/quiz-exam-summary.component'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { ArtemisQuizQuestionTypesModule } from 'app/exercises/quiz/shared/questions/artemis-quiz-question-types.module'; -import { ArtemisModelingEditorModule } from 'app/exercises/modeling/shared/modeling-editor.module'; -import { ArtemisFullscreenModule } from 'app/shared/fullscreen/fullscreen.module'; -import { ArtemisResultModule } from 'app/exercises/shared/result/result.module'; -import { ArtemisComplaintsModule } from 'app/complaints/complaints.module'; -import { ExamGeneralInformationComponent } from 'app/exam/participate/general-information/exam-general-information.component'; -import { ExamResultOverviewComponent } from 'app/exam/participate/summary/result-overview/exam-result-overview.component'; -import { ArtemisHeaderExercisePageWithDetailsModule } from 'app/exercises/shared/exercise-headers/exercise-headers.module'; -import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; -import { ExampleSolutionComponent } from 'app/exercises/shared/example-solution/example-solution.component'; -import { ArtemisProgrammingExerciseManagementModule } from 'app/exercises/programming/manage/programming-exercise-management.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overview/grading-key-overview.module'; -import { ExamResultSummaryExerciseCardHeaderComponent } from 'app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component'; -import { ArtemisModelingParticipationModule } from 'app/exercises/modeling/participate/modeling-participation.module'; -import { ArtemisTextParticipationModule } from 'app/exercises/text/participate/text-participation.module'; -import { ArtemisFileUploadParticipationModule } from 'app/exercises/file-upload/participate/file-upload-participation.module'; -import { ArtemisFeedbackModule } from 'app/exercises/shared/feedback/feedback.module'; -import { CollapsibleCardComponent } from 'app/exam/participate/summary/collapsible-card.component'; -import { NoDataComponent } from 'app/shared/no-data-component'; - -@NgModule({ - imports: [ - RouterModule, - ArtemisSharedCommonModule, - ArtemisSharedModule, - ArtemisSharedComponentModule, - ArtemisQuizQuestionTypesModule, - ArtemisModelingEditorModule, - ArtemisFullscreenModule, - ArtemisResultModule, - ArtemisComplaintsModule, - ArtemisProgrammingExerciseManagementModule, - ArtemisHeaderExercisePageWithDetailsModule, - ArtemisMarkdownModule, - SubmissionResultStatusModule, - ArtemisExamSharedModule, - ArtemisSharedComponentModule, - GradingKeyOverviewModule, - ArtemisModelingParticipationModule, - ArtemisTextParticipationModule, - ArtemisFileUploadParticipationModule, - ArtemisFeedbackModule, - NoDataComponent, - ExamResultSummaryComponent, - ProgrammingExamSummaryComponent, - ModelingExamSummaryComponent, - FileUploadExamSummaryComponent, - TextExamSummaryComponent, - QuizExamSummaryComponent, - ExamGeneralInformationComponent, - ExamResultOverviewComponent, - ExamResultSummaryExerciseCardHeaderComponent, - ExampleSolutionComponent, - CollapsibleCardComponent, - ], - exports: [ExamResultSummaryComponent, ExamGeneralInformationComponent], -}) -export class ArtemisParticipationSummaryModule {} diff --git a/src/main/webapp/app/exam/participate/timer/exam-timer.module.ts b/src/main/webapp/app/exam/participate/timer/exam-timer.module.ts deleted file mode 100644 index 94c53564d044..000000000000 --- a/src/main/webapp/app/exam/participate/timer/exam-timer.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ExamTimerComponent } from 'app/exam/participate/timer/exam-timer.component'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; - -@NgModule({ - imports: [CommonModule, ArtemisSharedCommonModule, ExamTimerComponent], - exports: [ExamTimerComponent], -}) -export class ArtemisExamTimerModule {} diff --git a/src/main/webapp/app/exam/shared/exam-shared.module.ts b/src/main/webapp/app/exam/shared/exam-shared.module.ts deleted file mode 100644 index 68d31244d222..000000000000 --- a/src/main/webapp/app/exam/shared/exam-shared.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NgModule } from '@angular/core'; -import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { TestExamWorkingTimeComponent } from 'app/exam/shared/testExam-workingTime/test-exam-working-time.component'; -import { WorkingTimeControlComponent } from 'app/exam/shared/working-time-control/working-time-control.component'; -import { ExamLiveEventComponent } from 'app/exam/shared/events/exam-live-event.component'; -import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { WorkingTimeChangeComponent } from 'app/exam/shared/working-time-change/working-time-change.component'; - -@NgModule({ - imports: [ - ArtemisSharedCommonModule, - ArtemisMarkdownModule, - StudentExamWorkingTimeComponent, - TestExamWorkingTimeComponent, - WorkingTimeControlComponent, - WorkingTimeChangeComponent, - ExamLiveEventComponent, - ], - exports: [StudentExamWorkingTimeComponent, TestExamWorkingTimeComponent, WorkingTimeControlComponent, WorkingTimeChangeComponent, ExamLiveEventComponent], -}) -export class ArtemisExamSharedModule {} diff --git a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.html b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.html index 677106c40346..e2c6ba9e9f43 100644 --- a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.html +++ b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.html @@ -4,6 +4,7 @@ profileInfo.activeProfiles.includes(PROFILE_IRIS))), { initialValue: false }); + artemisIntelligenceActions = computed(() => + this.irisEnabled() ? [new RewriteAction(this.artemisIntelligenceService, RewritingVariant.PROBLEM_STATEMENT, this.courseId)] : [], + ); + savingInstructions = false; unsavedChangesValue = false; @@ -117,6 +148,10 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie protected readonly MarkdownEditorHeight = MarkdownEditorHeight; + ngOnInit() { + this.courseId = Number(this.activatedRoute.snapshot.paramMap.get('courseId')); + } + ngOnChanges(changes: SimpleChanges): void { if (hasExerciseChanged(changes)) { this.setupTestCaseSubscription(); @@ -236,9 +271,9 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie loadTestCasesFromTemplateParticipationResult = (templateParticipationId: number): Observable> => { // Fallback for exercises that don't have test cases yet. return this.programmingExerciseParticipationService.getLatestResultWithFeedback(templateParticipationId).pipe( - rxMap((result) => (!result?.feedbacks ? throwError(() => new Error('no result available')) : result)), + map((result) => (!result?.feedbacks ? throwError(() => new Error('no result available')) : result)), // use the text (legacy case) or the name of the provided test case attribute - rxMap(({ feedbacks }: Result) => feedbacks!.map((feedback) => feedback.text ?? feedback.testCase?.testName).sort()), + map(({ feedbacks }: Result) => feedbacks!.map((feedback) => feedback.text ?? feedback.testCase?.testName).sort()), catchError(() => of([])), ); }; diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts index c3eff649807f..0cbb2cffaff5 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts @@ -230,7 +230,7 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { ); } this.supportsAuxiliaryRepositories = - this.programmingLanguageFeatureService.getProgrammingLanguageFeature(programmingExercise.programmingLanguage).auxiliaryRepositoriesSupported ?? + this.programmingLanguageFeatureService.getProgrammingLanguageFeature(programmingExercise.programmingLanguage)?.auxiliaryRepositoriesSupported ?? false; this.localVCEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); this.localCIEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALCI); @@ -269,9 +269,8 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { .subscribe({ next: () => { this.checkAndAlertInconsistencies(); - this.plagiarismCheckSupported = this.programmingLanguageFeatureService.getProgrammingLanguageFeature( - programmingExercise.programmingLanguage, - ).plagiarismCheckSupported; + this.plagiarismCheckSupported = + this.programmingLanguageFeatureService.getProgrammingLanguageFeature(programmingExercise.programmingLanguage)?.plagiarismCheckSupported ?? false; /** we make sure to await the results of the subscriptions (switchMap) to only call {@link getExerciseDetails} once */ this.exerciseDetailSections = this.getExerciseDetails(); diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts index 732a969f49b0..6dfa8ae91c74 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.ts @@ -289,7 +289,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest const languageChanged = this.selectedProgrammingLanguageValue !== language; this.selectedProgrammingLanguageValue = language; - const programmingLanguageFeature = this.programmingLanguageFeatureService.getProgrammingLanguageFeature(language); + const programmingLanguageFeature = this.programmingLanguageFeatureService.getProgrammingLanguageFeature(language)!; this.packageNameRequired = programmingLanguageFeature?.packageNameRequired; this.staticCodeAnalysisAllowed = programmingLanguageFeature.staticCodeAnalysis; this.checkoutSolutionRepositoryAllowed = programmingLanguageFeature.checkoutSolutionRepositoryAllowed; @@ -377,7 +377,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest // update the project types for java programming exercises according to whether dependencies should be included if (this.programmingExercise.programmingLanguage === ProgrammingLanguage.JAVA) { - const programmingLanguageFeature = this.programmingLanguageFeatureService.getProgrammingLanguageFeature(ProgrammingLanguage.JAVA); + const programmingLanguageFeature = this.programmingLanguageFeatureService.getProgrammingLanguageFeature(ProgrammingLanguage.JAVA)!; if (type == ProjectType.MAVEN_BLACKBOX) { this.selectedProjectTypeValue = ProjectType.MAVEN_BLACKBOX; this.programmingExercise.projectType = ProjectType.MAVEN_BLACKBOX; diff --git a/src/main/webapp/app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service.ts b/src/main/webapp/app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service.ts index 9188c2afaea0..8fca224ebfa7 100644 --- a/src/main/webapp/app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/service/programming-language-feature/programming-language-feature.service.ts @@ -31,8 +31,8 @@ export class ProgrammingLanguageFeatureService { }); } - public getProgrammingLanguageFeature(programmingLanguage: ProgrammingLanguage): ProgrammingLanguageFeature { - return this.programmingLanguageFeatures.get(programmingLanguage)!; + public getProgrammingLanguageFeature(programmingLanguage: ProgrammingLanguage): ProgrammingLanguageFeature | undefined { + return this.programmingLanguageFeatures.get(programmingLanguage); } public supportsProgrammingLanguage(programmingLanguage: ProgrammingLanguage): boolean { diff --git a/src/main/webapp/app/faq/faq-update.component.html b/src/main/webapp/app/faq/faq-update.component.html index 51211a6bb77e..207f1d0c1221 100644 --- a/src/main/webapp/app/faq/faq-update.component.html +++ b/src/main/webapp/app/faq/faq-update.component.html @@ -21,7 +21,8 @@

diff --git a/src/main/webapp/app/faq/faq-update.component.ts b/src/main/webapp/app/faq/faq-update.component.ts index 086feb0e9fdc..ea0d03c191e9 100644 --- a/src/main/webapp/app/faq/faq-update.component.ts +++ b/src/main/webapp/app/faq/faq-update.component.ts @@ -1,7 +1,7 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, computed, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, map } from 'rxjs'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; @@ -16,6 +16,12 @@ import { ArtemisCategorySelectorModule } from 'app/shared/category-selector/cate import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { AccountService } from 'app/core/auth/account.service'; +import { RewriteAction } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_IRIS } from 'app/app.constants'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; @Component({ selector: 'jhi-faq-update', @@ -24,6 +30,15 @@ import { AccountService } from 'app/core/auth/account.service'; imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisMarkdownEditorModule, ArtemisCategorySelectorModule], }) export class FaqUpdateComponent implements OnInit { + private alertService = inject(AlertService); + private faqService = inject(FaqService); + private activatedRoute = inject(ActivatedRoute); + private navigationUtilService = inject(ArtemisNavigationUtilService); + private router = inject(Router); + private profileService = inject(ProfileService); + private accountService = inject(AccountService); + private artemisIntelligenceService = inject(ArtemisIntelligenceService); + faq: Faq; isSaving: boolean; isAllowedToSave: boolean; @@ -33,18 +48,14 @@ export class FaqUpdateComponent implements OnInit { isAtLeastInstructor = false; domainActionsDescription = [new FormulaAction()]; + irisEnabled = toSignal(this.profileService.getProfileInfo().pipe(map((profileInfo) => profileInfo.activeProfiles.includes(PROFILE_IRIS))), { initialValue: false }); + artemisIntelligenceActions = computed(() => (this.irisEnabled() ? [new RewriteAction(this.artemisIntelligenceService, RewritingVariant.FAQ, this.courseId)] : [])); + // Icons readonly faQuestionCircle = faQuestionCircle; readonly faSave = faSave; readonly faBan = faBan; - private alertService = inject(AlertService); - private faqService = inject(FaqService); - private activatedRoute = inject(ActivatedRoute); - private navigationUtilService = inject(ArtemisNavigationUtilService); - private router = inject(Router); - private accountService = inject(AccountService); - ngOnInit() { this.isSaving = false; this.courseId = Number(this.activatedRoute.snapshot.paramMap.get('courseId')); diff --git a/src/main/webapp/app/icons/icons.ts b/src/main/webapp/app/icons/icons.ts index cabc6c1d4613..700580c34027 100644 --- a/src/main/webapp/app/icons/icons.ts +++ b/src/main/webapp/app/icons/icons.ts @@ -56,9 +56,22 @@ export const facDetails: IconDefinition = { ], } as IconDefinition; +export const facArtemisIntelligence: IconDefinition = { + prefix: 'fac' as IconPrefix, + iconName: 'artemis-intelligence' as IconName, + icon: [ + 24, // SVG view box width + 24, // SVG view box height + [], + '', + 'M6.56973 16.902H7.44723C7.74423 16.902 7.96023 16.8705 8.09523 16.8075C8.23023 16.7445 8.32923 16.6005 8.39223 16.3755L9.85023 10.7865C9.88623 10.6425 9.92223 10.4985 9.95823 10.3545C9.99423 10.2105 10.0257 10.0665 10.0527 9.9225H10.1067C10.1337 10.0665 10.1652 10.2105 10.2012 10.3545C10.2372 10.4985 10.2732 10.6425 10.3092 10.7865L11.7267 16.3755C11.7897 16.6005 11.8977 16.7445 12.0507 16.8075C12.2127 16.8705 12.4242 16.902 12.6852 16.902H13.6167C13.9227 16.902 14.1252 16.866 14.2242 16.794C14.3322 16.722 14.3862 16.623 14.3862 16.497C14.3862 16.407 14.3727 16.3125 14.3457 16.2135C14.3187 16.1055 14.2827 15.993 14.2377 15.876L11.8077 8.046C11.7177 7.767 11.5962 7.5915 11.4432 7.5195C11.2992 7.4385 11.0787 7.398 10.7817 7.398H9.40473C9.11673 7.398 8.89623 7.4385 8.74323 7.5195C8.59023 7.5915 8.46873 7.767 8.37873 8.046L5.96223 15.876C5.92623 15.993 5.89473 16.1055 5.86773 16.2135C5.83173 16.3125 5.81373 16.407 5.81373 16.497C5.81373 16.623 5.86323 16.722 5.96223 16.794C6.07023 16.866 6.27273 16.902 6.56973 16.902ZM7.77123 15.2415H12.3882V13.1895H7.77123V15.2415Z M15.9695 16.902C15.5915 16.902 15.317 16.8525 15.146 16.7535C14.975 16.6455 14.8895 16.5015 14.8895 16.3215V7.9785C14.8895 7.7985 14.975 7.659 15.146 7.56C15.317 7.452 15.5915 7.398 15.9695 7.398H16.307C16.685 7.398 16.9595 7.452 17.1305 7.56C17.3015 7.659 17.387 7.7985 17.387 7.9785V16.3215C17.387 16.5015 17.3015 16.6455 17.1305 16.7535C16.9595 16.8525 16.685 16.902 16.307 16.902H15.9695Z M7.125 0C7.74844 0 8.25 0.501562 8.25 1.125V3H10.875V1.125C10.875 0.501562 11.3766 0 12 0C12.6234 0 13.125 0.501562 13.125 1.125V3H15.75V1.125C15.75 0.501562 16.2516 0 16.875 0C17.4984 0 18 0.501562 18 1.125V3C19.6547 3 21 4.34531 21 6H22.875C23.4984 6 24 6.50156 24 7.125C24 7.74844 23.4984 8.25 22.875 8.25H21V10.875H22.875C23.4984 10.875 24 11.3766 24 12C24 12.6234 23.4984 13.125 22.875 13.125H21V15.75H22.875C23.4984 15.75 24 16.2516 24 16.875C24 17.4984 23.4984 18 22.875 18H21C21 19.6547 19.6547 21 18 21V22.875C18 23.4984 17.4984 24 16.875 24C16.2516 24 15.75 23.4984 15.75 22.875V21H13.125V22.875C13.125 23.4984 12.6234 24 12 24C11.3766 24 10.875 23.4984 10.875 22.875V21H8.25V22.875C8.25 23.4984 7.74844 24 7.125 24C6.50156 24 6 23.4984 6 22.875V21C4.34531 21 3 19.6547 3 18H1.125C0.501562 18 0 17.4984 0 16.875C0 16.2516 0.501562 15.75 1.125 15.75H3V13.125H1.125C0.501562 13.125 0 12.6234 0 12C0 11.3766 0.501562 10.875 1.125 10.875H3V8.25H1.125C0.501562 8.25 0 7.74844 0 7.125C0 6.50156 0.501562 6 1.125 6H3C3 4.34531 4.34531 3 6 3V1.125C6 0.501562 6.50156 0 7.125 0ZM6.32812 5.10938C5.65503 5.10938 5.10938 5.65503 5.10938 6.32812V17.7188C5.10938 18.3918 5.65503 18.9375 6.32812 18.9375H17.7188C18.3918 18.9375 18.9375 18.3918 18.9375 17.7188V6.32812C18.9375 5.65503 18.3918 5.10938 17.7188 5.10938H6.32812Z', + ], +} as IconDefinition; + export const artemisIconPack: IconPack = { facSidebar, facSaveSuccess, facSaveWarning, facDetails, + facArtemisIntelligence, }; diff --git a/src/main/webapp/app/overview/course-exams/course-exams.module.ts b/src/main/webapp/app/overview/course-exams/course-exams.module.ts deleted file mode 100644 index 69d09384d273..000000000000 --- a/src/main/webapp/app/overview/course-exams/course-exams.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { CourseExamsComponent } from 'app/overview/course-exams/course-exams.component'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; -import { NgModule } from '@angular/core'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module'; - -@NgModule({ - imports: [ArtemisSharedModule, ArtemisSharedCommonModule, ArtemisSharedComponentModule, ArtemisExamSharedModule, ArtemisSidebarModule, CourseExamsComponent], - exports: [CourseExamsComponent], -}) -export class CourseExamsModule {} diff --git a/src/main/webapp/app/overview/courses-routing.module.ts b/src/main/webapp/app/overview/courses-routing.module.ts index 0669891b2db4..79171c01970b 100644 --- a/src/main/webapp/app/overview/courses-routing.module.ts +++ b/src/main/webapp/app/overview/courses-routing.module.ts @@ -269,7 +269,7 @@ const routes: Routes = [ }, canActivate: [UserRouteAccessService], canDeactivate: [PendingChangesGuard], - loadChildren: () => import('../exam/participate/exam-participation.module').then((m) => m.ArtemisExamParticipationModule), + loadChildren: () => import('../exam/participate/exam-participation.route').then((m) => m.examParticipationRoute), }, ], }, diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html index 9cfeb910578a..a7b15ad2903b 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.html @@ -201,6 +201,30 @@ } } + + @if (displayedActions.artemisIntelligence.length > 0) { +
+ @if (artemisIntelligenceService.isLoading()) { + + } @else { + + + } +
+ } @for (action of displayedActions.meta; track action.id) { diff --git a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts index bae7891424ab..31922986d8fc 100644 --- a/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts +++ b/src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts @@ -41,7 +41,7 @@ import { AttachmentAction } from 'app/shared/monaco-editor/model/actions/attachm import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { StrikethroughAction } from 'app/shared/monaco-editor/model/actions/strikethrough.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; -import { faAngleDown, faGripLines, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { faAngleDown, faGripLines, faQuestionCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { v4 as uuid } from 'uuid'; import { FileUploadResponse, FileUploaderService } from 'app/shared/http/file-uploader.service'; import { AlertService, AlertType } from 'app/core/util/alert.service'; @@ -70,6 +70,8 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatButton } from '@angular/material/button'; import { ArtemisTranslatePipe } from '../../pipes/artemis-translate.pipe'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; +import { facArtemisIntelligence } from 'app/icons/icons'; export enum MarkdownEditorHeight { INLINE = 125, @@ -89,6 +91,8 @@ interface MarkdownActionsByGroup { }; // Special case due to the complex structure of lectures, attachments, and their slides lecture?: LectureAttachmentReferenceAction; + // AI assistance in the editor + artemisIntelligence: TextEditorAction[]; meta: TextEditorAction[]; } @@ -140,6 +144,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie private readonly metisService = inject(MetisService, { optional: true }); private readonly fileUploaderService = inject(FileUploaderService); private readonly artemisMarkdown = inject(ArtemisMarkdownService); + protected readonly artemisIntelligenceService = inject(ArtemisIntelligenceService); @ViewChild(MonacoEditorComponent, { static: false }) monacoEditor: MonacoEditorComponent; @ViewChild('fullElement', { static: true }) fullElement: ElementRef; @@ -228,6 +233,9 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie @Input() domainActions: TextEditorDomainAction[] = []; + @Input() + artemisIntelligenceActions: TextEditorAction[] = []; + @Input() metaActions: TextEditorAction[] = [new FullscreenAction()]; @@ -271,6 +279,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie color: undefined, domain: { withoutOptions: [], withOptions: [] }, lecture: undefined, + artemisIntelligence: [], meta: [], }; @@ -295,8 +304,10 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie readonly colorPickerHeight = 110; // Icons protected readonly faQuestionCircle = faQuestionCircle; + protected readonly faSpinner = faSpinner; protected readonly faGripLines = faGripLines; protected readonly faAngleDown = faAngleDown; + protected readonly facArtemisIntelligence = facArtemisIntelligence; // Types and values exposed to the template protected readonly LectureUnitType = LectureUnitType; protected readonly ReferenceType = ReferenceType; @@ -325,6 +336,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie ) as TextEditorDomainActionWithOptions[], }, lecture: this.filterDisplayedAction(this.lectureReferenceAction), + artemisIntelligence: this.filterDisplayedActions(this.artemisIntelligenceActions ?? []), meta: this.filterDisplayedActions(this.metaActions), }; } @@ -368,6 +380,7 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie this.domainActions, ...(this.colorAction ? [this.colorAction] : []), ...(this.lectureReferenceAction ? [this.lectureReferenceAction] : []), + ...this.artemisIntelligenceActions, this.metaActions, ] .flat() diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts index 0f1456bec524..ea28aa20229e 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts @@ -232,4 +232,11 @@ export class MonacoTextEditorAdapter implements TextEditor { const modifier = MonacoTextEditorAdapter.MODIFIER_MAP.get(keybinding.getModifier()) ?? 0; return keyCode | modifier; } + + getFullText(): string { + const firstPosition = new TextEditorPosition(1, 1); + const lastPosition = this.getEndPosition(); + const range = new TextEditorRange(firstPosition, lastPosition); + return this.getTextAtRange(range); + } } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts index ff6c204b4414..c06d9a77aabe 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts @@ -53,6 +53,12 @@ export interface TextEditor { */ getTextAtRange(range: TextEditorRange): string; + /** + * Retrieves the full text of the editor. + * Line endings are normalized to '\n'. + */ + getFullText(): string; + /** * Retrieves the text of the line at the given line number in the editor. * @param lineNumber The line number to get the text from. Line numbers start at 1. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts new file mode 100644 index 000000000000..3aca4d2edbca --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts @@ -0,0 +1,66 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { AlertService } from 'app/core/util/alert.service'; + +/** + * Service providing shared functionality for Artemis Intelligence of the markdown editor. + * This service is intended to be used by the AI actions of the Monaco editors. + */ +@Injectable({ providedIn: 'root' }) +export class ArtemisIntelligenceService { + public resourceUrl = 'api/courses'; + + private http = inject(HttpClient); + private jhiWebsocketService = inject(WebsocketService); + private alertService = inject(AlertService); + + private isLoadingRewrite = signal(false); + isLoading = computed(() => this.isLoadingRewrite()); + + /** + * Triggers the rewriting pipeline via HTTP and subscribes to its WebSocket updates. + * @param toBeRewritten The text to be rewritten. + * @param rewritingVariant The variant for rewriting. + * @param courseId The ID of the course to which the rewritten text belongs. + * @return Observable that emits the rewritten text when available. + */ + rewrite(toBeRewritten: string, rewritingVariant: RewritingVariant, courseId: number): Observable { + this.isLoadingRewrite.set(true); + return new Observable((observer) => { + this.http + .post(`${this.resourceUrl}/${courseId}/rewrite-text`, { + toBeRewritten: toBeRewritten, + variant: rewritingVariant, + }) + .subscribe({ + next: () => { + const websocketTopic = `/user/topic/iris/rewriting/${courseId}`; + this.jhiWebsocketService.subscribe(websocketTopic); + this.jhiWebsocketService.receive(websocketTopic).subscribe({ + next: (update: any) => { + if (update.result) { + observer.next(update.result); + observer.complete(); + this.isLoadingRewrite.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + this.alertService.success('artemisApp.markdownEditor.artemisIntelligence.alerts.rewrite.success'); + } + }, + error: (error) => { + observer.error(error); + this.isLoadingRewrite.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + }, + }); + }, + error: (error) => { + this.isLoadingRewrite.set(false); + observer.error(error); + }, + }); + }); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts new file mode 100644 index 000000000000..73ac1525d675 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts @@ -0,0 +1,29 @@ +import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model'; +import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; + +/** + * Artemis Intelligence action for rewriting in the editor. + */ +export class RewriteAction extends TextEditorAction { + static readonly ID = 'artemisIntelligence.rewrite.action'; + + element?: HTMLElement; + + constructor( + private readonly artemisIntelligenceService: ArtemisIntelligenceService, + private readonly rewritingVariant: RewritingVariant, + private readonly courseId: number, + ) { + super(RewriteAction.ID, 'artemisApp.markdownEditor.artemisIntelligence.commands.rewrite'); + } + + /** + * Runs the rewriting of the markdown content of the editor. + * @param editor The editor in which to rewrite the markdown. + */ + run(editor: TextEditor): void { + this.rewriteMarkdown(editor, this.artemisIntelligenceService, this.rewritingVariant, this.courseId); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts new file mode 100644 index 000000000000..80ab65fddeed --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts @@ -0,0 +1,6 @@ +enum RewritingVariant { + FAQ = 'FAQ', + PROBLEM_STATEMENT = 'PROBLEM_STATEMENT', +} + +export default RewritingVariant; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts index 62e037868ce0..fe5da5e71942 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts @@ -7,6 +7,8 @@ import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/ import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-position.model'; import { TextEditorCompletionItem } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-completion-item.model'; import { TextEditorKeybinding } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-keybinding.model'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; export abstract class TextEditorAction implements Disposable { id: string; @@ -293,4 +295,22 @@ export abstract class TextEditorAction implements Disposable { } editor.layout(); } + + /** + * Rewrites the given text using Artemis Intelligence. If no text is provided, the editor's will return undefined. + * @param editor The editor to toggle the text rewriting for. + * @param artemisIntelligence The Artemis Intelligence service to use for rewriting the text. + * @param variant The variant to use for rewriting the text. + * @param courseId The ID of the course to use for rewriting the text (for tracking purposes). + */ + rewriteMarkdown(editor: TextEditor, artemisIntelligence: ArtemisIntelligenceService, variant: RewritingVariant, courseId: number): void { + const text = editor.getFullText(); + if (text) { + artemisIntelligence.rewrite(text, variant, courseId).subscribe({ + next: (message) => { + this.replaceTextAtRange(editor, new TextEditorRange(new TextEditorPosition(1, 1), this.getEndPosition(editor)), message); + }, + }); + } + } } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.module.ts b/src/main/webapp/app/shared/sidebar/sidebar.module.ts index 8f46f0f921c3..24a81c5ff50f 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.module.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.module.ts @@ -13,7 +13,6 @@ import { ArtemisSharedCommonModule } from '../shared-common.module'; import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; import { SidebarCardDirective } from 'app/shared/sidebar/sidebar-card.directive'; import { ConversationOptionsComponent } from 'app/shared/sidebar/conversation-options/conversation-options.component'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; @@ -26,7 +25,6 @@ import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-pict ArtemisSharedCommonModule, SubmissionResultStatusModule, SidebarCardDirective, - ArtemisExamSharedModule, SearchFilterComponent, ProfilePictureComponent, SidebarAccordionComponent, diff --git a/src/main/webapp/i18n/de/markdownEditor.json b/src/main/webapp/i18n/de/markdownEditor.json index d2d331be87b7..aa77392955f3 100644 --- a/src/main/webapp/i18n/de/markdownEditor.json +++ b/src/main/webapp/i18n/de/markdownEditor.json @@ -12,7 +12,18 @@ "slideNotFound": "Referenzierte Folie wurde nicht gefunden", "imageNotFound": "Referenziertes Bild wurde nicht gefunden" }, - "slideWithNumber": "Folie {{ number }}" + "slideWithNumber": "Folie {{ number }}", + "artemisIntelligence": { + "tooltip": "Artemis Intelligence", + "commands": { + "rewrite": "Umformulieren" + }, + "alerts": { + "rewrite": { + "success": "Der Text wurde umgeschrieben. Drücke Strg+Z / Cmd+Z, um den Originaltext wiederherzustellen." + } + } + } } } } diff --git a/src/main/webapp/i18n/en/markdownEditor.json b/src/main/webapp/i18n/en/markdownEditor.json index 26c9c39e7fc3..44666915a0d7 100644 --- a/src/main/webapp/i18n/en/markdownEditor.json +++ b/src/main/webapp/i18n/en/markdownEditor.json @@ -12,7 +12,18 @@ "slideNotFound": "Referenced slide was not found", "imageNotFound": "Referenced image was not found" }, - "slideWithNumber": "Slide {{ number }}" + "slideWithNumber": "Slide {{ number }}", + "artemisIntelligence": { + "tooltip": "Artemis Intelligence", + "commands": { + "rewrite": "Rewrite" + }, + "alerts": { + "rewrite": { + "success": "The text was rewritten. Press Ctrl+Z / Cmd+Z to restore the original text." + } + } + } } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/core/config/LicenseConfigurationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/config/LicenseConfigurationTest.java new file mode 100644 index 000000000000..d6ea2691f4d2 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/core/config/LicenseConfigurationTest.java @@ -0,0 +1,27 @@ +package de.tum.cit.aet.artemis.core.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class LicenseConfigurationTest { + + @Test + void testMatlabLicenseServer() { + String licenseServer = "1234@license-server"; + LicenseConfiguration licenseConfiguration = new LicenseConfiguration(new LicenseConfiguration.MatLabLicense(licenseServer)); + assertThat(licenseConfiguration.getMatlabLicenseServer()).isEqualTo(licenseServer); + } + + @Test + void testMatlabNullRecord() { + LicenseConfiguration licenseConfiguration = new LicenseConfiguration(null); + assertThat(licenseConfiguration.getMatlabLicenseServer()).isNull(); + } + + @Test + void testMatlabNullValue() { + LicenseConfiguration licenseConfiguration = new LicenseConfiguration(new LicenseConfiguration.MatLabLicense(null)); + assertThat(licenseConfiguration.getMatlabLicenseServer()).isNull(); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java index db8e032aca21..a71d07785f7b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/connector/IrisRequestMockProvider.java @@ -37,6 +37,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyExtractionPipelineExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; @Component @Profile(PROFILE_IRIS) @@ -162,6 +163,20 @@ public void mockRunCompetencyExtractionResponseAnd(Consumer executionDTOConsumer) { + // @formatter:off + mockServer + .expect(ExpectedCount.once(), requestTo(pipelinesApiURL + "/rewriting/faq/run")) + .andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisRewritingPipelineExecutionDTO.class); + executionDTOConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + // @formatter:on + } + public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/fullIngestion")).andExpect(method(HttpMethod.POST)).andRespond(request -> { var mockRequest = (MockClientHttpRequest) request; diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java index 28c42c67d205..9ed370ccf0c3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java @@ -16,6 +16,6 @@ protected int dtoAsAnnotatedRecordThreshold() { @Override protected int dtoNameEndingThreshold() { - return 4; + return 5; } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/service/LicenseServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/service/LicenseServiceTest.java new file mode 100644 index 000000000000..86621e4042d8 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/programming/service/LicenseServiceTest.java @@ -0,0 +1,51 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import de.tum.cit.aet.artemis.core.config.LicenseConfiguration; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; +import de.tum.cit.aet.artemis.programming.domain.ProjectType; +import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest; + +class LicenseServiceTest extends AbstractSpringIntegrationIndependentTest { + + @Autowired + private LicenseService licenseService; + + @Test + void testIsLicensedNoneRequired() { + boolean isLicensed = licenseService.isLicensed(ProgrammingLanguage.JAVA, ProjectType.GRADLE_GRADLE); + assertThat(isLicensed).isTrue(); + } + + @Test + void testGetLicenseNoneRequired() { + Map environment = licenseService.getEnvironment(ProgrammingLanguage.JAVA, ProjectType.GRADLE_GRADLE); + assertThat(environment).isEmpty(); + } + + @Test + void testIsLicensedMatlab() { + boolean isLicensed = licenseService.isLicensed(ProgrammingLanguage.MATLAB, null); + assertThat(isLicensed).isTrue(); + } + + @Test + void testGetLicenseMatlab() { + Map environment = licenseService.getEnvironment(ProgrammingLanguage.MATLAB, null); + assertThat(environment).containsEntry("MLM_LICENSE_FILE", "1234@license-server"); + } + + @Test + void testIsLicensedMatlabUnlicensed() { + LicenseConfiguration licenseConfiguration = new LicenseConfiguration(null); + LicenseService licenseService = new LicenseService(licenseConfiguration); + boolean isLicensed = licenseService.isLicensed(ProgrammingLanguage.MATLAB, null); + assertThat(isLicensed).isFalse(); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ArgumentSources.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ArgumentSources.java index c8b66beb167f..f7c94236f82b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/util/ArgumentSources.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ArgumentSources.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.programming.util; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.ASSEMBLER; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.MATLAB; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.OCAML; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.VHDL; @@ -14,7 +15,7 @@ public class ArgumentSources { // TODO: Add template for VHDL, Assembler, and Ocaml and activate those languages here again public static Set generateJenkinsSupportedLanguages() { - List unsupportedLanguages = List.of(VHDL, ASSEMBLER, OCAML); + List unsupportedLanguages = List.of(VHDL, ASSEMBLER, OCAML, MATLAB); var supportedLanguages = EnumSet.copyOf(ProgrammingLanguage.getEnabledLanguages()); unsupportedLanguages.forEach(supportedLanguages::remove); diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java index 2538b01761f7..83333e3e6049 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java @@ -245,7 +245,7 @@ protected static void mockDockerClient() throws InterruptedException { String dummyContainerId = "1234567890"; - // Mock dockerClient.createContainerCmd(String dockerImage).withHostConfig(HostConfig hostConfig).withEnv(String... env).withCmd(String... cmd).exec() + // Mock dockerClient.createContainerCmd(String dockerImage).withHostConfig(HostConfig hostConfig).withEnv(String... env).withEntrypoint().withCmd(String... cmd).exec() CreateContainerCmd createContainerCmd = mock(CreateContainerCmd.class); CreateContainerResponse createContainerResponse = new CreateContainerResponse(); createContainerResponse.setId(dummyContainerId); @@ -254,6 +254,7 @@ protected static void mockDockerClient() throws InterruptedException { doReturn(createContainerCmd).when(createContainerCmd).withHostConfig(any()); doReturn(createContainerCmd).when(createContainerCmd).withEnv(anyList()); doReturn(createContainerCmd).when(createContainerCmd).withUser(anyString()); + doReturn(createContainerCmd).when(createContainerCmd).withEntrypoint(); doReturn(createContainerCmd).when(createContainerCmd).withCmd(anyString(), anyString(), anyString()); doReturn(createContainerResponse).when(createContainerCmd).exec(); diff --git a/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts index c675ea69d8c7..6cf5e3ef6db6 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts @@ -12,7 +12,6 @@ import dayjs from 'dayjs/esm'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { provideRouter } from '@angular/router'; @@ -40,7 +39,7 @@ describe('ExamStartInformationComponent', () => { studentExam = { id: 1, exam, user, workingTime: 60, submitted: true } as StudentExam; return TestBed.configureTestingModule({ - imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisExamSharedModule], + imports: [ArtemisSharedModule, ArtemisSharedComponentModule], declarations: [ ExamStartInformationComponent, MockComponent(StudentExamWorkingTimeComponent), diff --git a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts index 275bd4cb4f48..df52bcd9683d 100644 --- a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts @@ -16,6 +16,8 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AlertService } from 'app/core/util/alert.service'; import { FaqCategory } from 'app/entities/faq-category.model'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; describe('FaqUpdateComponent', () => { let faqUpdateComponentFixture: ComponentFixture; @@ -36,6 +38,7 @@ describe('FaqUpdateComponent', () => { faq1.questionAnswer = 'questionAnswer'; faq1.categories = [new FaqCategory('category1', '#94a11c')]; courseId = 1; + const mockProfileInfo = { activeProfiles: ['iris'] } as ProfileInfo; TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockModule(ArtemisMarkdownEditorModule), MockModule(BrowserAnimationsModule)], declarations: [FaqUpdateComponent, MockPipe(HtmlForMarkdownPipe), MockRouterLinkDirective], @@ -73,6 +76,10 @@ describe('FaqUpdateComponent', () => { ); }, }), + + MockProvider(ProfileService, { + getProfileInfo: () => of(mockProfileInfo), + }), ], }).compileComponents(); @@ -242,4 +249,10 @@ describe('FaqUpdateComponent', () => { expect(alertServiceStub).toHaveBeenCalledOnce(); flush(); })); + + it('should handleMarkdownChange properly ', () => { + faqUpdateComponent.faq = { questionTitle: 'test1', questionAnswer: 'answer' } as Faq; + faqUpdateComponent.handleMarkdownChange('test'); + expect(faqUpdateComponent.faq.questionAnswer).toEqual('test'); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-editable-instruction.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-editable-instruction.component.spec.ts index c00bb591678c..f45afcd4c876 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-editable-instruction.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-editable-instruction.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core import { TranslateService } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; -import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { Subject, of, throwError } from 'rxjs'; import { DebugElement } from '@angular/core'; import { ArtemisTestModule } from '../../test.module'; @@ -28,6 +28,9 @@ import { HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; import { MockAlertService } from '../../helpers/mocks/service/mock-alert.service'; import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProfileInfo } from '../../../../../main/webapp/app/shared/layouts/profiles/profile-info.model'; describe('ProgrammingExerciseEditableInstructionComponent', () => { let comp: ProgrammingExerciseEditableInstructionComponent; @@ -53,6 +56,17 @@ describe('ProgrammingExerciseEditableInstructionComponent', () => { { testName: 'test3', active: false }, ]; + const mockProfileInfo = { activeProfiles: ['iris'] } as ProfileInfo; + + const route = { + snapshot: { paramMap: convertToParamMap({ courseId: '1' }) }, + url: { + pipe: () => ({ + subscribe: () => {}, + }), + }, + } as ActivatedRoute; + beforeEach(() => { return TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockDirective(NgbTooltip)], @@ -69,6 +83,10 @@ describe('ProgrammingExerciseEditableInstructionComponent', () => { { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: TranslateService, useClass: MockTranslateService }, { provide: AlertService, useClass: MockAlertService }, + { provide: ActivatedRoute, useValue: route }, + MockProvider(ProfileService, { + getProfileInfo: () => of(mockProfileInfo), + }), ], }) .compileComponents() diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 276d3e82fd7d..216be110cf91 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -84,6 +84,7 @@ describe('PostingsMarkdownEditor', () => { revealRange: jest.fn(), addCompleter: jest.fn(), addPasteListener: jest.fn(), + getFullText: jest.fn(), }; const mockPositionStrategy = { diff --git a/src/test/javascript/spec/helpers/mocks/activated-route/mock-activated-route-with-subjects.ts b/src/test/javascript/spec/helpers/mocks/activated-route/mock-activated-route-with-subjects.ts index 28ca21214cc9..2a766c7b24c2 100644 --- a/src/test/javascript/spec/helpers/mocks/activated-route/mock-activated-route-with-subjects.ts +++ b/src/test/javascript/spec/helpers/mocks/activated-route/mock-activated-route-with-subjects.ts @@ -1,10 +1,10 @@ import { Subject } from 'rxjs'; -import { Params } from '@angular/router'; +import { convertToParamMap, Params } from '@angular/router'; export class MockActivatedRouteWithSubjects { private subject = new Subject(); params = this.subject; - + snapshot = { paramMap: convertToParamMap({ courseId: '1' }) }; setSubject = (subject: Subject) => { this.params = subject; }; diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts index c67a0ed52664..13557d1d71d3 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts @@ -41,7 +41,7 @@ import { MockParticipationService } from '../../helpers/mocks/service/mock-parti import { MockProgrammingExerciseService } from '../../helpers/mocks/service/mock-programming-exercise.service'; import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../helpers/mocks/service/mock-websocket.service'; -import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; +import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { CodeEditorContainerComponent } from 'app/exercises/programming/shared/code-editor/container/code-editor-container.component'; import { IncludedInScoreBadgeComponent } from 'app/exercises/shared/exercise-headers/included-in-score-badge.component'; import { ProgrammingExerciseInstructorExerciseStatusComponent } from 'app/exercises/programming/manage/status/programming-exercise-instructor-exercise-status.component'; @@ -67,6 +67,8 @@ import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { mockCodeEditorMonacoViewChildren } from '../../helpers/mocks/mock-instance.helper'; import { REPOSITORY } from 'app/exercises/programming/manage/code-editor/code-editor-instructor-base-container.component'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; describe('CodeEditorInstructorIntegration', () => { let comp: CodeEditorInstructorAndEditorContainerComponent; @@ -90,6 +92,8 @@ describe('CodeEditorInstructorIntegration', () => { let findWithParticipationsSubject: Subject<{ body: ProgrammingExercise }>; let routeSubject: Subject; + const mockProfileInfo = { activeProfiles: ['iris'] } as ProfileInfo; + // Workaround for an error with MockComponent(). You can remove this once https://github.com/help-me-mom/ng-mocks/issues/8634 is resolved. mockCodeEditorMonacoViewChildren(); @@ -138,6 +142,9 @@ describe('CodeEditorInstructorIntegration', () => { { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: ProgrammingExerciseService, useClass: MockProgrammingExerciseService }, { provide: WebsocketService, useClass: MockWebsocketService }, + MockProvider(ProfileService, { + getProfileInfo: () => of(mockProfileInfo), + }), ], }) .compileComponents() diff --git a/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts b/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts new file mode 100644 index 000000000000..8c3635ca6e3f --- /dev/null +++ b/src/test/javascript/spec/service/artemis-intelligence.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ArtemisTestModule } from '../test.module'; + +import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; +import { BehaviorSubject } from 'rxjs'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; +import RewritingVariant from 'app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant'; + +describe('ArtemisIntelligenceService', () => { + let httpMock: HttpTestingController; + let service: ArtemisIntelligenceService; + + beforeEach(() => { + const mockWebsocketService = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + receive: jest.fn().mockReturnValue(new BehaviorSubject({ result: 'Rewritten Text' })), + }; + + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: WebsocketService, useValue: mockWebsocketService }], + }); + + httpMock = TestBed.inject(HttpTestingController); + service = TestBed.inject(ArtemisIntelligenceService); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should trigger rewriting pipeline and return rewritten text', () => { + const toBeRewritten = 'OriginalText'; + const rewritingVariant = RewritingVariant.FAQ; + const courseId = 1; + + service.rewrite(toBeRewritten, rewritingVariant, courseId).subscribe((rewrittenText) => { + expect(rewrittenText).toBe('Rewritten Text'); + }); + + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/rewrite-text`, + method: 'POST', + }); + + req.flush(null); + }); + + it('should handle HTTP error correctly', () => { + const toBeRewritten = 'OriginalText'; + const rewritingVariant = RewritingVariant.FAQ; + const courseId = 1; + + service.rewrite(toBeRewritten, rewritingVariant, courseId).subscribe({ + next: () => { + throw new Error('Expected error, but got success response'); + }, + error: (err) => { + expect(err).toBe('HTTP Request Error:'); + }, + }); + + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/rewrite-text`, + method: 'POST', + }); + + req.flush(null, { status: 400, statusText: 'Bad Request' }); + }); + }); +}); diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 7256c7457c95..43996ae2d62b 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -82,13 +82,18 @@ artemis: default: "~~invalid~~" go: default: "~~invalid~~" + matlab: + default: "~~invalid~~" ruby: default: "~~invalid~~" build-agent: short-name: "artemis-build-agent-test" telemetry: enabled: false # setting this to false will disable sending any information to the telemetry service - + licenses: + matlab: + license-server: "1234@license-server" + spring: application: name: Artemis