From 72ca6ae82aba2e424decee9871353466b44f0b1b Mon Sep 17 00:00:00 2001 From: Ege Kocabas <48245934+egekocabas@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:05:10 +0100 Subject: [PATCH 01/17] Development: Add Helios-based deployment workflow for test servers and reusable build logic (#10109) --- .github/workflows/build.yml | 192 +++++++++---------- .github/workflows/reusable-build.yml | 193 ++++++++++++++++++++ .github/workflows/testserver-deployment.yml | 165 +++++++++++++++++ 3 files changed, 445 insertions(+), 105 deletions(-) create mode 100644 .github/workflows/reusable-build.yml create mode 100644 .github/workflows/testserver-deployment.yml 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/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 From 32212f8b9639a9e17bfacf1486a4c04bdab2168f Mon Sep 17 00:00:00 2001 From: Tim Cremer <65229601+cremertim@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:28:52 +0100 Subject: [PATCH 02/17] Communication: Add Artemis intelligence rewrite action for FAQs (#10157) --- .../iris/service/IrisRewritingService.java | 97 +++++++++++++++++++ .../iris/service/pyris/PyrisDTOService.java | 4 + .../pyris/PyrisStatusUpdateService.java | 20 +++- ...petencyExtractionPipelineExecutionDTO.java | 2 +- .../dto/data/PyrisRewriteTextRequestDTO.java | 9 ++ .../PyrisRewritingPipelineExecutionDTO.java | 15 +++ .../PyrisRewritingStatusUpdateDTO.java | 21 ++++ .../pyris/dto/rewriting/RewritingVariant.java | 5 + .../iris/service/pyris/job/RewritingJob.java | 22 +++++ .../iris/web/IrisRewritingResource.java | 66 +++++++++++++ .../open/PublicPyrisStatusUpdateResource.java | 27 ++++++ .../webapp/app/faq/faq-update.component.html | 3 +- .../webapp/app/faq/faq-update.component.ts | 29 ++++-- src/main/webapp/app/icons/icons.ts | 13 +++ .../markdown-editor-monaco.component.html | 24 +++++ .../markdown-editor-monaco.component.ts | 15 ++- .../adapter/monaco-text-editor.adapter.ts | 7 ++ .../actions/adapter/text-editor.interface.ts | 6 ++ .../artemis-intelligence.service.ts | 66 +++++++++++++ .../artemis-intelligence/rewrite.action.ts | 29 ++++++ .../artemis-intelligence/rewriting-variant.ts | 6 ++ .../model/actions/text-editor-action.model.ts | 20 ++++ src/main/webapp/i18n/de/markdownEditor.json | 13 ++- src/main/webapp/i18n/en/markdownEditor.json | 13 ++- .../connector/IrisRequestMockProvider.java | 15 +++ .../IrisCodeStyleArchitectureTest.java | 2 +- .../faq/faq-update.component.spec.ts | 13 +++ ...postings-markdown-editor.component.spec.ts | 1 + .../artemis-intelligence.service.spec.ts | 75 ++++++++++++++ 29 files changed, 622 insertions(+), 16 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/IrisRewritingService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisRewriteTextRequestDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingPipelineExecutionDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/PyrisRewritingStatusUpdateDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/rewriting/RewritingVariant.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/RewritingJob.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/web/IrisRewritingResource.java create mode 100644 src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewrite.action.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/rewriting-variant.ts create mode 100644 src/test/javascript/spec/service/artemis-intelligence.service.spec.ts 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/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/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/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/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/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/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/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' }); + }); + }); +}); From f2bff3b8ebf7735f6b894079f86f1fb23eddc171 Mon Sep 17 00:00:00 2001 From: "Felix T.J. Dietrich" Date: Thu, 30 Jan 2025 16:32:31 +0100 Subject: [PATCH 03/17] Programming exercises: Add Artemis intelligence rewriting for problem statement (#10156) --- ...ercise-editable-instruction.component.html | 1 + ...exercise-editable-instruction.component.ts | 45 ++++++++++++++++--- ...ise-editable-instruction.component.spec.ts | 20 ++++++++- .../mock-activated-route-with-subjects.ts | 4 +- ...code-editor-instructor.integration.spec.ts | 9 +++- 5 files changed, 70 insertions(+), 9 deletions(-) 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/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/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() From 76be7b3d0dfaf27ee4398a67991bac33378a522f Mon Sep 17 00:00:00 2001 From: Michal Kawka <73854755+coolchock@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:46:09 +0100 Subject: [PATCH 04/17] Development: Remove Angular client modules in exam mode (#10169) --- src/main/webapp/app/app.routes.ts | 2 +- .../course/manage/course-management.route.ts | 2 +- .../app/exam/exam-scores/exam-scores.route.ts | 25 --- .../app/exam/manage/exam-management.module.ts | 142 ------------------ .../app/exam/manage/exam-management.route.ts | 18 +-- .../manage/exams/exam-detail.component.ts | 4 +- .../exam-mode-picker.module.ts | 10 -- .../students-upload-images.module.ts | 11 -- .../events/exam-live-events.module.ts | 13 -- .../exam-bar/exam-bar.component.ts | 6 +- .../exam-navigation-bar.module.ts | 12 -- .../exam-participation.component.ts | 10 +- .../participate/exam-participation.module.ts | 73 --------- .../participate/exam-participation.route.ts | 9 -- .../exam-start-information.component.ts | 4 +- ...exam-exercise-update-highlighter.module.ts | 9 -- .../exam-submission-components.module.ts | 46 ------ .../summary/exam-result-summary.component.ts | 2 +- .../summary/exam-result-summary.module.ts | 71 --------- .../participate/timer/exam-timer.module.ts | 10 -- .../app/exam/shared/exam-shared.module.ts | 22 --- .../course-exams/course-exams.module.ts | 13 -- .../app/overview/courses-routing.module.ts | 2 +- .../app/shared/sidebar/sidebar.module.ts | 2 - .../exam-start-information.component.spec.ts | 3 +- 25 files changed, 28 insertions(+), 493 deletions(-) delete mode 100644 src/main/webapp/app/exam/exam-scores/exam-scores.route.ts delete mode 100644 src/main/webapp/app/exam/manage/exam-management.module.ts delete mode 100644 src/main/webapp/app/exam/manage/exams/exam-mode-picker/exam-mode-picker.module.ts delete mode 100644 src/main/webapp/app/exam/manage/students/upload-images/students-upload-images.module.ts delete mode 100644 src/main/webapp/app/exam/participate/events/exam-live-events.module.ts delete mode 100644 src/main/webapp/app/exam/participate/exam-navigation-bar/exam-navigation-bar.module.ts delete mode 100644 src/main/webapp/app/exam/participate/exam-participation.module.ts delete mode 100644 src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts delete mode 100644 src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts delete mode 100644 src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts delete mode 100644 src/main/webapp/app/exam/participate/timer/exam-timer.module.ts delete mode 100644 src/main/webapp/app/exam/shared/exam-shared.module.ts delete mode 100644 src/main/webapp/app/overview/course-exams/course-exams.module.ts 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/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/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/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/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), From 1179759e47ee10a8327a4348be754e2843cac49e Mon Sep 17 00:00:00 2001 From: Ole Vester <73833780+ole-ve@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:58:41 +0100 Subject: [PATCH 05/17] Development: Run GitHub coverage summary step only on success in default test pipeline (#10186) --- .github/workflows/test.yml | 2 +- gradle/jacoco.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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/**" ] } From 623907b1748883b80f12af65f3d8e71bf5211240 Mon Sep 17 00:00:00 2001 From: Marcel Gaupp Date: Thu, 30 Jan 2025 21:05:43 +0100 Subject: [PATCH 06/17] Programming exercises: Add MATLAB programming exercise template (#10039) --- .../programming-exercise-features.inc | 4 ++ .../de/tum/cit/aet/artemis/ArtemisApp.java | 3 +- .../service/BuildJobContainerService.java | 2 +- .../core/config/LicenseConfiguration.java | 30 +++++++++ .../domain/ProgrammingLanguage.java | 1 + .../programming/service/LicenseService.java | 60 ++++++++++++++++++ ...ProgrammingExerciseBuildConfigService.java | 63 ++++++++++++++++--- .../service/ProgrammingExerciseService.java | 2 +- .../ProgrammingLanguageFeatureService.java | 58 ++++++++++++++--- .../service/TemplateUpgradePolicyService.java | 4 +- .../ci/ContinuousIntegrationService.java | 9 +-- ...abCIProgrammingLanguageFeatureService.java | 13 +++- ...kinsProgrammingLanguageFeatureService.java | 13 +++- ...alCIProgrammingLanguageFeatureService.java | 15 ++++- src/main/resources/config/application.yml | 2 + .../templates/aeolus/matlab/default.sh | 20 ++++++ .../templates/aeolus/matlab/default.yaml | 18 ++++++ .../templates/matlab/exercise/.gitattributes | 28 +++++++++ .../templates/matlab/exercise/.gitignore | 36 +++++++++++ .../matlab/exercise/averageGradeByStudent.m | 3 + .../templates/matlab/exercise/finalGrade.m | 3 + .../matlab/exercise/medianGradeByAssignment.m | 3 + src/main/resources/templates/matlab/readme | 49 +++++++++++++++ .../templates/matlab/solution/.gitattributes | 28 +++++++++ .../templates/matlab/solution/.gitignore | 36 +++++++++++ .../matlab/solution/averageGradeByStudent.m | 3 + .../templates/matlab/solution/finalGrade.m | 3 + .../matlab/solution/medianGradeByAssignment.m | 3 + .../templates/matlab/test/.gitattributes | 28 +++++++++ .../templates/matlab/test/.gitignore | 39 ++++++++++++ .../templates/matlab/test/testRunner.m | 12 ++++ .../templates/matlab/test/tests/GradeTest.m | 45 +++++++++++++ .../programming/programming-exercise.model.ts | 1 + .../programming-exercise-detail.component.ts | 7 +-- .../programming-exercise-update.component.ts | 4 +- .../programming-language-feature.service.ts | 4 +- .../core/config/LicenseConfigurationTest.java | 27 ++++++++ .../service/LicenseServiceTest.java | 51 +++++++++++++++ .../programming/util/ArgumentSources.java | 3 +- ...ctSpringIntegrationLocalCILocalVCTest.java | 3 +- src/test/resources/config/application.yml | 7 ++- 41 files changed, 704 insertions(+), 39 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/core/config/LicenseConfiguration.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/service/LicenseService.java create mode 100644 src/main/resources/templates/aeolus/matlab/default.sh create mode 100644 src/main/resources/templates/aeolus/matlab/default.yaml create mode 100644 src/main/resources/templates/matlab/exercise/.gitattributes create mode 100644 src/main/resources/templates/matlab/exercise/.gitignore create mode 100644 src/main/resources/templates/matlab/exercise/averageGradeByStudent.m create mode 100644 src/main/resources/templates/matlab/exercise/finalGrade.m create mode 100644 src/main/resources/templates/matlab/exercise/medianGradeByAssignment.m create mode 100644 src/main/resources/templates/matlab/readme create mode 100644 src/main/resources/templates/matlab/solution/.gitattributes create mode 100644 src/main/resources/templates/matlab/solution/.gitignore create mode 100644 src/main/resources/templates/matlab/solution/averageGradeByStudent.m create mode 100644 src/main/resources/templates/matlab/solution/finalGrade.m create mode 100644 src/main/resources/templates/matlab/solution/medianGradeByAssignment.m create mode 100644 src/main/resources/templates/matlab/test/.gitattributes create mode 100644 src/main/resources/templates/matlab/test/.gitignore create mode 100644 src/main/resources/templates/matlab/test/testRunner.m create mode 100644 src/main/resources/templates/matlab/test/tests/GradeTest.m create mode 100644 src/test/java/de/tum/cit/aet/artemis/core/config/LicenseConfigurationTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/programming/service/LicenseServiceTest.java diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index 2c29ff79a1f4..4c3fea5bf56e 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 | + +----------------------+----------+---------+ - Not all ``templates`` support the same feature set and supported features can also change depending on the continuous integration system setup. Depending on the feature set, some options might not be available during the creation of the programming exercise. @@ -95,6 +97,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 | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ - *Sequential Test Runs*: ``Artemis`` can generate a build plan which first executes structural and then behavioral tests. This feature can help students to better concentrate on the immediate challenge at hand. - *Static Code Analysis*: ``Artemis`` can generate a build plan which additionally executes static code analysis tools. 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/programming/domain/ProgrammingLanguage.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java index 0c4894182e1e..fe6a36d2eed9 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 9dd3249bd1a4..d066241c7211 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 -> + case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, MATLAB -> defaultRepositoryUpgradeService; - case SQL, MATLAB, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); + case SQL, RUBY, 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 3f14c1b2a015..c03b64a6022c 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,8 +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 -> "assignment"; - case SQL, MATLAB, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); + case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, MATLAB -> + "assignment"; + case SQL, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } }, @@ -230,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 -> "tests"; - case SQL, MATLAB, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); + case C, VHDL, ASSEMBLER, OCAML, C_SHARP, GO, BASH, MATLAB -> "tests"; + case SQL, RUBY, 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 ec4a7dd3598a..eb8a47580c43 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 @@ -25,11 +25,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; @@ -37,8 +41,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)); @@ -56,5 +66,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 e9c7e13b8659..41cdabd90bda 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; @@ -27,11 +28,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; @@ -42,8 +47,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)); @@ -56,6 +67,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)); @@ -63,5 +75,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 e9aebc1980b1..5e4b7713fe50 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" # The following properties are used to configure the Artemis build agent. # The build agent is responsible for executing the buildJob to test student submissions. 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/entities/programming/programming-exercise.model.ts b/src/main/webapp/app/entities/programming/programming-exercise.model.ts index ece9e6d6989c..8b5e1b0136b3 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/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/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/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/resources/config/application.yml b/src/test/resources/config/application.yml index 869514c89759..2eca321307c2 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -82,11 +82,16 @@ artemis: default: "~~invalid~~" go: default: "~~invalid~~" + matlab: + 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 From 706679340f531fdb04f97337cfae370299e83d43 Mon Sep 17 00:00:00 2001 From: Marcel Gaupp Date: Thu, 30 Jan 2025 21:46:46 +0100 Subject: [PATCH 07/17] Programming exercises: Add Ruby programming exercise template (#10202) --- .../programming-exercise-features.inc | 4 + .../domain/ProgrammingLanguage.java | 1 + .../service/TemplateUpgradePolicyService.java | 4 +- .../ci/ContinuousIntegrationService.java | 8 +- ...kinsProgrammingLanguageFeatureService.java | 2 + .../build_plan/JenkinsBuildPlanService.java | 4 +- ...alCIProgrammingLanguageFeatureService.java | 2 + src/main/resources/config/application.yml | 2 + .../templates/aeolus/ruby/default.sh | 14 +++ .../templates/aeolus/ruby/default.yaml | 13 +++ .../jenkins/ruby/regularRuns/pipeline.groovy | 55 ++++++++++++ .../templates/ruby/exercise/.gitignore | 8 ++ .../resources/templates/ruby/exercise/Gemfile | 5 ++ .../templates/ruby/exercise/Gemfile.lock | 14 +++ .../ruby/exercise/src/bubble_sort.rb | 11 +++ .../templates/ruby/exercise/src/client.rb | 66 ++++++++++++++ .../templates/ruby/exercise/src/merge_sort.rb | 11 +++ src/main/resources/templates/ruby/readme | 89 +++++++++++++++++++ .../templates/ruby/solution/.gitignore | 8 ++ .../resources/templates/ruby/solution/Gemfile | 5 ++ .../templates/ruby/solution/Gemfile.lock | 14 +++ .../ruby/solution/src/bubble_sort.rb | 13 +++ .../templates/ruby/solution/src/client.rb | 64 +++++++++++++ .../templates/ruby/solution/src/context.rb | 13 +++ .../templates/ruby/solution/src/merge_sort.rb | 58 ++++++++++++ .../templates/ruby/solution/src/policy.rb | 24 +++++ .../resources/templates/ruby/test/.gitignore | 11 +++ .../resources/templates/ruby/test/Gemfile | 10 +++ .../templates/ruby/test/Gemfile.lock | 25 ++++++ .../resources/templates/ruby/test/Rakefile | 16 ++++ .../templates/ruby/test/assignment_path.rb | 3 + .../templates/ruby/test/test/test_behavior.rb | 64 +++++++++++++ .../templates/ruby/test/test/test_helper.rb | 12 +++ .../ruby/test/test/test_structural.rb | 31 +++++++ .../programming/programming-exercise.model.ts | 1 + src/test/resources/config/application.yml | 2 + 36 files changed, 679 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/templates/aeolus/ruby/default.sh create mode 100644 src/main/resources/templates/aeolus/ruby/default.yaml create mode 100644 src/main/resources/templates/jenkins/ruby/regularRuns/pipeline.groovy create mode 100644 src/main/resources/templates/ruby/exercise/.gitignore create mode 100644 src/main/resources/templates/ruby/exercise/Gemfile create mode 100644 src/main/resources/templates/ruby/exercise/Gemfile.lock create mode 100644 src/main/resources/templates/ruby/exercise/src/bubble_sort.rb create mode 100755 src/main/resources/templates/ruby/exercise/src/client.rb create mode 100644 src/main/resources/templates/ruby/exercise/src/merge_sort.rb create mode 100644 src/main/resources/templates/ruby/readme create mode 100644 src/main/resources/templates/ruby/solution/.gitignore create mode 100644 src/main/resources/templates/ruby/solution/Gemfile create mode 100644 src/main/resources/templates/ruby/solution/Gemfile.lock create mode 100644 src/main/resources/templates/ruby/solution/src/bubble_sort.rb create mode 100755 src/main/resources/templates/ruby/solution/src/client.rb create mode 100644 src/main/resources/templates/ruby/solution/src/context.rb create mode 100644 src/main/resources/templates/ruby/solution/src/merge_sort.rb create mode 100644 src/main/resources/templates/ruby/solution/src/policy.rb create mode 100644 src/main/resources/templates/ruby/test/.gitignore create mode 100644 src/main/resources/templates/ruby/test/Gemfile create mode 100644 src/main/resources/templates/ruby/test/Gemfile.lock create mode 100644 src/main/resources/templates/ruby/test/Rakefile create mode 100644 src/main/resources/templates/ruby/test/assignment_path.rb create mode 100644 src/main/resources/templates/ruby/test/test/test_behavior.rb create mode 100644 src/main/resources/templates/ruby/test/test/test_helper.rb create mode 100644 src/main/resources/templates/ruby/test/test/test_structural.rb diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index 4c3fea5bf56e..8ba8ed672b07 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -51,6 +51,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | MATLAB | yes | no | +----------------------+----------+---------+ + | Ruby | yes | yes | + +----------------------+----------+---------+ - Not all ``templates`` support the same feature set and supported features can also change depending on the continuous integration system setup. Depending on the feature set, some options might not be available during the creation of the programming exercise. @@ -99,6 +101,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ | MATLAB | no | no | no | no | n/a | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ + | Ruby | no | no | no | no | n/a | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+------------------------+ - *Sequential Test Runs*: ``Artemis`` can generate a build plan which first executes structural and then behavioral tests. This feature can help students to better concentrate on the immediate challenge at hand. - *Static Code Analysis*: ``Artemis`` can generate a build plan which additionally executes static code analysis tools. 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 fe6a36d2eed9..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 @@ -52,6 +52,7 @@ public enum ProgrammingLanguage { OCAML, PYTHON, R, + RUBY, RUST, SWIFT, TYPESCRIPT, 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 d066241c7211..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, MATLAB -> + 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, RUBY, 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 c03b64a6022c..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, MATLAB -> + 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, RUBY, 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, MATLAB -> "tests"; - case SQL, RUBY, 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/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java index eb8a47580c43..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 @@ -13,6 +13,7 @@ import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUBY; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.TYPESCRIPT; @@ -62,6 +63,7 @@ protected Map getSupportedProgr programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, true, false, true, true, false, List.of(), false)); programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false)); programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false)); + programmingLanguageFeatures.put(RUBY, new ProgrammingLanguageFeature(RUBY, false, false, false, false, false, List.of(), false)); programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false)); // Jenkins is not supporting XCODE at the moment programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, true, true, true, false, List.of(PLAIN), false)); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java index 970ffb719c51..b639d6ae34d2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java @@ -172,8 +172,8 @@ private JenkinsXmlConfigBuilder builderFor(ProgrammingLanguage programmingLangua throw new UnsupportedOperationException("Xcode templates are not available for Jenkins."); } return switch (programmingLanguage) { - case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH -> jenkinsBuildPlanCreator; - case VHDL, ASSEMBLER, OCAML, SQL, MATLAB, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT, C_SHARP, GO, BASH, RUBY -> jenkinsBuildPlanCreator; + case VHDL, ASSEMBLER, OCAML, SQL, MATLAB, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException(programmingLanguage + " templates are not available for Jenkins."); }; } 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 41cdabd90bda..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 @@ -16,6 +16,7 @@ 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; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUBY; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.TYPESCRIPT; @@ -71,6 +72,7 @@ protected Map getSupportedProgr 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)); + programmingLanguageFeatures.put(RUBY, new ProgrammingLanguageFeature(RUBY, false, false, false, false, false, List.of(), true)); programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), true)); 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)); diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 5e4b7713fe50..04634254d4ac 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -107,6 +107,8 @@ artemis: 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" # The following properties are used to configure the Artemis build agent. # The build agent is responsible for executing the buildJob to test student submissions. diff --git a/src/main/resources/templates/aeolus/ruby/default.sh b/src/main/resources/templates/aeolus/ruby/default.sh new file mode 100644 index 000000000000..eca5b3e5156c --- /dev/null +++ b/src/main/resources/templates/aeolus/ruby/default.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +test () { + echo '⚙️ executing test' + cd "${testWorkingDirectory}" + bundler exec rake ci:test +} + +main () { + test +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/ruby/default.yaml b/src/main/resources/templates/aeolus/ruby/default.yaml new file mode 100644 index 000000000000..bd32a925c8e4 --- /dev/null +++ b/src/main/resources/templates/aeolus/ruby/default.yaml @@ -0,0 +1,13 @@ +api: v0.0.1 +metadata: + name: "Ruby" + id: ruby +actions: + - name: test + script: |- + cd "${testWorkingDirectory}" + bundler exec rake ci:test + results: + - name: Minitest Test Results + path: "${testWorkingDirectory}/report.xml" + type: junit diff --git a/src/main/resources/templates/jenkins/ruby/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/ruby/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..b3afb23cdf64 --- /dev/null +++ b/src/main/resources/templates/jenkins/ruby/regularRuns/pipeline.groovy @@ -0,0 +1,55 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Test') { + sh ''' + cd tests + bundler exec rake ci:test + ''' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + rm -rf results + mkdir results + cp tests/report.xml $WORKSPACE/results/ || true + sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/ruby/exercise/.gitignore b/src/main/resources/templates/ruby/exercise/.gitignore new file mode 100644 index 000000000000..9106b2a345b0 --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/src/main/resources/templates/ruby/exercise/Gemfile b/src/main/resources/templates/ruby/exercise/Gemfile new file mode 100644 index 000000000000..dba00cea4087 --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "date", "~> 3.4" diff --git a/src/main/resources/templates/ruby/exercise/Gemfile.lock b/src/main/resources/templates/ruby/exercise/Gemfile.lock new file mode 100644 index 000000000000..af596da80a54 --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/Gemfile.lock @@ -0,0 +1,14 @@ +GEM + remote: https://rubygems.org/ + specs: + date (3.4.1) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + date (~> 3.4) + +BUNDLED WITH + 2.6.2 diff --git a/src/main/resources/templates/ruby/exercise/src/bubble_sort.rb b/src/main/resources/templates/ruby/exercise/src/bubble_sort.rb new file mode 100644 index 000000000000..5b4f9744ba04 --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/src/bubble_sort.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class BubbleSort + # Sorts dates with BubbleSort. + # @param input [Array] the Array of Dates to be sorted + def perform_sort(input) + # TODO: implement + + raise(NotImplementedError) + end +end diff --git a/src/main/resources/templates/ruby/exercise/src/client.rb b/src/main/resources/templates/ruby/exercise/src/client.rb new file mode 100755 index 000000000000..697afa7fc52c --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/src/client.rb @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "date" + +module Client + # TODO: Implement BubbleSort + # TODO: Implement MergeSort + + # TODO: Create and implement a Context class according to the UML class diagram + # TODO: Create and implement a Policy class as described in the problem statement + + ITERATIONS = 10 + DATES_LENGTH_MIN = 5 + DATES_LENGTH_MAX = 15 + + # Main method. + # Add code to demonstrate your implementation here. + def self.main + # TODO: Init Context and Policy + + # Run multiple times to simulate different sorting strategies + ITERATIONS.times do + dates = create_random_dates + + # TODO: Configure context + + print("Unsorted Array of dates: ") + print_dates(dates) + + # TODO: Sort dates + + print("Sorted Array of dates: ") + print_dates(dates) + end + end + + # Generates a List of random Date objects with random List size between + # DATES_LENGTH_MIN and DATES_LENGTH_MAX. + # @return [Array] an Array of random Date objects. + def self.create_random_dates + dates_length = Random.rand(DATES_LENGTH_MIN..DATES_LENGTH_MAX) + + lowest_date = Date.new(2024, 9, 15) + highest_date = Date.new(2025, 1, 15) + + Array.new(dates_length) { random_date_within(lowest_date, highest_date) } + end + + # Creates a random Date within the given range. + # @param low [Date] the lower bound + # @param high [Date] the upper bound + # @return [Date] a random Date within the given range + def self.random_date_within(low, high) + random_jd = Random.rand(low.jd..high.jd) + Date.jd(random_jd) + end + + # Prints out the given Array of Date objects. + # @param dates [Array] list of the dates to print + def self.print_dates(dates) + puts(dates.join(", ")) + end +end + +Client.main diff --git a/src/main/resources/templates/ruby/exercise/src/merge_sort.rb b/src/main/resources/templates/ruby/exercise/src/merge_sort.rb new file mode 100644 index 000000000000..25a227ed89c3 --- /dev/null +++ b/src/main/resources/templates/ruby/exercise/src/merge_sort.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MergeSort + # Sorts dates with MergeSort. + # @param input [Array] the Array of Dates to be sorted + def perform_sort(input) + # TODO: implement + + raise(NotImplementedError) + end +end diff --git a/src/main/resources/templates/ruby/readme b/src/main/resources/templates/ruby/readme new file mode 100644 index 000000000000..ee6e5c451431 --- /dev/null +++ b/src/main/resources/templates/ruby/readme @@ -0,0 +1,89 @@ +# Sorting with the Strategy Pattern + +In this exercise, we want to implement sorting algorithms and choose them based on runtime specific variables. + +### Part 1: Sorting + +First, we need to implement two sorting algorithms, in this case `MergeSort` and `BubbleSort`. + +**You have the following tasks:** + +1. [task][Implement Bubble Sort](test_bubble_sort_sorts) +Implement the method `perform_sort(input: Array[Date])` in the class `BubbleSort`. Make sure to follow the Bubble Sort algorithm exactly. + +2. [task][Implement Merge Sort](test_merge_sort_sorts) +Implement the method `perform_sort(input: Array[Date])` in the class `MergeSort`. Make sure to follow the Merge Sort algorithm exactly. + +### Part 2: Strategy Pattern + +We want the application to apply different algorithms for sorting an `Array` of `Date` objects. +Use the strategy pattern to select the right sorting algorithm at runtime. + +**You have the following tasks:** + +1. [task][Context Class](test_context_structure) +Create and implement a `Context` class in `context.rb` following the class diagram below. +Add read and write accessors for the attributes and associations. + +2. [task][Context Policy](test_policy_structure) +Create and implement a `Policy` class in `policy.rb` following the class diagram below. +Add read and write accessors for the attributes and associations. +`Policy` should implement a simple configuration mechanism: + + 1. [task][Select MergeSort](test_use_merge_sort_for_big_list) + Select `MergeSort` when the Array has more than 10 dates. + + 2. [task][Select BubbleSort](test_use_bubble_sort_for_small_list) + Select `BubbleSort` when the Array has less or equal 10 dates. + +3. Complete the `Client` class which demonstrates switching between two strategies at runtime. + +@startuml + +class Client { +} + +class Policy ##testsColor(test_policy_structure) { + +configure() +} + +class Context ##testsColor(test_context_structure) { + -dates: Array[Date] + +sort() +} + +interface SortStrategy { + +perform_sort(input: Array[Date]) +} + +class BubbleSort { + +perform_sort(input: Array[Date]) +} + +class MergeSort { + +perform_sort(input: Array[Date]) +} + +MergeSort -up-|> SortStrategy +BubbleSort -up-|> SortStrategy +Policy -right-> Context #testsColor(test_policy_structure): context +Context -right-> SortStrategy #testsColor(test_context_structure): sort_algorithm +Client .down.> Policy +Client .down.> Context + +hide empty fields +hide empty methods + +@enduml + + +### Part 3: Optional Challenges + +(These are not tested) + +1. Create a new class `QuickSort` and implement the Quick Sort algorithm. + +2. Make the method `perform_sort(input: Array[Date])` not depend on `Date` objects, so that other objects can also be sorted by the same method. +**Hint:** Have a look at the module `Comparable`. + +3. Think about a useful decision in `Policy` when to use the new `QuickSort` algorithm. diff --git a/src/main/resources/templates/ruby/solution/.gitignore b/src/main/resources/templates/ruby/solution/.gitignore new file mode 100644 index 000000000000..9106b2a345b0 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/src/main/resources/templates/ruby/solution/Gemfile b/src/main/resources/templates/ruby/solution/Gemfile new file mode 100644 index 000000000000..dba00cea4087 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "date", "~> 3.4" diff --git a/src/main/resources/templates/ruby/solution/Gemfile.lock b/src/main/resources/templates/ruby/solution/Gemfile.lock new file mode 100644 index 000000000000..af596da80a54 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/Gemfile.lock @@ -0,0 +1,14 @@ +GEM + remote: https://rubygems.org/ + specs: + date (3.4.1) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + date (~> 3.4) + +BUNDLED WITH + 2.6.2 diff --git a/src/main/resources/templates/ruby/solution/src/bubble_sort.rb b/src/main/resources/templates/ruby/solution/src/bubble_sort.rb new file mode 100644 index 000000000000..ae9a7b5b8da5 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/src/bubble_sort.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class BubbleSort + # Sorts dates with BubbleSort. + # @param input [Array] the Array of Dates to be sorted + def perform_sort(input) + (input.length - 1).downto(0) do |i| + (0...i).each do |j| + input[j], input[j + 1] = input[j + 1], input[j] if input[j] > input[j + 1] + end + end + end +end diff --git a/src/main/resources/templates/ruby/solution/src/client.rb b/src/main/resources/templates/ruby/solution/src/client.rb new file mode 100755 index 000000000000..695fbc348da9 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/src/client.rb @@ -0,0 +1,64 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "date" + +require_relative "context" +require_relative "policy" + +module Client + ITERATIONS = 10 + DATES_LENGTH_MIN = 5 + DATES_LENGTH_MAX = 15 + + # Main method. + # Add code to demonstrate your implementation here. + def self.main + context = Context.new + policy = Policy.new(context) + + ITERATIONS.times do + dates = create_random_dates + + context.dates = dates + policy.configure + + print("Unsorted Array of dates: ") + print_dates(dates) + + context.sort + + print("Sorted Array of dates: ") + print_dates(dates) + end + end + + # Generates a List of random Date objects with random List size between + # DATES_LENGTH_MIN and DATES_LENGTH_MAX. + # @return [Array] an Array of random Date objects. + def self.create_random_dates + dates_length = Random.rand(DATES_LENGTH_MIN..DATES_LENGTH_MAX) + + lowest_date = Date.new(2024, 9, 15) + highest_date = Date.new(2025, 1, 15) + + Array.new(dates_length) { random_date_within(lowest_date, highest_date) } + end + + # Creates a random Date within the given range. + # @param low [Date] the lower bound + # @param high [Date] the upper bound + # @return [Date] a random Date within the given range + def self.random_date_within(low, high) + random_jd = Random.rand(low.jd..high.jd) + Date.jd(random_jd) + end + + # Prints out the given Array of Date objects. + # @param dates [Array] list of the dates to print + def self.print_dates(dates) + puts(dates.join(", ")) + end +end + +Client.main diff --git a/src/main/resources/templates/ruby/solution/src/context.rb b/src/main/resources/templates/ruby/solution/src/context.rb new file mode 100644 index 000000000000..09a51bdc6861 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/src/context.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Context + attr_accessor :dates, :sort_algorithm + + # Runs the configured sort algorithm. + def sort + raise "sort_algorithm not set" if @sort_algorithm.nil? + raise "dates not set" if @dates.nil? + + @sort_algorithm.perform_sort(@dates) + end +end diff --git a/src/main/resources/templates/ruby/solution/src/merge_sort.rb b/src/main/resources/templates/ruby/solution/src/merge_sort.rb new file mode 100644 index 000000000000..7e2b70286d46 --- /dev/null +++ b/src/main/resources/templates/ruby/solution/src/merge_sort.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class MergeSort + # Wrapper method for the real MergeSort algorithm. + # @param input [Array] the Array of Dates to be sorted + def perform_sort(input) + mergesort(input, 0, input.length) + end + + private + + # Recursive merge sort method + def mergesort(input, low, high) + return if high - low <= 1 + + mid = (low + high) / 2 + + mergesort(input, low, mid) + mergesort(input, mid, high) + merge(input, low, mid, high) + end + + # Merge method + def merge(input, low, middle, high) + temp = Array.new(high - low) + + left_index = low + right_index = middle + whole_index = 0 + + while left_index < middle && right_index < high + if input[left_index] <= input[right_index] + temp[whole_index] = input[left_index] + left_index += 1 + else + temp[whole_index] = input[right_index] + right_index += 1 + end + whole_index += 1 + end + + while left_index < middle + temp[whole_index] = input[left_index] + left_index += 1 + whole_index += 1 + end + + while right_index < high + temp[whole_index] = input[right_index] + right_index += 1 + whole_index += 1 + end + + temp.each_with_index do |value, index| + input[low + index] = value + end + end +end diff --git a/src/main/resources/templates/ruby/solution/src/policy.rb b/src/main/resources/templates/ruby/solution/src/policy.rb new file mode 100644 index 000000000000..c8ba20b4105c --- /dev/null +++ b/src/main/resources/templates/ruby/solution/src/policy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "bubble_sort" +require_relative "merge_sort" + +class Policy + attr_accessor :context + + DATES_SIZE_THRESHOLD = 10 + + def initialize(context) + @context = context + end + + # Chooses a strategy depending on the number of date objects. + def configure + sort_algorithm = if @context.dates.length > DATES_SIZE_THRESHOLD + MergeSort.new + else + BubbleSort.new + end + @context.sort_algorithm = sort_algorithm + end +end diff --git a/src/main/resources/templates/ruby/test/.gitignore b/src/main/resources/templates/ruby/test/.gitignore new file mode 100644 index 000000000000..f7edf22658b3 --- /dev/null +++ b/src/main/resources/templates/ruby/test/.gitignore @@ -0,0 +1,11 @@ +/report.xml +/rubocop.sarif + +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/src/main/resources/templates/ruby/test/Gemfile b/src/main/resources/templates/ruby/test/Gemfile new file mode 100644 index 000000000000..98ac822b9f28 --- /dev/null +++ b/src/main/resources/templates/ruby/test/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "rake", "~> 13.0" + +gem "minitest", "~> 5.16" +gem "minitest-junit", "~> 2.1" + +gem "date", "~> 3.4" diff --git a/src/main/resources/templates/ruby/test/Gemfile.lock b/src/main/resources/templates/ruby/test/Gemfile.lock new file mode 100644 index 000000000000..761adfe3c6f4 --- /dev/null +++ b/src/main/resources/templates/ruby/test/Gemfile.lock @@ -0,0 +1,25 @@ +GEM + remote: https://rubygems.org/ + specs: + bigdecimal (3.1.9) + date (3.4.1) + minitest (5.25.4) + minitest-junit (2.1.0) + minitest (~> 5.11) + ox (~> 2, >= 2.14.2) + ox (2.14.20) + bigdecimal (>= 3.0) + rake (13.2.1) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + date (~> 3.4) + minitest (~> 5.16) + minitest-junit (~> 2.1) + rake (~> 13.0) + +BUNDLED WITH + 2.6.2 diff --git a/src/main/resources/templates/ruby/test/Rakefile b/src/main/resources/templates/ruby/test/Rakefile new file mode 100644 index 000000000000..1066ffc918ca --- /dev/null +++ b/src/main/resources/templates/ruby/test/Rakefile @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative "assignment_path" + +require "minitest/test_task" + +Minitest::TestTask.create :test + +namespace :ci do + Minitest::TestTask.create :test do |task| + task.extra_args = ["--verbose", "--junit", "--junit-jenkins", "--junit-filename=report.xml"] + end +end + +task default: %i[test] +task ci: %i[ci:test] diff --git a/src/main/resources/templates/ruby/test/assignment_path.rb b/src/main/resources/templates/ruby/test/assignment_path.rb new file mode 100644 index 000000000000..55e8c0b163ab --- /dev/null +++ b/src/main/resources/templates/ruby/test/assignment_path.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ASSIGNMENT_PATH = File.expand_path("../${studentParentWorkingDirectoryName}", __dir__) diff --git a/src/main/resources/templates/ruby/test/test/test_behavior.rb b/src/main/resources/templates/ruby/test/test/test_behavior.rb new file mode 100644 index 000000000000..0c76fcf42b74 --- /dev/null +++ b/src/main/resources/templates/ruby/test/test/test_behavior.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +require "date" + +class TestBehavior < Minitest::Test + def setup + @dates = [ + Date.new(2018, 11, 8), + Date.new(2017, 4, 15), + Date.new(2016, 2, 15), + Date.new(2017, 9, 15) + ] + @ordered_dates = [ + Date.new(2016, 2, 15), + Date.new(2017, 4, 15), + Date.new(2017, 9, 15), + Date.new(2018, 11, 8) + ] + end + + def test_bubble_sort_sorts + require "bubble_sort" + + bubble_sort = BubbleSort.new + bubble_sort.perform_sort(@dates) + assert_equal(@ordered_dates, @dates, "dates were not sorted") + end + + def test_merge_sort_sorts + require "merge_sort" + + merge_sort = MergeSort.new + merge_sort.perform_sort(@dates) + assert_equal(@ordered_dates, @dates, "dates were not sorted") + end + + def test_use_merge_sort_for_big_list + require "context" + require "policy" + require "bubble_sort" + + dates = Array.new(11, 0) + context = Context.new + context.dates = dates + policy = Policy.new(context) + policy.configure + assert(context.sort_algorithm.instance_of?(MergeSort), "selected algorithm was not MergeSort") + end + + def test_use_bubble_sort_for_small_list + require "context" + require "policy" + require "merge_sort" + + dates = Array.new(3, 0) + context = Context.new + context.dates = dates + policy = Policy.new(context) + policy.configure + assert(context.sort_algorithm.instance_of?(BubbleSort), "selected algorithm was not BubbleSort") + end +end diff --git a/src/main/resources/templates/ruby/test/test/test_helper.rb b/src/main/resources/templates/ruby/test/test/test_helper.rb new file mode 100644 index 000000000000..8ab2ec04aeb9 --- /dev/null +++ b/src/main/resources/templates/ruby/test/test/test_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative "../assignment_path" + +$LOAD_PATH.unshift File.join(ASSIGNMENT_PATH, "src") + +require "minitest/autorun" + +# exit successfully on failure +Minitest.after_run do + exit 0 +end diff --git a/src/main/resources/templates/ruby/test/test/test_structural.rb b/src/main/resources/templates/ruby/test/test/test_structural.rb new file mode 100644 index 000000000000..417522f29efe --- /dev/null +++ b/src/main/resources/templates/ruby/test/test/test_structural.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class TestStructural < Minitest::Test + def test_context_structure + require "context" + + assert(defined?(Context), "Context is not defined") + assert(Context.instance_of?(Class), "Context is not a class") + assert_equal([], Context.instance_method(:initialize).parameters, + "The constructor of Context does not have the expected parameters") + assert(Context.public_method_defined?(:dates), "Context has not attribute reader for 'dates'") + assert(Context.public_method_defined?(:dates=), "Context has not attribute writer for 'dates'") + assert(Context.public_method_defined?(:sort_algorithm), "Context has not attribute reader for 'sort_algorithm'") + assert(Context.public_method_defined?(:sort_algorithm=), "Context has not attribute writer for 'sort_algorithm'") + assert(Context.public_method_defined?(:sort), "Context has no method 'sort'") + end + + def test_policy_structure + require "policy" + + assert(defined?(Policy), "Policy is not defined") + assert(Policy.instance_of?(Class), "Policy is not a class") + assert_equal([%i[req context]], Policy.instance_method(:initialize).parameters, + "The constructor of Policy does not have the expected parameters") + assert(Policy.public_method_defined?(:context), "Policy has not attribute reader for 'context'") + assert(Policy.public_method_defined?(:context=), "Policy has not attribute writer for 'context'") + assert(Policy.public_method_defined?(:configure), "Policy has no method 'configure'") + end +end 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 8b5e1b0136b3..b07d272ff26a 100644 --- a/src/main/webapp/app/entities/programming/programming-exercise.model.ts +++ b/src/main/webapp/app/entities/programming/programming-exercise.model.ts @@ -27,6 +27,7 @@ export enum ProgrammingLanguage { OCAML = 'OCAML', PYTHON = 'PYTHON', R = 'R', + RUBY = 'RUBY', RUST = 'RUST', SWIFT = 'SWIFT', TYPESCRIPT = 'TYPESCRIPT', diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 2eca321307c2..43996ae2d62b 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -84,6 +84,8 @@ artemis: default: "~~invalid~~" matlab: default: "~~invalid~~" + ruby: + default: "~~invalid~~" build-agent: short-name: "artemis-build-agent-test" telemetry: From 7d4f9dcae736d0fbd8793a798d4a51ca92f60923 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 31 Jan 2025 08:23:07 +0100 Subject: [PATCH 08/17] Development: Update test coverage --- gradle/jacoco.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index a76db0977c6e..d8089206e76f 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -42,8 +42,8 @@ ext { "CLASS": 1 ], "iris" : [ - "INSTRUCTION": 0.792, - "CLASS": 17 + "INSTRUCTION": 0.775, + "CLASS": 22 ], "lecture" : [ "INSTRUCTION": 0.867, From 6d861a9755a7700df4c3358e295ad19dd85022c3 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 31 Jan 2025 08:28:55 +0100 Subject: [PATCH 09/17] Development: Update client dependencies --- package-lock.json | 539 +++++++++++++++++++++++----------------------- package.json | 42 ++-- 2 files changed, 287 insertions(+), 294 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed48679b10de..044c2ebf8d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,18 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "19.1.3", - "@angular/cdk": "19.1.1", - "@angular/common": "19.1.3", - "@angular/compiler": "19.1.3", - "@angular/core": "19.1.3", - "@angular/forms": "19.1.3", - "@angular/localize": "19.1.3", - "@angular/material": "19.1.1", - "@angular/platform-browser": "19.1.3", - "@angular/platform-browser-dynamic": "19.1.3", - "@angular/router": "19.1.3", - "@angular/service-worker": "19.1.3", + "@angular/animations": "19.1.4", + "@angular/cdk": "19.1.2", + "@angular/common": "19.1.4", + "@angular/compiler": "19.1.4", + "@angular/core": "19.1.4", + "@angular/forms": "19.1.4", + "@angular/localize": "19.1.4", + "@angular/material": "19.1.2", + "@angular/platform-browser": "19.1.4", + "@angular/platform-browser-dynamic": "19.1.4", + "@angular/router": "19.1.4", + "@angular/service-worker": "19.1.4", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "19.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -33,7 +33,7 @@ "@ng-bootstrap/ng-bootstrap": "18.0.0", "@ngx-translate/core": "16.0.4", "@ngx-translate/http-loader": "16.0.1", - "@sentry/angular": "8.51.0", + "@sentry/angular": "8.52.1", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "21.1.3", "@swimlane/ngx-graph": "9.0.1", @@ -45,7 +45,7 @@ "crypto-js": "4.2.0", "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", - "dompurify": "3.2.3", + "dompurify": "3.2.4", "emoji-js": "3.8.1", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -65,7 +65,7 @@ "ngx-infinite-scroll": "19.0.0", "ngx-webstorage": "19.0.1", "pako": "2.1.0", - "papaparse": "5.5.1", + "papaparse": "5.5.2", "pdf-lib": "1.17.1", "pdfjs-dist": "4.10.38", "rxjs": "7.8.1", @@ -89,11 +89,11 @@ "@angular-eslint/eslint-plugin-template": "19.0.2", "@angular-eslint/schematics": "19.0.2", "@angular-eslint/template-parser": "19.0.2", - "@angular/build": "19.1.4", - "@angular/cli": "19.1.4", - "@angular/compiler-cli": "19.1.3", - "@angular/language-service": "19.1.3", - "@sentry/types": "8.51.0", + "@angular/build": "19.1.5", + "@angular/cli": "19.1.5", + "@angular/compiler-cli": "19.1.4", + "@angular/language-service": "19.1.4", + "@sentry/types": "8.52.1", "@testing-library/angular": "17.3.5", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.7", @@ -102,7 +102,7 @@ "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/pako": "2.0.3", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", @@ -287,13 +287,13 @@ "license": "MIT" }, "node_modules/@angular-devkit/architect": { - "version": "0.1901.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1901.4.tgz", - "integrity": "sha512-EoRTN8p7z0YnqOEIJKKu/NwSsCJxFkyGuZOobz7btnUWwlDqG8CNAhJgtlsOXPihwEkHEkzRIm1feDkWEjCYsA==", + "version": "0.1901.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1901.5.tgz", + "integrity": "sha512-zlRudZx34FkFZnSdaQCjxDleHwbQYNLdBFcLi+FBwt0UXqxmhbEIasK3l/3kCOC3QledrjUzVXgouji+OZ/WGQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.4", + "@angular-devkit/core": "19.1.5", "rxjs": "7.8.1" }, "engines": { @@ -303,18 +303,18 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.1.4.tgz", - "integrity": "sha512-t8qC26Boz1aAMt2xVKthwEXRqMI4ZVwelxRNfHryLdLTujTaehFt3qbjxukMmRGCWmQObauH0UOvDh3pAA24dQ==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.1.5.tgz", + "integrity": "sha512-ny7ktNOTxaEi6cS3V6XFP5bbJkgiMt3OUNUYLdfdbv4y6wolVlPVHKl+wb4xs6tgbnmx63+e6zGpoDMCRytgcg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1901.4", - "@angular-devkit/build-webpack": "0.1901.4", - "@angular-devkit/core": "19.1.4", - "@angular/build": "19.1.4", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/build-webpack": "0.1901.5", + "@angular-devkit/core": "19.1.5", + "@angular/build": "19.1.5", "@babel/core": "7.26.0", "@babel/generator": "7.26.3", "@babel/helper-annotate-as-pure": "7.25.9", @@ -325,7 +325,7 @@ "@babel/preset-env": "7.26.0", "@babel/runtime": "7.26.0", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.1.4", + "@ngtools/webpack": "19.1.5", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -379,7 +379,7 @@ "@angular/localize": "^19.0.0", "@angular/platform-server": "^19.0.0", "@angular/service-worker": "^19.0.0", - "@angular/ssr": "^19.1.4", + "@angular/ssr": "^19.1.5", "@web/test-runner": "^0.19.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -387,7 +387,7 @@ "karma": "^6.3.0", "ng-packagr": "^19.0.0", "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "typescript": ">=5.5 <5.8" }, "peerDependenciesMeta": { @@ -452,14 +452,14 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1901.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1901.4.tgz", - "integrity": "sha512-C/Cd1JeRTy2P/powIldc5UZObw92TDGATD/LFlfPfi94celLa2DlEL1ybPTpnGs/R5/q5R26F6fbhmAVSeTJ8g==", + "version": "0.1901.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1901.5.tgz", + "integrity": "sha512-UxEoF7F8L1GpH/N4me7VGe5ZPfxIiVHyhw5/ck3rcVbT6YD22/GYFGSJRGYP+D7LLTJ7OOQvfD6Bc/q62HhWvA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/architect": "0.1901.4", + "@angular-devkit/architect": "0.1901.5", "rxjs": "7.8.1" }, "engines": { @@ -473,9 +473,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.4.tgz", - "integrity": "sha512-IDvSSiQgaixH2RtZtIpq1+XaHeuzMiTWfDyNF9DuYcU+S8CdG1SWrc8d59tmOrM/q+IRGyFgbBhTU1un52hNHw==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.5.tgz", + "integrity": "sha512-wGKV+i5mCM/Hd/3CsdrIYcVi5G2Wg/D5941bUDXivrbsqHfKVINxAkI3OI1eaD90VnAL8ICrQEoAhh6ni2Umkg==", "dev": true, "license": "MIT", "dependencies": { @@ -501,13 +501,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.4.tgz", - "integrity": "sha512-EKXBkx6EDcvyO+U68w/eXicRaF92zSSzYNvR3tMZszEKYE6xBr3kZxY99PP54HXQHR4zYwLvFJVp+T6bnvte2w==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.5.tgz", + "integrity": "sha512-8QjOlO2CktcTT0TWcaABea2xSePxoPKaZu96+6gc8oZzj/y8DbdGiO9mRvIac9+m4hiZI41Cqm1W+yMsCzYMkA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.4", + "@angular-devkit/core": "19.1.5", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -623,9 +623,9 @@ } }, "node_modules/@angular/animations": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.1.3.tgz", - "integrity": "sha512-MI+Tbp9OOisrQtTQH7o+xiQCODXicCs8WHNpGzdCpnXdRkQuVSOb6xAjD9OXJqcQGotLgeyennnkIJGXdz4RTA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.1.4.tgz", + "integrity": "sha512-QGswsf/X+k7TijIgBzL6V8+KcArFAgebY6zM0L/Len8v5PNzPzdjJH99+P++5AOLiJctYKfISUwnlMbDb50NrA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -634,18 +634,19 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.3" + "@angular/core": "19.1.4" } }, "node_modules/@angular/build": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.1.4.tgz", - "integrity": "sha512-yfvLeUT2a8JTuVBY259vsSv0uLyhikHHgQcWa3VSr0TvCKrwCsBIFDq7vqmhLqIVWi/Z4D7n3J5JQAbDrl38Sg==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.1.5.tgz", + "integrity": "sha512-byoHcv0/s6WGWap59s43N/eC+4NsviuTnGoj+iR0ayubk8snn6jdkZLbFDfnTuQlTiu4ok8/XcksjzeMkgGyyw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1901.4", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/core": "19.1.5", "@babel/core": "7.26.0", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -667,7 +668,7 @@ "rollup": "4.30.1", "sass": "1.83.1", "semver": "7.6.3", - "vite": "6.0.7", + "vite": "6.0.11", "watchpack": "2.4.2" }, "engines": { @@ -684,11 +685,11 @@ "@angular/localize": "^19.0.0", "@angular/platform-server": "^19.0.0", "@angular/service-worker": "^19.0.0", - "@angular/ssr": "^19.1.4", + "@angular/ssr": "^19.1.5", "less": "^4.2.0", "ng-packagr": "^19.0.0", "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "typescript": ">=5.5 <5.8" }, "peerDependenciesMeta": { @@ -740,9 +741,9 @@ } }, "node_modules/@angular/cdk": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.1.1.tgz", - "integrity": "sha512-MmfNB9iANuDN1TS+HL8uKqA3/7pdVeCRN+HdAcfqFrcqZmSUUSlYWy8PXqymmyeXxoSwt9p4I/6R0By03VoCMw==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.1.2.tgz", + "integrity": "sha512-rzrZ4BkGNIZWSdw0OsuSB/H9UB5ppPvmBq+uRHdYmZoYjo5wu1pmePxAIZDIBR8xdaNy9rZ4ecS6IebDkgYPrg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -757,18 +758,18 @@ } }, "node_modules/@angular/cli": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.1.4.tgz", - "integrity": "sha512-C1Z2OTLjUJIkLsay6RJ1rzY0Tdb1Mj/cBh9dZryDstuits8G0Tphe36hnLownnoHspFQfjSRtVzF4NwKiDlQRw==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.1.5.tgz", + "integrity": "sha512-bedjH3jUcrLgN3GOTTuvjbPcY3Lm0YcYBVY35S1ugI88UK6nbtttiRdgK++Qk2Q8wbg6zuaBAr4ACbfPMsnRaA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1901.4", - "@angular-devkit/core": "19.1.4", - "@angular-devkit/schematics": "19.1.4", + "@angular-devkit/architect": "0.1901.5", + "@angular-devkit/core": "19.1.5", + "@angular-devkit/schematics": "19.1.5", "@inquirer/prompts": "7.2.1", "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.1.4", + "@schematics/angular": "19.1.5", "@yarnpkg/lockfile": "1.1.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", @@ -791,9 +792,9 @@ } }, "node_modules/@angular/common": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.1.3.tgz", - "integrity": "sha512-r1P0W6FKrON83szIJboF8z6UNCVL4HIxyD+nhmHMMT/iJpu4kDHVugaN/+w2jYLb4oelAJK5xzkzA+1IaHpzLg==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.1.4.tgz", + "integrity": "sha512-E4MCl13VIotOxmzKQ/UGciPeaRXQgH7ymesEjYVGcT8jmC+qz5dEcoN7L5Jvq9aUsmLBt9MFp/B5QqKCIXMqYA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -802,14 +803,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.3", + "@angular/core": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.1.3.tgz", - "integrity": "sha512-omX5Gyt3zlJVTUteO2YxsqYWtAIpkvs8kRYSUsLTi79V1gbGo+J1TawFuyBTrWxj4UtTGvwmDgZxiCIwMtP5KQ==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.1.4.tgz", + "integrity": "sha512-9vGUZ+QhGWvf5dfeILybrh5rvZQtNqS8WumMeX2/vCb0JTA0N4DsL1Sy47HuWcgKBxbmHVUdF5/iufcFaqk2FA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -818,7 +819,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.1.3" + "@angular/core": "19.1.4" }, "peerDependenciesMeta": { "@angular/core": { @@ -827,9 +828,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.1.3.tgz", - "integrity": "sha512-nDBvZenQECcr9CClmTp3iJNilRQ6oDKFgBkhlWffEFBx0Z6kBA36MXKKLuCkf31D+NGmt5VJlAkl8Ax8BJ9qJw==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.1.4.tgz", + "integrity": "sha512-ozJvTUzPOgFqlz69YnV14Ncod+iH0cXZvUKerjw8o+JsixLG2LmJpwQ79Gh4a/ZQmAkAxMAYYK5izCiio8MmTg==", "license": "MIT", "dependencies": { "@babel/core": "7.26.0", @@ -850,14 +851,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "19.1.3", + "@angular/compiler": "19.1.4", "typescript": ">=5.5 <5.8" } }, "node_modules/@angular/core": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.1.3.tgz", - "integrity": "sha512-Hh1eHvi+y+gsTRODiEEEWnRj5zqv9WNoou1KmQ1mv1NTOf0Pv61Hg9P2rBWDr0mPIXFSzqUKjyzW30BgdQ+AEA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.1.4.tgz", + "integrity": "sha512-r3T81lM9evmuW36HA3VAxIJ61M8kirGR8yHoln9fXSnYG8UeJ7JlWEbVRHmVHKOB48VK0bS/VxqN+w9TOq3bZg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -871,9 +872,9 @@ } }, "node_modules/@angular/forms": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.1.3.tgz", - "integrity": "sha512-M6eEJBysJm9zSUhm8ggljZCsgHLccZl70P34tyddb8erh9it2uoOXW0aVaZgDt1UAiF5a1EzjdVdN4TZTT/OGA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.1.4.tgz", + "integrity": "sha512-dcf4G+vXrfvy5NAP+C4A2rBeaZuwKs/TeWjZDpkRUPQMwTvDJcSNH+pqOeVsYUGNY2BkY1uPjzmgZh4F5NMQ9A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -882,16 +883,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.3", - "@angular/core": "19.1.3", - "@angular/platform-browser": "19.1.3", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-19.1.3.tgz", - "integrity": "sha512-6A1Y2j7Qz85GzxKy0C+JFIQaUNoURNR3L6FNvJIfI73ADl74NBy+M+MzZTBlhlfyB3TEGyExZmuV6wHtB/hU+w==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-19.1.4.tgz", + "integrity": "sha512-4W6dlBvukL3b7BnGiMM5cPGx3rAAVhBNicfNHX6hXCkz26AV0VFIbfrt/8GRSFmsDYZEOhXvhAy8dxHQCtyCqA==", "dev": true, "license": "MIT", "engines": { @@ -899,9 +900,9 @@ } }, "node_modules/@angular/localize": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.1.3.tgz", - "integrity": "sha512-MrDZG3A5Wk9l5AXt9Y5a7FYM608SqqnhWfnWPlkBU8HwE/IEOKY1QDIRLDoUZ1RchEFpI7QX46sqTEIGc5V+hA==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.1.4.tgz", + "integrity": "sha512-AFfaxnGUWl1QZGmhYNTH8adWynSqjNwHweOUQ/ItIQ+MkbIPOpAtZp+ar6SRJZpatR59O8797jPKVFTAebLvLQ==", "license": "MIT", "dependencies": { "@babel/core": "7.26.0", @@ -918,21 +919,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "19.1.3", - "@angular/compiler-cli": "19.1.3" + "@angular/compiler": "19.1.4", + "@angular/compiler-cli": "19.1.4" } }, "node_modules/@angular/material": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.1.1.tgz", - "integrity": "sha512-x/EwyBx3yCIYyu/hve19eecmufJzwltRWOO/3Y74jY4jSNNFrR9046t0ptw4fyEXjN8UQZI6Fp/melcZxl3IiA==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.1.2.tgz", + "integrity": "sha512-MdczdTmvsXLtEFV9nTgFs9mPM1FSAz4L1kByJCdIgHtOu62e9t2XcSUqOPJTlDrA5wWafZvPWh2woJfJr4iwjw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^19.0.0 || ^20.0.0", - "@angular/cdk": "19.1.1", + "@angular/cdk": "19.1.2", "@angular/common": "^19.0.0 || ^20.0.0", "@angular/core": "^19.0.0 || ^20.0.0", "@angular/forms": "^19.0.0 || ^20.0.0", @@ -941,9 +942,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.1.3.tgz", - "integrity": "sha512-bLgnM2hRyzUdoWRoUhe+IMenlr74EvrgwyG7anJ27bjg5PcvhQPXrGqU0hri5yPDb9SHVJZminr7OjNCN8QJkQ==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.1.4.tgz", + "integrity": "sha512-IoVIvemj7ni6GLDCvwtZhTgMQjPyG+xPW7rASN2RVl9T3uS1fJUpXrh5JzBcCikIj20O2KV9mqt7p4iIXy9jbQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -952,9 +953,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "19.1.3", - "@angular/common": "19.1.3", - "@angular/core": "19.1.3" + "@angular/animations": "19.1.4", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4" }, "peerDependenciesMeta": { "@angular/animations": { @@ -963,9 +964,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.1.3.tgz", - "integrity": "sha512-rfsHu/+wB8YLPjsHKd/Go0SI8zP2gjMkebUHM9SbvVLXEAkxFubcF2htVKbKu8eTncfEJEXD6+3gRAjh5SLrKw==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.1.4.tgz", + "integrity": "sha512-r1AM8qkjl63cg46tgOHsVV4URHDctcVpt98DU/d/yN8JAugrx6GA1qOM/HMDspMjEIU4aYcSkUUY6h6uIkYmOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -974,16 +975,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.3", - "@angular/compiler": "19.1.3", - "@angular/core": "19.1.3", - "@angular/platform-browser": "19.1.3" + "@angular/common": "19.1.4", + "@angular/compiler": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4" } }, "node_modules/@angular/router": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.1.3.tgz", - "integrity": "sha512-DJ9BgvtxJV6xohaPQXPdBsFCZoQIEq2OPDyKcoW4L0ST4kIIFpHyI6wJ+AlPnLkhSwmOOoHciH0oxZ2xPVxmiQ==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.1.4.tgz", + "integrity": "sha512-0gEhGGqcCS7adKuv/XeQjRbhEqRXPhIH4ygjwfonV+uvmK+C1sf+bnAt4o01hxwf12w4FcnNPkgBKt+rJJ+LpA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -992,16 +993,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.3", - "@angular/core": "19.1.3", - "@angular/platform-browser": "19.1.3", + "@angular/common": "19.1.4", + "@angular/core": "19.1.4", + "@angular/platform-browser": "19.1.4", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "19.1.3", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.1.3.tgz", - "integrity": "sha512-87XPbzqy9sGYkN21y4vqJBT6lMuuQJrkx5ID1pXdDGxUXlfiGprshtbLPNCUcxHXOnLOdnAI2WZ5DDjl4NMNpw==", + "version": "19.1.4", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.1.4.tgz", + "integrity": "sha512-g4hGvmsVyYbCg/8iFzqi2r4gwOopkYqdTk0NXk+o39OoD2p679uUQ9rZd9oJs1EUMXn08Bk5jhIA/yiaonp3mw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1013,8 +1014,8 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.1.3", - "@angular/core": "19.1.3" + "@angular/core": "19.1.4", + "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -3853,15 +3854,15 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.6.tgz", - "integrity": "sha512-PgP35JfmGjHU0LSXOyRew0zHuA9N6OJwOlos1fZ20b7j8ISeAdib3L+n0jIxBtX958UeEpte6xhG/gxJ5iUqMw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.7.tgz", + "integrity": "sha512-lyoF4uYdBBTnqeB1gjPdYkiQ++fz/iYKaP9DON1ZGlldkvAEJsjaOBRdbl5UW1pOSslBRd701jxhAG0MlhHd2w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/figures": "^1.0.9", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -3890,19 +3891,18 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.4.tgz", - "integrity": "sha512-5y4/PUJVnRb4bwWY67KLdebWOhOc7xj5IP2J80oWXa64mVag24rwQ1VAdnj7/eDY/odhguW0zQ1Mp1pj6fO/2w==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.5.tgz", + "integrity": "sha512-/vyCWhET0ktav/mUeBqJRYTwmjFPIKPRYb3COAw7qORULgipGSUO2vL32lQKki3UxDKJ8BvuEbokaoyCA6YlWw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.9", - "@inquirer/type": "^3.0.2", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, @@ -3911,14 +3911,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.3.tgz", - "integrity": "sha512-S9KnIOJuTZpb9upeRSBBhoDZv7aSV3pG9TECrBj0f+ZsFwccz886hzKBrChGrXMJwd4NKY+pOA9Vy72uqnd6Eg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.4.tgz", + "integrity": "sha512-S8b6+K9PLzxiFGGc02m4syhEu5JsH0BukzRsuZ+tpjJ5aDsDX1WfNfOil2fmsO36Y1RMcpJGxlfQ1yh4WfU28Q==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3", "external-editor": "^3.1.0" }, "engines": { @@ -3929,14 +3929,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.6.tgz", - "integrity": "sha512-TRTfi1mv1GeIZGyi9PQmvAaH65ZlG4/FACq6wSzs7Vvf1z5dnNWsAAXBjWMHt76l+1hUY8teIqJFrWBk5N6gsg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.7.tgz", + "integrity": "sha512-PsUQ5t7r+DPjW0VVEHzssOTBM2UPHnvBNse7hzuki7f6ekRL94drjjfBLrGEDe7cgj3pguufy/cuFwMeWUWHXw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -3947,9 +3947,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.9.tgz", - "integrity": "sha512-BXvGj0ehzrngHTPTDqUoDT3NXL8U0RxUk2zJm2A66RhCEIWdtU1v6GuUqNAgArW4PQ9CinqIWyHdQgdwOj06zQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", + "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", "dev": true, "license": "MIT", "engines": { @@ -3957,14 +3957,14 @@ } }, "node_modules/@inquirer/input": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.3.tgz", - "integrity": "sha512-zeo++6f7hxaEe7OjtMzdGZPHiawsfmCZxWB9X1NpmYgbeoyerIbWemvlBxxl+sQIlHC0WuSAG19ibMq3gbhaqQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.4.tgz", + "integrity": "sha512-CKKF8otRBdIaVnRxkFLs00VNA9HWlEh3x4SqUfC3A8819TeOZpTYG/p+4Nqu3hh97G+A0lxkOZNYE7KISgU8BA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2" + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3" }, "engines": { "node": ">=18" @@ -3974,14 +3974,14 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.6.tgz", - "integrity": "sha512-xO07lftUHk1rs1gR0KbqB+LJPhkUNkyzV/KhH+937hdkMazmAYHLm1OIrNKpPelppeV1FgWrgFDjdUD8mM+XUg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.7.tgz", + "integrity": "sha512-uU2nmXGC0kD8+BLgwZqcgBD1jcw2XFww2GmtP6b4504DkOp+fFAhydt7JzRR1TAI2dmj175p4SZB0lxVssNreA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2" + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3" }, "engines": { "node": ">=18" @@ -3991,14 +3991,14 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.6.tgz", - "integrity": "sha512-QLF0HmMpHZPPMp10WGXh6F+ZPvzWE7LX6rNoccdktv/Rov0B+0f+eyXkAcgqy5cH9V+WSpbLxu2lo3ysEVK91w==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.7.tgz", + "integrity": "sha512-DFpqWLx+C5GV5zeFWuxwDYaeYnTWYphO07pQ2VnP403RIqRIpwBG0ATWf7pF+3IDbaXEtWatCJWxyDrJ+rkj2A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2" }, "engines": { @@ -4034,14 +4034,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.6.tgz", - "integrity": "sha512-QoE4s1SsIPx27FO4L1b1mUjVcoHm1pWE/oCmm4z/Hl+V1Aw5IXl8FYYzGmfXaBT0l/sWr49XmNSiq7kg3Kd/Lg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.7.tgz", + "integrity": "sha512-ZeBca+JCCtEIwQMvhuROT6rgFQWWvAImdQmIIP3XoyDFjrp2E0gZlEn65sWIoR6pP2EatYK96pvx0887OATWQQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/type": "^3.0.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -4052,15 +4052,15 @@ } }, "node_modules/@inquirer/search": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.6.tgz", - "integrity": "sha512-eFZ2hiAq0bZcFPuFFBmZEtXU1EarHLigE+ENCtpO+37NHCl4+Yokq1P/d09kUblObaikwfo97w+0FtG/EXl5Ng==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.7.tgz", + "integrity": "sha512-Krq925SDoLh9AWSNee8mbSIysgyWtcPnSAp5YtPBGCQ+OCO+5KGC8FwLpyxl8wZ2YAov/8Tp21stTRK/fw5SGg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/figures": "^1.0.9", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -4071,15 +4071,15 @@ } }, "node_modules/@inquirer/select": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.6.tgz", - "integrity": "sha512-yANzIiNZ8fhMm4NORm+a74+KFYHmf7BZphSOBovIzYPVLquseTGEkU5l2UTnBOf5k0VLmTgPighNDLE9QtbViQ==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.7.tgz", + "integrity": "sha512-ejGBMDSD+Iqk60u5t0Zf2UQhGlJWDM78Ep70XpNufIfc+f4VOTeybYKXu9pDjz87FkRzLiVsGpQG2SzuGlhaJw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.4", - "@inquirer/figures": "^1.0.9", - "@inquirer/type": "^3.0.2", + "@inquirer/core": "^10.1.5", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -4091,9 +4091,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.2.tgz", - "integrity": "sha512-ZhQ4TvhwHZF+lGhQ2O/rsjo80XoZR5/5qhOY3t6FJuX5XBg5Be8YzYTvaUGJnc12AUGI2nr4QSUE4PhKSigx7g==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.3.tgz", + "integrity": "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg==", "dev": true, "license": "MIT", "engines": { @@ -5624,9 +5624,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.1.4.tgz", - "integrity": "sha512-ZmUlbVqu/pz8abxVxNCKgKeY5g2MX1NsKxhM8rRV5tVV/MaAtSYNHgmFSYcKWA178v7k6BUuhnoNNxl5qqc1kw==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.1.5.tgz", + "integrity": "sha512-oIpE5Ci/Gl2iZqa0Hs6IOxaXEDHkF/zisHcflzYGkMnYcSFj+wRgYEuBFaHLCwuxQf9OdGu31i05w849i6tY1Q==", "dev": true, "license": "MIT", "peer": true, @@ -5900,9 +5900,9 @@ } }, "node_modules/@npmcli/redact": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.0.0.tgz", - "integrity": "sha512-/1uFzjVcfzqrgCeGW7+SZ4hv0qLWmKXVzFahZGJ6QuJBj6Myt9s17+JL86i76NV9YSnJRcGXJYQbAU0rn1YTCQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", "dev": true, "license": "ISC", "engines": { @@ -6673,14 +6673,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.1.4.tgz", - "integrity": "sha512-HFf83SoXbj1K4jkYSSfCg/oXkmSGBx0zG1Lh+dE5GZFdTQmykrBY519aSdrqLVyZzKYjTGfDfSewUeO4a0GE2A==", + "version": "19.1.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.1.5.tgz", + "integrity": "sha512-Yks2QD87z2qJhVLi6O0tQDBG4pyX5n5c8BYEyZ+yiThjzIXBRkHjWS1jIFvd/y1+yU/NQFHYG/sy8sVOxfQ9IA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.4", - "@angular-devkit/schematics": "19.1.4", + "@angular-devkit/core": "19.1.5", + "@angular-devkit/schematics": "19.1.5", "jsonc-parser": "3.3.1" }, "engines": { @@ -6690,63 +6690,63 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.51.0.tgz", - "integrity": "sha512-r94yfRK17zNJER0hgQE4qOSy5pWzsnFcGTJQSqhSEKUcC4KK37qSfoPrPejFxtIqXhqlkd/dTWKvrMwXWcn0MQ==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.52.1.tgz", + "integrity": "sha512-+GXnlJCPWxNkneojLFFdfF8rt7nQ1BIRctdZx6JneQRahC9hJ0hHR4WnIa47iB7d+3hJiJWmfe7I+k+6rMuoPA==", "license": "MIT", "dependencies": { - "@sentry/core": "8.51.0" + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.51.0.tgz", - "integrity": "sha512-VgfxSZWLYUPKDnkt2zG+Oe5ccv8U3WPM6Mo4kfABIJT3Ai4VbZB7+vb2a4pm6lUCF9DeOPXHb5o9Tg17SHDAHw==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.52.1.tgz", + "integrity": "sha512-zakzlMHeEb+0FsPtISDNrFjiwIB/JeXc1xzelvSb9QAh3htog+snnqa5rqrRdYmAKNZM3TTe16X/aKqCJ54dCg==", "license": "MIT", "dependencies": { - "@sentry/core": "8.51.0" + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.51.0.tgz", - "integrity": "sha512-lkm7id3a2n3yMZeF5socCVQUeEeShNOGr7Wtsmb5RORacEnld0z+NfbMTilo1mDwiWBzI5OYBjm62eglm1HFsQ==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.52.1.tgz", + "integrity": "sha512-jCk+N5RknOwj3w+yECQKd0ozB3JOKLkkrpGL+v9rQxWM9mYcfcD7+WJfgQVjfqQ19NCtH3m231fTEL4BAUMFMA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.51.0", - "@sentry/core": "8.51.0" + "@sentry-internal/browser-utils": "8.52.1", + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.51.0.tgz", - "integrity": "sha512-ERXIbwdULkdtIQnfkMLRVfpoGV2rClwySGRlTPepFKeLxlcXo9o09cPu+qbukiDnGK0cgEgRnrV961hMg21Bmw==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.52.1.tgz", + "integrity": "sha512-KQKRD6d3m4jTLaxGi8gASEc5kU/SxOsiQ/k1DAeTOZwRhGt63zzbBnSg6IaGZLFNqmKK+QYhoCrn3pPO7+NECg==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.51.0", - "@sentry/core": "8.51.0" + "@sentry-internal/replay": "8.52.1", + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.51.0.tgz", - "integrity": "sha512-3hHtxmKIfBZurqTEL680gLe+5F5NpNajkg+ZmVC49/Wu9X9ubpmETBuUasqkB7q+iyx+eoE04AznzEEpLRLuOA==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.52.1.tgz", + "integrity": "sha512-Xe+x4ssAVCGG2l1MowF0hDnLnG1SL1LOt+cOHn1t3IaY+9HnCnlkW9X4asuyiFPCgxyjcVHwsjBpUK9YyWax5w==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.51.0", - "@sentry/core": "8.51.0", + "@sentry/browser": "8.52.1", + "@sentry/core": "8.52.1", "tslib": "^2.4.1" }, "engines": { @@ -6760,38 +6760,38 @@ } }, "node_modules/@sentry/browser": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.51.0.tgz", - "integrity": "sha512-1kbbyVfBBAx5Xyynp+lC5lLnAHo0qJ2r4mtmdT6koPjesvoOocEK0QQnouQBmdUbm3L0L/bPI1SgXjbeJyhzHQ==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.52.1.tgz", + "integrity": "sha512-MB7NZ5zSkA5kFEGvEa/y+0pt5UFB8pToFGC2wBR0nfQfhQ9amIdv+LYPyJFGXGIIEVCIQMEnSlm1nGH4RKzZfw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.51.0", - "@sentry-internal/feedback": "8.51.0", - "@sentry-internal/replay": "8.51.0", - "@sentry-internal/replay-canvas": "8.51.0", - "@sentry/core": "8.51.0" + "@sentry-internal/browser-utils": "8.52.1", + "@sentry-internal/feedback": "8.52.1", + "@sentry-internal/replay": "8.52.1", + "@sentry-internal/replay-canvas": "8.52.1", + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.51.0.tgz", - "integrity": "sha512-Go0KxCYLw+OBIlLSv5YsYX+x9NW43fNVcyB6rhkSp2Q5Zme3tAE6KtZFvyu4SO7G/903wisW5Q6qV6UuK/ee4A==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.52.1.tgz", + "integrity": "sha512-FG0P9I03xk4jBI4O7NBkw8uqLGH9/RWOSFoRH3eYvUTyBLhkk9IaCFbAAGBNZhojky8T7gqYwnuRbFNlrAiuSA==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.51.0.tgz", - "integrity": "sha512-LNwI3IrZR0OaB3u4e8PwjRCO/NZy0m3Hld8j44WnbA/fwq0V5b9PH0wG6NCISOsIBSDzun0HpHCUi3VeQoupvw==", + "version": "8.52.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.52.1.tgz", + "integrity": "sha512-Q8wXwEkNHNr6ahbspn85cFh/ZWTfFgp5kNQO9YngqcCgWJFVMPz3h0jhpUitH9SOqHl7kPdH9CXQDwbxiMOCJQ==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "8.51.0" + "@sentry/core": "8.52.1" }, "engines": { "node": ">=14.18" @@ -7305,9 +7305,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.5.tgz", - "integrity": "sha512-GLZPrd9ckqEBFMcVM/qRFAP0Hg3qiVEojgEFsx/N/zKXsBzbGF6z5FBDpZ0+Xhp1xr+qRZYjfGr1cWHB9oFHSA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "dev": true, "license": "MIT", "peer": true, @@ -7471,9 +7471,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", "dev": true, "license": "MIT" }, @@ -7514,9 +7514,9 @@ "peer": true }, "node_modules/@types/node": { - "version": "22.10.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz", - "integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==", + "version": "22.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "dev": true, "license": "MIT", "dependencies": { @@ -7551,12 +7551,6 @@ "@types/node": "*" } }, - "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -7574,12 +7568,11 @@ "peer": true }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", + "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.0.2" } }, @@ -9452,9 +9445,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001695", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", - "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", + "version": "1.0.30001696", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz", + "integrity": "sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==", "funding": [ { "type": "opencollective", @@ -9585,9 +9578,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true, "license": "MIT" }, @@ -10902,9 +10895,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10972,9 +10965,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.88", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz", - "integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==", + "version": "1.5.90", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.90.tgz", + "integrity": "sha512-C3PN4aydfW91Natdyd449Kw+BzhLmof6tzy5W1pFC5SpQxVXT+oyiyOG9AgYYSN9OdA/ik3YkCrpwqI8ug5Tug==", "license": "ISC" }, "node_modules/emittery": { @@ -12094,9 +12087,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -15892,9 +15885,9 @@ } }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, "license": "MIT" }, @@ -17353,9 +17346,9 @@ "license": "(MIT AND Zlib)" }, "node_modules/papaparse": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.1.tgz", - "integrity": "sha512-EuEKUhyxrHVozD7g3/ztsJn6qaKse8RPfR6buNB2dMJvdtXNhcw8jccVi/LxNEY3HVrV6GO6Z4OoeCG9Iy9wpA==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==", "license": "MIT" }, "node_modules/parent-module": { diff --git a/package.json b/package.json index 539be2fabcfc..77ab43e5c826 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "19.1.3", - "@angular/cdk": "19.1.1", - "@angular/common": "19.1.3", - "@angular/compiler": "19.1.3", - "@angular/core": "19.1.3", - "@angular/forms": "19.1.3", - "@angular/localize": "19.1.3", - "@angular/material": "19.1.1", - "@angular/platform-browser": "19.1.3", - "@angular/platform-browser-dynamic": "19.1.3", - "@angular/router": "19.1.3", - "@angular/service-worker": "19.1.3", + "@angular/animations": "19.1.4", + "@angular/cdk": "19.1.2", + "@angular/common": "19.1.4", + "@angular/compiler": "19.1.4", + "@angular/core": "19.1.4", + "@angular/forms": "19.1.4", + "@angular/localize": "19.1.4", + "@angular/material": "19.1.2", + "@angular/platform-browser": "19.1.4", + "@angular/platform-browser-dynamic": "19.1.4", + "@angular/router": "19.1.4", + "@angular/service-worker": "19.1.4", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "19.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -36,7 +36,7 @@ "@ng-bootstrap/ng-bootstrap": "18.0.0", "@ngx-translate/core": "16.0.4", "@ngx-translate/http-loader": "16.0.1", - "@sentry/angular": "8.51.0", + "@sentry/angular": "8.52.1", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "21.1.3", "@swimlane/ngx-graph": "9.0.1", @@ -48,7 +48,7 @@ "crypto-js": "4.2.0", "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", - "dompurify": "3.2.3", + "dompurify": "3.2.4", "emoji-js": "3.8.1", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -68,7 +68,7 @@ "ngx-infinite-scroll": "19.0.0", "ngx-webstorage": "19.0.1", "pako": "2.1.0", - "papaparse": "5.5.1", + "papaparse": "5.5.2", "pdf-lib": "1.17.1", "pdfjs-dist": "4.10.38", "rxjs": "7.8.1", @@ -138,11 +138,11 @@ "@angular-eslint/eslint-plugin-template": "19.0.2", "@angular-eslint/schematics": "19.0.2", "@angular-eslint/template-parser": "19.0.2", - "@angular/build": "19.1.4", - "@angular/cli": "19.1.4", - "@angular/compiler-cli": "19.1.3", - "@angular/language-service": "19.1.3", - "@sentry/types": "8.51.0", + "@angular/build": "19.1.5", + "@angular/cli": "19.1.5", + "@angular/compiler-cli": "19.1.4", + "@angular/language-service": "19.1.4", + "@sentry/types": "8.52.1", "@testing-library/angular": "17.3.5", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.7", @@ -151,7 +151,7 @@ "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", - "@types/node": "22.10.10", + "@types/node": "22.12.0", "@types/pako": "2.0.3", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", From f7c5970a9f4b68928637a8fa7d3593d466259bca Mon Sep 17 00:00:00 2001 From: Tobias Wasner Date: Fri, 31 Jan 2025 15:20:58 +0100 Subject: [PATCH 10/17] Programming exercises: Add Artemis intelligence consistency check (#10209) --- package-lock.json | 13 ++- .../service/IrisConsistencyCheckService.java | 101 ++++++++++++++++++ .../pyris/PyrisStatusUpdateService.java | 22 +++- ...sConsistencyCheckPipelineExecutionDTO.java | 15 +++ .../PyrisConsistencyCheckStatusUpdateDTO.java | 22 ++++ .../pyris/job/ConsistencyCheckJob.java | 22 ++++ .../web/IrisConsistencyCheckResource.java | 59 ++++++++++ .../open/PublicPyrisStatusUpdateResource.java | 28 +++++ ...ercise-editable-instruction.component.html | 7 ++ ...exercise-editable-instruction.component.ts | 21 +++- .../artemis-intelligence.service.ts | 46 +++++++- .../consistency-check.action.ts | 33 ++++++ .../model/actions/text-editor-action.model.ts | 21 ++++ src/main/webapp/i18n/de/markdownEditor.json | 3 +- .../webapp/i18n/de/programmingExercise.json | 5 + src/main/webapp/i18n/en/markdownEditor.json | 3 +- .../webapp/i18n/en/programmingExercise.json | 5 + 17 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/IrisConsistencyCheckService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckPipelineExecutionDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckStatusUpdateDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ConsistencyCheckJob.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/web/IrisConsistencyCheckResource.java create mode 100644 src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action.ts diff --git a/package-lock.json b/package-lock.json index 044c2ebf8d4f..1a1fd03cd3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7551,6 +7551,12 @@ "@types/node": "*" } }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -7568,11 +7574,12 @@ "peer": true }, "node_modules/@types/react": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", - "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", "dependencies": { + "@types/prop-types": "*", "csstype": "^3.0.2" } }, diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisConsistencyCheckService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisConsistencyCheckService.java new file mode 100644 index 000000000000..ca66bcf16bdd --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisConsistencyCheckService.java @@ -0,0 +1,101 @@ +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.UserRepository; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisDTOService; +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.consistencyCheck.PyrisConsistencyCheckPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.job.ConsistencyCheckJob; +import de.tum.cit.aet.artemis.iris.service.websocket.IrisWebsocketService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; + +/** + * Service to handle the rewriting subsystem of Iris. + */ +@Service +@Profile(PROFILE_IRIS) +public class IrisConsistencyCheckService { + + private final PyrisPipelineService pyrisPipelineService; + + private final LLMTokenUsageService llmTokenUsageService; + + private final ExerciseRepository exerciseRepository; + + private final IrisWebsocketService websocketService; + + private final PyrisJobService pyrisJobService; + + private final UserRepository userRepository; + + private final PyrisDTOService pyrisDTOService; + + public IrisConsistencyCheckService(PyrisPipelineService pyrisPipelineService, LLMTokenUsageService llmTokenUsageService, ExerciseRepository exerciseRepository, + IrisWebsocketService websocketService, PyrisJobService pyrisJobService, UserRepository userRepository, PyrisDTOService pyrisDTOService) { + this.pyrisPipelineService = pyrisPipelineService; + this.llmTokenUsageService = llmTokenUsageService; + this.exerciseRepository = exerciseRepository; + this.websocketService = websocketService; + this.pyrisJobService = pyrisJobService; + this.userRepository = userRepository; + this.pyrisDTOService = pyrisDTOService; + } + + /** + * Executes the consistency check pipeline on Pyris + * + * @param user the user for which the pipeline should be executed + * @param exercise the exercise for which the pipeline should be executed + */ + public void executeConsistencyCheckPipeline(User user, ProgrammingExercise exercise) { + Course course = exercise.getCourseViaExerciseGroupOrCourseMember(); + // @formatter:off + pyrisPipelineService.executePipeline( + "inconsistency-check", + "default", + Optional.empty(), + pyrisJobService.createTokenForJob(token -> new ConsistencyCheckJob(token, course.getId(), exercise.getId(), user.getId())), + executionDto -> new PyrisConsistencyCheckPipelineExecutionDTO(executionDto, pyrisDTOService.toPyrisProgrammingExerciseDTO(exercise)), + stages -> websocketService.send(user.getLogin(), websocketTopic(exercise.getId()), new PyrisConsistencyCheckStatusUpdateDTO(stages, null, null)) + ); + // @formatter:on + } + + /** + * Takes a status update from Pyris containing a new consistency check result and sends it to the client via websocket + * + * @param job Job related to the status update + * @param statusUpdate the status update containing the consistency check result + * @return the same job that was passed in + */ + public ConsistencyCheckJob handleStatusUpdate(ConsistencyCheckJob job, PyrisConsistencyCheckStatusUpdateDTO statusUpdate) { + Exercise exercise = exerciseRepository.findByIdElseThrow(job.exerciseId()); + if (statusUpdate.tokens() != null && !statusUpdate.tokens().isEmpty()) { + llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> builder.withExercise(exercise.getId()).withUser(job.userId())); + } + + var user = userRepository.findById(job.userId()).orElseThrow(); + websocketService.send(user.getLogin(), websocketTopic(job.exerciseId()), statusUpdate); + + return job; + } + + private static String websocketTopic(long exerciseId) { + return "consistency-check/exercises/" + exerciseId; + } + +} 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 258be6263b7e..5a3e47776ea2 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 @@ -4,21 +4,22 @@ import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.iris.service.IrisCompetencyGenerationService; +import de.tum.cit.aet.artemis.iris.service.IrisConsistencyCheckService; 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.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; 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; +import de.tum.cit.aet.artemis.iris.service.pyris.job.ConsistencyCheckJob; 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; @@ -46,17 +47,18 @@ public class PyrisStatusUpdateService { private final IrisRewritingService rewritingService; - private static final Logger log = LoggerFactory.getLogger(PyrisStatusUpdateService.class); + private final IrisConsistencyCheckService consistencyCheckService; public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseChatSessionService irisExerciseChatSessionService, IrisTextExerciseChatSessionService irisTextExerciseChatSessionService, IrisCourseChatSessionService courseChatSessionService, - IrisCompetencyGenerationService competencyGenerationService, IrisRewritingService rewritingService) { + IrisCompetencyGenerationService competencyGenerationService, IrisRewritingService rewritingService, IrisConsistencyCheckService consistencyCheckService) { this.pyrisJobService = pyrisJobService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; this.courseChatSessionService = courseChatSessionService; this.competencyGenerationService = competencyGenerationService; this.rewritingService = rewritingService; + this.consistencyCheckService = consistencyCheckService; } /** @@ -123,6 +125,18 @@ public void handleStatusUpdate(RewritingJob job, PyrisRewritingStatusUpdateDTO s removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } + /** + * Handles the status update of a consistency check job and forwards it to + * {@link IrisConsistencyCheckService#handleStatusUpdate(ConsistencyCheckJob, PyrisConsistencyCheckStatusUpdateDTO)} + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(ConsistencyCheckJob job, PyrisConsistencyCheckStatusUpdateDTO statusUpdate) { + var updatedJob = consistencyCheckService.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/consistencyCheck/PyrisConsistencyCheckPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckPipelineExecutionDTO.java new file mode 100644 index 000000000000..716814770d4c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckPipelineExecutionDTO.java @@ -0,0 +1,15 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.consistencyCheck; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisProgrammingExerciseDTO; + +/** + * DTO to execute the Iris consistency check pipeline on Pyris + * + * @param execution The pipeline execution details + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisConsistencyCheckPipelineExecutionDTO(PyrisPipelineExecutionDTO execution, PyrisProgrammingExerciseDTO exercise) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckStatusUpdateDTO.java new file mode 100644 index 000000000000..06ffcf60355c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/consistencyCheck/PyrisConsistencyCheckStatusUpdateDTO.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.consistencyCheck; + +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 consistency check feature. + * Pyris sends callback updates back to Artemis during checking consistency of the exercise. + * These update contains found inconsistencies, iff. any, + * 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 PyrisConsistencyCheckStatusUpdateDTO(List stages, String result, List tokens) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ConsistencyCheckJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ConsistencyCheckJob.java new file mode 100644 index 000000000000..5a48bf4c751c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ConsistencyCheckJob.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 check an exercise for consistency. + * + * @param jobId the job id + * @param courseId the course in which the consistency check is being done (permission checking) + * @param exerciseId the exercise in which the consistency check is being done + * @param userId the user who started the job + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ConsistencyCheckJob(String jobId, long courseId, long exerciseId, long userId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return false; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisConsistencyCheckResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisConsistencyCheckResource.java new file mode 100644 index 000000000000..1baac0a6feb2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisConsistencyCheckResource.java @@ -0,0 +1,59 @@ +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; +import de.tum.cit.aet.artemis.iris.service.IrisConsistencyCheckService; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; + +/** + * REST controller for checking consistency of exercises. + */ +@Profile(PROFILE_IRIS) +@RestController +@RequestMapping("api/iris/consistency-check/") +public class IrisConsistencyCheckResource { + + private static final Logger log = LoggerFactory.getLogger(IrisConsistencyCheckResource.class); + + private final UserRepository userRepository; + + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final Optional irisConsistencyCheckService; + + public IrisConsistencyCheckResource(UserRepository userRepository, ProgrammingExerciseRepository programmingExerciseRepository, + Optional irisConsistencyCheckService) { + this.userRepository = userRepository; + this.programmingExerciseRepository = programmingExerciseRepository; + this.irisConsistencyCheckService = irisConsistencyCheckService; + } + + /** + * POST /api/iris/consistency-check/exercises/{exerciseId} : Check the consistency of an exercise. + * + * @param exerciseId the id of the exercise to check + * @return the ResponseEntity with status 200 (OK) + */ + @EnforceAtLeastEditorInExercise + @PostMapping("exercises/{exerciseId}") + public ResponseEntity consistencyCheckExercise(@PathVariable Long exerciseId) { + var consistencyCheckService = irisConsistencyCheckService.orElseThrow(); + var user = userRepository.getUserWithGroupsAndAuthorities(); + var programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId); + consistencyCheckService.executeConsistencyCheckPipeline(user, programmingExercise); + 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 3dabd0970310..933a8ed99d11 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 @@ -22,9 +22,11 @@ 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.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; 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.ConsistencyCheckJob; 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; @@ -176,6 +178,32 @@ public ResponseEntity setRewritingJobStatus(@PathVariable String runId, @R return ResponseEntity.ok().build(); } + /** + * POST public/pyris/pipelines/inconsistency-check/runs/:runId/status : Send the consistency check response 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/inconsistency-check/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setConsistencyCheckJobStatus(@PathVariable String runId, @RequestBody PyrisConsistencyCheckStatusUpdateDTO statusUpdateDTO, + HttpServletRequest request) { + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, ConsistencyCheckJob.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/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 e2c6ba9e9f43..6a281f838068 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 @@ -77,4 +77,11 @@ } } + @if (showConsistencyCheck()) { +

+ } diff --git a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts index 26067dec71c5..26b18b59576b 100644 --- a/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/instructions-editor/programming-exercise-editable-instruction.component.ts @@ -13,6 +13,7 @@ import { ViewEncapsulation, computed, inject, + signal, } from '@angular/core'; import { AlertService } from 'app/core/util/alert.service'; import { ProgrammingExerciseInstructionComponent } from 'app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component'; @@ -27,7 +28,7 @@ import { hasExerciseChanged } from 'app/exercises/shared/exercise/exercise.utils import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; import { ProgrammingExerciseGradingService } from 'app/exercises/programming/manage/services/programming-exercise-grading.service'; import { Result } from 'app/entities/result.model'; -import { faCheckCircle, faCircleNotch, faExclamationTriangle, faGripLines, faSave } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faCircleNotch, faExclamationTriangle, faSave } from '@fortawesome/free-solid-svg-icons'; import { MarkdownEditorHeight, MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; import { Annotation } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; import { FormulaAction } from 'app/shared/monaco-editor/model/actions/formula.action'; @@ -47,6 +48,7 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { ConsistencyCheckAction } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action'; @Component({ selector: 'jhi-programming-exercise-editable-instructions', @@ -83,14 +85,23 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie domainActions: TextEditorDomainAction[] = [new FormulaAction(), new TaskAction(), this.testCaseAction]; courseId: number; + exerciseId: number; irisEnabled = toSignal(this.profileService.getProfileInfo().pipe(map((profileInfo) => profileInfo.activeProfiles.includes(PROFILE_IRIS))), { initialValue: false }); artemisIntelligenceActions = computed(() => - this.irisEnabled() ? [new RewriteAction(this.artemisIntelligenceService, RewritingVariant.PROBLEM_STATEMENT, this.courseId)] : [], + this.irisEnabled() + ? [ + new RewriteAction(this.artemisIntelligenceService, RewritingVariant.PROBLEM_STATEMENT, this.courseId), + ...(this.exerciseId ? [new ConsistencyCheckAction(this.artemisIntelligenceService, this.exerciseId, this.renderedConsistencyCheckResultMarkdown)] : []), + ] + : [], ); savingInstructions = false; unsavedChangesValue = false; + renderedConsistencyCheckResultMarkdown = signal(''); + showConsistencyCheck = computed(() => !!this.renderedConsistencyCheckResultMarkdown()); + testCaseSubscription: Subscription; forceRenderSubscription: Subscription; @@ -144,12 +155,12 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie faCheckCircle = faCheckCircle; faExclamationTriangle = faExclamationTriangle; faCircleNotch = faCircleNotch; - faGripLines = faGripLines; protected readonly MarkdownEditorHeight = MarkdownEditorHeight; ngOnInit() { this.courseId = Number(this.activatedRoute.snapshot.paramMap.get('courseId')); + this.exerciseId = Number(this.activatedRoute.snapshot.paramMap.get('exerciseId')); } ngOnChanges(changes: SimpleChanges): void { @@ -221,6 +232,10 @@ export class ProgrammingExerciseEditableInstructionComponent implements AfterVie this.instructionChange.emit(problemStatement); } + dismissConsistencyCheck() { + this.renderedConsistencyCheckResultMarkdown.set(''); + } + /** * Signal that the markdown should be rendered into html. */ 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 index 3aca4d2edbca..45a708fa0c96 100644 --- 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 @@ -11,14 +11,15 @@ import { AlertService } from 'app/core/util/alert.service'; */ @Injectable({ providedIn: 'root' }) export class ArtemisIntelligenceService { - public resourceUrl = 'api/courses'; + public resourceUrl = 'api'; private http = inject(HttpClient); private jhiWebsocketService = inject(WebsocketService); private alertService = inject(AlertService); - private isLoadingRewrite = signal(false); - isLoading = computed(() => this.isLoadingRewrite()); + private isLoadingRewrite = signal(false); + private isLoadingConsistencyCheck = signal(false); + isLoading = computed(() => this.isLoadingRewrite() || this.isLoadingConsistencyCheck()); /** * Triggers the rewriting pipeline via HTTP and subscribes to its WebSocket updates. @@ -31,7 +32,7 @@ export class ArtemisIntelligenceService { this.isLoadingRewrite.set(true); return new Observable((observer) => { this.http - .post(`${this.resourceUrl}/${courseId}/rewrite-text`, { + .post(`${this.resourceUrl}/courses/${courseId}/rewrite-text`, { toBeRewritten: toBeRewritten, variant: rewritingVariant, }) @@ -63,4 +64,41 @@ export class ArtemisIntelligenceService { }); }); } + + /** + * Triggers the consistency check pipeline via HTTP and subscribes to its WebSocket updates. + * + * @param exerciseId The ID of the exercise to check for consistency. + * @return Observable that emits the consistency check result when available. + */ + consistencyCheck(exerciseId: number): Observable { + this.isLoadingConsistencyCheck.set(true); + return new Observable((observer) => { + this.http.post(`${this.resourceUrl}/iris/consistency-check/exercises/${exerciseId}`, null).subscribe({ + next: () => { + const websocketTopic = `/user/topic/iris/consistency-check/exercises/${exerciseId}`; + this.jhiWebsocketService.subscribe(websocketTopic); + this.jhiWebsocketService.receive(websocketTopic).subscribe({ + next: (update: any) => { + if (update.result) { + observer.next(update.result); + observer.complete(); + this.isLoadingConsistencyCheck.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + } + }, + error: (error) => { + observer.error(error); + this.isLoadingConsistencyCheck.set(false); + this.jhiWebsocketService.unsubscribe(websocketTopic); + }, + }); + }, + error: (error) => { + this.isLoadingConsistencyCheck.set(false); + observer.error(error); + }, + }); + }); + } } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action.ts new file mode 100644 index 000000000000..228ae77db94a --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/artemis-intelligence/consistency-check.action.ts @@ -0,0 +1,33 @@ +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 { ArtemisIntelligenceService } from 'app/shared/monaco-editor/model/actions/artemis-intelligence/artemis-intelligence.service'; +import { WritableSignal } from '@angular/core'; + +/** + * Artemis Intelligence action for consistency checking exercises + */ +export class ConsistencyCheckAction extends TextEditorAction { + static readonly ID = 'artemisIntelligence.consistencyCheck.action'; + + element?: HTMLElement; + + constructor( + private readonly artemisIntelligenceService: ArtemisIntelligenceService, + private readonly exerciseId: number, + private readonly resultSignal: WritableSignal, + ) { + super(ConsistencyCheckAction.ID, 'artemisApp.markdownEditor.artemisIntelligence.commands.consistencyCheck'); + } + + /** + * Runs the consistency check on the exercise. + * + * @param editor The editor in which to rewrite the markdown. + * @param artemisIntelligenceService The service to use for rewriting the markdown. + * @param exerciseId The id of the exercise to check. + * @param resultSignal The signal to write the result of the consistency check to. + */ + run(editor: TextEditor): void { + this.consistencyCheck(editor, this.artemisIntelligenceService, this.exerciseId, this.resultSignal); + } +} 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 fe5da5e71942..88d5ab74e7ae 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 @@ -9,6 +9,8 @@ import { TextEditorCompletionItem } from 'app/shared/monaco-editor/model/actions 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'; +import { WritableSignal } from '@angular/core'; +import { htmlForMarkdown } from 'app/shared/util/markdown.conversion.util'; export abstract class TextEditorAction implements Disposable { id: string; @@ -313,4 +315,23 @@ export abstract class TextEditorAction implements Disposable { }); } } + + /** + * Runs the consistency check on the exercise. + * + * @param editor The editor for which the consistency check should be run (only used for checking non-empty text). + * @param artemisIntelligence The service to use for consistency checking. + * @param exerciseId The id of the exercise to check. + * @param resultSignal The signal to write the result of the consistency check to. + */ + consistencyCheck(editor: TextEditor, artemisIntelligence: ArtemisIntelligenceService, exerciseId: number, resultSignal: WritableSignal): void { + const text = editor.getFullText(); + if (text) { + artemisIntelligence.consistencyCheck(exerciseId).subscribe({ + next: (result) => { + resultSignal.set(htmlForMarkdown(result)); + }, + }); + } + } } diff --git a/src/main/webapp/i18n/de/markdownEditor.json b/src/main/webapp/i18n/de/markdownEditor.json index aa77392955f3..1efa89ebe0a1 100644 --- a/src/main/webapp/i18n/de/markdownEditor.json +++ b/src/main/webapp/i18n/de/markdownEditor.json @@ -16,7 +16,8 @@ "artemisIntelligence": { "tooltip": "Artemis Intelligence", "commands": { - "rewrite": "Umformulieren" + "rewrite": "Umformulieren", + "consistencyCheck": "Konsistenz prüfen" }, "alerts": { "rewrite": { diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index dbb1c466df22..08efba447837 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -743,6 +743,11 @@ "addEnvVar": "Umgebungsvariable hinzufügen", "removeEnvVar": "Entfernen" } + }, + "artemisIntelligence": { + "consistencyCheckAlert": { + "title": "Resultat der Konsistenzprüfung" + } } }, "error": { diff --git a/src/main/webapp/i18n/en/markdownEditor.json b/src/main/webapp/i18n/en/markdownEditor.json index 44666915a0d7..8eeb6bc09868 100644 --- a/src/main/webapp/i18n/en/markdownEditor.json +++ b/src/main/webapp/i18n/en/markdownEditor.json @@ -16,7 +16,8 @@ "artemisIntelligence": { "tooltip": "Artemis Intelligence", "commands": { - "rewrite": "Rewrite" + "rewrite": "Rewrite", + "consistencyCheck": "Check consistency" }, "alerts": { "rewrite": { diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 4982bf654361..f117904dde48 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -742,6 +742,11 @@ "addEnvVar": "Add Environment Variable", "removeEnvVar": "Remove" } + }, + "artemisIntelligence": { + "consistencyCheckAlert": { + "title": "Consistency Check Result" + } } }, "error": { From 59a9e64f08300ee1b332b883d264854fd7530e04 Mon Sep 17 00:00:00 2001 From: "Felix T.J. Dietrich" Date: Fri, 31 Jan 2025 18:15:17 +0100 Subject: [PATCH 11/17] `mDevelopment: Reduce coverage ratios to fix failing checks (#10239) --- gradle/jacoco.gradle | 4 ++-- jest.config.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index d8089206e76f..9efd486f9cbd 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -42,8 +42,8 @@ ext { "CLASS": 1 ], "iris" : [ - "INSTRUCTION": 0.775, - "CLASS": 22 + "INSTRUCTION": 0.760, + "CLASS": 25 ], "lecture" : [ "INSTRUCTION": 0.867, diff --git a/jest.config.js b/jest.config.js index c06fee49fd06..b43abf32ab01 100644 --- a/jest.config.js +++ b/jest.config.js @@ -91,10 +91,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 88.84, - branches: 74.47, - functions: 83.03, - lines: 88.86, + statements: 88.82, + branches: 74.45, + functions: 82.97, + lines: 88.84, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], From c59f91c0e4ed8a764de11ddf9bc7484956482a87 Mon Sep 17 00:00:00 2001 From: Tim Cremer <65229601+cremertim@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:22:45 +0100 Subject: [PATCH 12/17] Iris: Enable IRIS to answer with FAQs (#10093) --- .../communication/service/FaqService.java | 85 ++++++ .../communication/web/FaqResource.java | 33 ++- .../artemis/core/service/ProfileService.java | 9 + .../domain/settings/IrisCourseSettings.java | 14 + .../domain/settings/IrisExerciseSettings.java | 12 + .../settings/IrisFaqIngestionSubSettings.java | 29 ++ .../domain/settings/IrisGlobalSettings.java | 15 + .../iris/domain/settings/IrisSettings.java | 5 + .../iris/domain/settings/IrisSubSettings.java | 3 +- .../domain/settings/IrisSubSettingsType.java | 2 +- ...risCombinedFaqIngestionSubSettingsDTO.java | 7 + .../iris/dto/IrisCombinedSettingsDTO.java | 3 +- .../repository/IrisSettingsRepository.java | 3 +- .../service/pyris/PyrisConnectorService.java | 69 ++++- .../iris/service/pyris/PyrisJobService.java | 30 +- .../pyris/PyrisStatusUpdateService.java | 17 +- .../service/pyris/PyrisWebhookService.java | 94 +++++- .../PyrisFaqIngestionStatusUpdateDTO.java | 11 + .../PyrisFaqWebhookDTO.java | 13 + .../PyrisWebhookFaqDeletionExecutionDTO.java | 12 + .../PyrisWebhookFaqIngestionExecutionDTO.java | 12 + .../pyris/job/FaqIngestionWebhookJob.java | 21 ++ ...b.java => LectureIngestionWebhookJob.java} | 2 +- .../service/settings/IrisSettingsService.java | 43 ++- .../settings/IrisSubSettingsService.java | 47 +++ .../aet/artemis/iris/web/IrisResource.java | 28 ++ .../open/PublicPyrisStatusUpdateResource.java | 32 +- .../changelog/20241217122200_changelog.xml | 29 ++ .../resources/config/liquibase/master.xml | 5 +- .../iris/settings/iris-settings.model.ts | 4 + .../iris/settings/iris-sub-settings.model.ts | 6 + src/main/webapp/app/faq/faq.component.html | 8 +- src/main/webapp/app/faq/faq.component.ts | 38 ++- src/main/webapp/app/faq/faq.service.ts | 13 +- .../iris-settings-update.component.html | 19 ++ .../iris-settings-update.component.ts | 9 + src/main/webapp/i18n/de/faq.json | 3 +- src/main/webapp/i18n/de/iris.json | 6 + src/main/webapp/i18n/en/faq.json | 3 +- src/main/webapp/i18n/en/iris.json | 8 +- .../communication/FaqIntegrationTest.java | 8 + .../connector/IrisRequestMockProvider.java | 19 ++ .../iris/AbstractIrisIntegrationTest.java | 2 + .../artemis/iris/PyrisFaqIngestionTest.java | 280 ++++++++++++++++++ .../iris/PyrisLectureIngestionTest.java | 4 +- .../account-information.component.spec.ts | 115 +++++-- .../spec/component/faq/faq.component.spec.ts | 52 ++++ ...s-course-settings-update.component.spec.ts | 3 +- ...s-global-settings-update.component.spec.ts | 2 +- .../lecture/lecture-detail.component.spec.ts | 1 + .../spec/service/faq.service.spec.ts | 16 + 51 files changed, 1245 insertions(+), 59 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java rename src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/{IngestionWebhookJob.java => LectureIngestionWebhookJob.java} (80%) create mode 100644 src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml create mode 100644 src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java new file mode 100644 index 000000000000..242d2eb789f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/FaqService.java @@ -0,0 +1,85 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; + +@Profile(PROFILE_CORE) +@Service +public class FaqService { + + private final Optional pyrisWebhookService; + + private final FaqRepository faqRepository; + + public FaqService(FaqRepository faqRepository, Optional pyrisWebhookService, ProfileService profileService) { + + this.pyrisWebhookService = pyrisWebhookService; + this.faqRepository = faqRepository; + + } + + /** + * Ingests FAQs into the Pyris system. If a specific FAQ ID is provided, the method will attempt to add + * that FAQ to Pyris. Otherwise, it will ingest all FAQs for the specified course that are in the "ACCEPTED" state. + * If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param courseId the ID of the course for which FAQs will be ingested + * @param faqId an optional ID of a specific FAQ to ingest; if not provided, all accepted FAQs for the course are processed + * @throws IllegalArgumentException if a specific FAQ is provided but its state is not "ACCEPTED" + */ + public void ingestFaqsIntoPyris(Long courseId, Optional faqId) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + faqId.ifPresentOrElse(id -> { + Faq faq = faqRepository.findById(id).orElseThrow(); + if (faq.getFaqState() != FaqState.ACCEPTED) { + throw new IllegalArgumentException("Faq is not in the state accepted, you cannot ingest this faq"); + } + pyrisWebhookService.get().addFaq(faq); + }, () -> faqRepository.findAllByCourseIdAndFaqState(courseId, FaqState.ACCEPTED).forEach(faq -> pyrisWebhookService.get().addFaq(faq))); + } + + /** + * Deletes an existing FAQ from the Pyris system. If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param existingFaq the FAQ to be removed from Pyris + */ + public void deleteFaqInPyris(Faq existingFaq) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + pyrisWebhookService.get().deleteFaq(existingFaq); + } + + /** + * Automatically updates or ingests a specific FAQ into the Pyris system for a given course. + * If the PyrisWebhookService is unavailable, the method does nothing. + * + * @param courseId the ID of the course to which the FAQ belongs + * @param faq the FAQ to be ingested or updated in Pyris + */ + public void autoIngestFaqsIntoPyris(Long courseId, Faq faq) { + if (pyrisWebhookService.isEmpty()) { + return; + } + + if (faq.getFaqState() != FaqState.ACCEPTED) { + return; + } + + pyrisWebhookService.get().autoUpdateFaqInPyris(courseId, faq); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java index 6983133ce2d1..419de87f37eb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -1,10 +1,12 @@ package de.tum.cit.aet.artemis.communication.web; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import java.net.URI; import java.net.URISyntaxException; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -20,12 +22,14 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.communication.service.FaqService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; @@ -58,10 +62,13 @@ public class FaqResource { private final FaqRepository faqRepository; - public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository) { + private final FaqService faqService; + + public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository, FaqService faqService) { this.faqRepository = faqRepository; this.courseRepository = courseRepository; this.authCheckService = authCheckService; + this.faqService = faqService; } /** @@ -86,6 +93,7 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long } Faq savedFaq = faqRepository.save(faq); FaqDTO dto = new FaqDTO(savedFaq); + faqService.autoIngestFaqsIntoPyris(courseId, savedFaq); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/faqs/" + savedFaq.getId())).body(dto); } @@ -112,6 +120,7 @@ public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } Faq updatedFaq = faqRepository.save(faq); + faqService.autoIngestFaqsIntoPyris(courseId, updatedFaq); FaqDTO dto = new FaqDTO(updatedFaq); return ResponseEntity.ok().body(dto); } @@ -152,6 +161,7 @@ public ResponseEntity deleteFaq(@PathVariable Long faqId, @PathVariable Lo if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } + faqService.deleteFaqInPyris(existingFaq); faqRepository.deleteById(faqId); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, faqId.toString())).build(); } @@ -212,6 +222,23 @@ public ResponseEntity> getFaqCategoriesForCourseByState(@PathVariabl return ResponseEntity.ok().body(faqs); } + /** + * POST /courses/{courseId}/ingest + * This endpoint is for starting the ingestion of all faqs or only one faq when triggered in Artemis. + * + * @param courseId the ID of the course for which all faqs should be ingested in pyris + * @param faqId If this id is present then only ingest this one faq of the respective course + * @return the ResponseEntity with status 200 (OK) and a message success or null if the operation failed + */ + @Profile(PROFILE_IRIS) + @PostMapping("courses/{courseId}/faqs/ingest") + @EnforceAtLeastInstructorInCourse + public ResponseEntity ingestFaqInIris(@PathVariable Long courseId, @RequestParam(required = false) Optional faqId) { + Course course = courseRepository.findByIdElseThrow(courseId); + faqService.ingestFaqsIntoPyris(courseId, faqId); + return ResponseEntity.ok().build(); + } + /** * @param courseId the id of the course the faq belongs to * @param role the required role of the user @@ -235,4 +262,8 @@ private void checkIsInstructorForAcceptedFaq(FaqState faqState, Long courseId) { } } + private boolean checkIfFaqIsAccepted(Faq faq) { + return faq.getFaqState() == FaqState.ACCEPTED; + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java index e142d9f82026..d98b27096b80 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java @@ -113,6 +113,15 @@ public boolean isAeolusActive() { return isProfileActive(Constants.PROFILE_AEOLUS); } + /** + * Checks if the IRIS profile is active + * + * @return true if the aeolus profile is active, false otherwise + */ + public boolean isIrisActive() { + return isProfileActive(Constants.PROFILE_IRIS); + } + /** * Checks if the lti profile is active * diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java index 8320f2b6d708..64a775bb711e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java @@ -40,6 +40,10 @@ public class IrisCourseSettings extends IrisSettings { @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @JoinColumn(name = "iris_faq_ingestion_settings_id") + private IrisFaqIngestionSubSettings irisFaqIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; @@ -101,4 +105,14 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings) { this.irisCompetencyGenerationSettings = irisCompetencyGenerationSubSettings; } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + return irisFaqIngestionSettings; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + this.irisFaqIngestionSettings = irisFaqIngestionSubSettings; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java index 8048a76e976b..ca1b6fc09ef7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java @@ -89,4 +89,16 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings) { } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + // Empty because exercises don't have exercise faq settings + return null; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + // Empty because exercises don't have exercise faq settings + + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java new file mode 100644 index 000000000000..fe344e1b8d97 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisFaqIngestionSubSettings.java @@ -0,0 +1,29 @@ +package de.tum.cit.aet.artemis.iris.domain.settings; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents the specific ingestion sub-settings of faqs for Iris. + * This class extends {@link IrisSubSettings} to provide settings required for faq data ingestion. + */ +@Entity +@DiscriminatorValue("FAQ_INGESTION") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class IrisFaqIngestionSubSettings extends IrisSubSettings { + + @Column(name = "auto_ingest_on_faq_creation") + private boolean autoIngestOnFaqCreation; + + public boolean getAutoIngestOnFaqCreation() { + return autoIngestOnFaqCreation; + } + + public void setAutoIngestOnFaqCreation(boolean autoIngestOnFaqCreation) { + this.autoIngestOnFaqCreation = autoIngestOnFaqCreation; + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java index 5531f65584ff..fdfe1c6ed629 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java @@ -35,6 +35,10 @@ public class IrisGlobalSettings extends IrisSettings { @JoinColumn(name = "iris_lecture_ingestion_settings_id") private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "iris_faq_ingestion_settings_id") + private IrisFaqIngestionSubSettings irisFaqIngestionSubSettings; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; @@ -88,4 +92,15 @@ public IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings() public void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings) { this.irisCompetencyGenerationSettings = irisCompetencyGenerationSettings; } + + @Override + public IrisFaqIngestionSubSettings getIrisFaqIngestionSettings() { + return irisFaqIngestionSubSettings; + } + + @Override + public void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings) { + this.irisFaqIngestionSubSettings = irisFaqIngestionSubSettings; + + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java index d67d49caeab0..cf05343a5f0e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java @@ -60,4 +60,9 @@ public abstract class IrisSettings extends DomainObject { public abstract IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings(); public abstract void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings); + + public abstract IrisFaqIngestionSubSettings getIrisFaqIngestionSettings(); + + public abstract void setIrisFaqIngestionSettings(IrisFaqIngestionSubSettings irisFaqIngestionSubSettings); + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java index 86e77fc9c034..52ee9f0e94a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java @@ -42,7 +42,8 @@ @JsonSubTypes.Type(value = IrisTextExerciseChatSubSettings.class, name = "text-exercise-chat"), @JsonSubTypes.Type(value = IrisCourseChatSubSettings.class, name = "course-chat"), @JsonSubTypes.Type(value = IrisLectureIngestionSubSettings.class, name = "lecture-ingestion"), - @JsonSubTypes.Type(value = IrisCompetencyGenerationSubSettings.class, name = "competency-generation") + @JsonSubTypes.Type(value = IrisCompetencyGenerationSubSettings.class, name = "competency-generation"), + @JsonSubTypes.Type(value = IrisFaqIngestionSubSettings.class, name = "faq-ingestion"), }) // @formatter:on @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java index fe3561f12c2a..6e08e3dbca18 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java @@ -2,5 +2,5 @@ public enum IrisSubSettingsType { CHAT, // TODO: Rename to PROGRAMMING_EXERCISE_CHAT - TEXT_EXERCISE_CHAT, COURSE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION + TEXT_EXERCISE_CHAT, COURSE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION, FAQ_INGESTION } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java new file mode 100644 index 000000000000..ab9ba9b036c5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedFaqIngestionSubSettingsDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.iris.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record IrisCombinedFaqIngestionSubSettingsDTO(boolean enabled) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java index 294f2e836140..f0f04e582ae5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java @@ -9,6 +9,7 @@ public record IrisCombinedSettingsDTO( IrisCombinedTextExerciseChatSubSettingsDTO irisTextExerciseChatSettings, IrisCombinedCourseChatSubSettingsDTO irisCourseChatSettings, IrisCombinedLectureIngestionSubSettingsDTO irisLectureIngestionSettings, - IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings + IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings, + IrisCombinedFaqIngestionSubSettingsDTO irisFaqIngestionSettings ) {} // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java index c9e8eafba618..e6211b3e0d7c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java @@ -45,7 +45,8 @@ default IrisGlobalSettings findGlobalSettingsElseThrow() { LEFT JOIN FETCH irisSettings.irisTextExerciseChatSettings LEFT JOIN FETCH irisSettings.irisLectureIngestionSettings LEFT JOIN FETCH irisSettings.irisCompetencyGenerationSettings - WHERE irisSettings.course.id = :courseId + LEFT JOIN FETCH irisSettings.irisFaqIngestionSettings + WHERE irisSettings.course.id = :courseId """) Optional findCourseSettings(@Param("courseId") long courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java index e29999073eb9..a964fa556ad3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java @@ -29,9 +29,12 @@ import de.tum.cit.aet.artemis.iris.exception.IrisForbiddenException; import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqDeletionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; -import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.web.open.PublicPyrisStatusUpdateResource; /** @@ -145,7 +148,7 @@ IngestionState getLectureUnitIngestionState(long courseId, long lectureId, long IngestionStateResponseDTO response = restTemplate.getForObject(url, IngestionStateResponseDTO.class); IngestionState state = response.state(); if (state != IngestionState.DONE) { - if (pyrisJobService.currentJobs().stream().filter(job -> job instanceof IngestionWebhookJob).map(job -> (IngestionWebhookJob) job) + if (pyrisJobService.currentJobs().stream().filter(job -> job instanceof LectureIngestionWebhookJob).map(job -> (LectureIngestionWebhookJob) job) .anyMatch(ingestionJob -> ingestionJob.courseId() == courseId && ingestionJob.lectureId() == lectureId && ingestionJob.lectureUnitId() == lectureUnitId)) { return IngestionState.IN_PROGRESS; } @@ -195,4 +198,66 @@ private String tryExtractErrorMessage(HttpStatusCodeException ex) { return ""; } } + + /** + * Executes a webhook and send faqs to the webhook with the given variant. This webhook adds an FAQ in the Pyris system. + * + * @param toUpdateFaq The DTO containing the faq to update + * @param executionDTO The DTO sent as a body for the execution + */ + public void executeFaqAdditionWebhook(PyrisFaqWebhookDTO toUpdateFaq, PyrisWebhookFaqIngestionExecutionDTO executionDTO) { + var endpoint = "/api/v1/webhooks/faqs"; + try { + restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); + } + catch (HttpStatusCodeException e) { + log.error("Failed to send faq {} to Pyris: {}", toUpdateFaq.faqId(), e.getMessage()); + throw toIrisException(e); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Failed to send faq {} to Pyris: {}", toUpdateFaq.faqId(), e.getMessage()); + throw new PyrisConnectorException("Could not fetch response from Pyris"); + } + } + + /** + * Executes a webhook and adds faqs to the webhook with the given variant. This webhook deletes an FAQ in the Pyris system. + * + * @param executionDTO The DTO sent as a body for the execution + */ + public void executeFaqDeletionWebhook(PyrisWebhookFaqDeletionExecutionDTO executionDTO) { + var endpoint = "/api/v1/webhooks/faqs/delete"; + try { + restTemplate.postForEntity(pyrisUrl + endpoint, objectMapper.valueToTree(executionDTO), Void.class); + } + catch (HttpStatusCodeException e) { + log.error("Failed to send faqs to Pyris", e); + throw toIrisException(e); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Failed to send faqs to Pyris", e); + throw new PyrisConnectorException("Could not fetch response from Pyris"); + } + } + + /** + * Retrieves the ingestion state of the faq specified by retrieving the ingestion state from the vector database in Pyris. + * + * @param courseId id of the course + * @return The ingestion state of the faq + * + */ + IngestionState getFaqIngestionState(long courseId, long faqId) { + try { + String encodedBaseUrl = URLEncoder.encode(artemisBaseUrl, StandardCharsets.UTF_8); + String url = pyrisUrl + "/api/v1/courses/" + courseId + "/faqs/" + faqId + "/ingestion-state?base_url=" + encodedBaseUrl; + IngestionStateResponseDTO response = restTemplate.getForObject(url, IngestionStateResponseDTO.class); + return response.state(); + } + catch (RestClientException | IllegalArgumentException e) { + log.error("Error fetching ingestion state for faq {}", faqId, e); + throw new PyrisConnectorException("Error fetching ingestion state for faq" + faqId); + } + + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java index 46c0dd7547cd..b57e759e0df7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java @@ -22,7 +22,8 @@ import de.tum.cit.aet.artemis.core.exception.ConflictException; 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.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; /** @@ -48,6 +49,9 @@ public class PyrisJobService { @Value("${artemis.iris.jobs.timeout:300}") private int jobTimeout; // in seconds + @Value("${artemis.iris.jobs.ingestion.timeout:3600}") + private int ingestionJobTimeout; // in seconds + public PyrisJobService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance) { this.hazelcastInstance = hazelcastInstance; } @@ -92,19 +96,31 @@ public String addCourseChatJob(Long courseId, Long sessionId) { } /** - * Adds a new ingestion webhook job to the job map with a timeout. + * Adds a new lecture ingestion webhook job to the job map with a timeout. * * @param courseId the ID of the course associated with the webhook job * @param lectureId the ID of the lecture associated with the webhook job * @param lectureUnitId the ID of the lecture unit associated with the webhook job * @return a unique token identifying the created webhook job */ - public String addIngestionWebhookJob(long courseId, long lectureId, long lectureUnitId) { + public String addLectureIngestionWebhookJob(long courseId, long lectureId, long lectureUnitId) { + var token = generateJobIdToken(); + var job = new LectureIngestionWebhookJob(token, courseId, lectureId, lectureUnitId); + jobMap.put(token, job, ingestionJobTimeout, TimeUnit.SECONDS); + return token; + } + + /** + * Adds a new faq ingestion webhook job to the job map with a timeout. + * + * @param courseId the ID of the course associated with the webhook job + * @param faqId the ID of the faq associated with the webhook job + * @return a unique token identifying the created webhook job + */ + public String addFaqIngestionWebhookJob(long courseId, long faqId) { var token = generateJobIdToken(); - var job = new IngestionWebhookJob(token, courseId, lectureId, lectureUnitId); - long timeoutWebhookJob = 60; - TimeUnit unitWebhookJob = TimeUnit.MINUTES; - jobMap.put(token, job, timeoutWebhookJob, unitWebhookJob); + var job = new FaqIngestionWebhookJob(token, courseId, faqId); + jobMap.put(token, job, ingestionJobTimeout, TimeUnit.SECONDS); return token; } 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 5a3e47776ea2..09455be4f785 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 @@ -14,6 +14,7 @@ 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.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; 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; @@ -22,7 +23,8 @@ import de.tum.cit.aet.artemis.iris.service.pyris.job.ConsistencyCheckJob; 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.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; 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; @@ -163,7 +165,18 @@ private void removeJobIfTerminatedElseUpdate(List stages, PyrisJo * @param job the job that is updated * @param statusUpdate the status update */ - public void handleStatusUpdate(IngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { + public void handleStatusUpdate(LectureIngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); } + + /** + * Handles the status update of a FAQ ingestion job. + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(FaqIngestionWebhookJob job, PyrisFaqIngestionStatusUpdateDTO statusUpdate) { + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java index 395932a7157a..f6eed94dc71c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java @@ -10,6 +10,7 @@ import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -19,6 +20,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.service.FilePathService; @@ -27,6 +29,9 @@ import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqWebhookDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqDeletionExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureUnitWebhookDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureDeletionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; @@ -163,7 +168,7 @@ public String addLectureUnitToPyrisDB(AttachmentUnit attachmentUnit) { * @return jobToken if the job was created */ private String executeLectureDeletionWebhook(List toUpdateAttachmentUnits) { - String jobToken = pyrisJobService.addIngestionWebhookJob(0, 0, 0); + String jobToken = pyrisJobService.addLectureIngestionWebhookJob(0, 0, 0); PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); PyrisWebhookLectureDeletionExecutionDTO executionDTO = new PyrisWebhookLectureDeletionExecutionDTO(toUpdateAttachmentUnits, settingsDTO, List.of()); pyrisConnectorService.executeLectureDeletionWebhook(executionDTO); @@ -177,7 +182,8 @@ private String executeLectureDeletionWebhook(List to * @return jobToken if the job was created */ private String executeLectureAdditionWebhook(PyrisLectureUnitWebhookDTO toUpdateAttachmentUnit) { - String jobToken = pyrisJobService.addIngestionWebhookJob(toUpdateAttachmentUnit.courseId(), toUpdateAttachmentUnit.lectureId(), toUpdateAttachmentUnit.lectureUnitId()); + String jobToken = pyrisJobService.addLectureIngestionWebhookJob(toUpdateAttachmentUnit.courseId(), toUpdateAttachmentUnit.lectureId(), + toUpdateAttachmentUnit.lectureUnitId()); PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(toUpdateAttachmentUnit, settingsDTO, List.of()); pyrisConnectorService.executeLectureAddtionWebhook("fullIngestion", executionDTO); @@ -240,4 +246,88 @@ public Map getLectureUnitsIngestionState(long courseId, lo .collect(Collectors.toMap(DomainObject::getId, unit -> pyrisConnectorService.getLectureUnitIngestionState(courseId, lectureId, unit.getId()))); } + private boolean faqIngestionEnabled(Course course) { + var settings = irisSettingsService.getRawIrisSettingsFor(course).getIrisFaqIngestionSettings(); + return settings != null && settings.isEnabled(); + } + + /** + * send the updated / created faqs to Pyris for ingestion if autoLecturesUpdate is enabled. + * + * @param courseId Id of the course where the attachment is added + * @param newFaq the new faqs to be sent to pyris for ingestion + */ + public void autoUpdateFaqInPyris(Long courseId, Faq newFaq) { + IrisCourseSettings presentCourseSettings = null; + Optional courseSettings = irisSettingsRepository.findCourseSettings(courseId); + if (courseSettings.isPresent()) { + presentCourseSettings = courseSettings.get(); + } + + if (presentCourseSettings != null && presentCourseSettings.getIrisFaqIngestionSettings() != null && presentCourseSettings.getIrisFaqIngestionSettings().isEnabled() + && presentCourseSettings.getIrisFaqIngestionSettings().getAutoIngestOnFaqCreation()) { + addFaq(newFaq); + } + } + + /** + * adds the faq to Pyris. + * + * @param faq The faq that will be added to pyris + * @return jobToken if the job was created else null + */ + public String addFaq(Faq faq) { + if (faqIngestionEnabled(faq.getCourse())) { + return executeFaqAdditionWebhook(new PyrisFaqWebhookDTO(faq.getId(), faq.getQuestionTitle(), faq.getQuestionAnswer(), faq.getCourse().getId(), + faq.getCourse().getTitle(), faq.getCourse().getDescription())); + } + return null; + } + + /** + * executes the faq addition webhook to add faq to the vector database on pyris + * + * @param toUpdateFaq The faq that got Updated as webhook DTO + * @return jobToken if the job was created else null + */ + + private String executeFaqAdditionWebhook(PyrisFaqWebhookDTO toUpdateFaq) { + String jobToken = pyrisJobService.addFaqIngestionWebhookJob(toUpdateFaq.courseId(), toUpdateFaq.faqId()); + PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); + PyrisWebhookFaqIngestionExecutionDTO executionDTO = new PyrisWebhookFaqIngestionExecutionDTO(toUpdateFaq, settingsDTO, List.of()); + pyrisConnectorService.executeFaqAdditionWebhook(toUpdateFaq, executionDTO); + return jobToken; + + } + + /** + * delete the faqs in pyris + * + * @param faq The faqs that gets erased + * @return jobToken if the job was created + */ + public String deleteFaq(Faq faq) { + return executeFaqDeletionWebhook(new PyrisFaqWebhookDTO(faq.getId(), faq.getQuestionTitle(), faq.getQuestionAnswer(), faq.getCourse().getId(), faq.getCourse().getTitle(), + faq.getCourse().getDescription())); + + } + + /** + * executes the faq deletion webhook to delete faq from the vector database on pyris + * + * @param toUpdateFaqs The faq that got Updated as webhook DTO + * @return jobToken if the job was created else null + */ + private String executeFaqDeletionWebhook(PyrisFaqWebhookDTO toUpdateFaqs) { + String jobToken = pyrisJobService.addFaqIngestionWebhookJob(0, 0); + PyrisPipelineExecutionSettingsDTO settingsDTO = new PyrisPipelineExecutionSettingsDTO(jobToken, List.of(), artemisBaseUrl); + PyrisWebhookFaqDeletionExecutionDTO executionDTO = new PyrisWebhookFaqDeletionExecutionDTO(toUpdateFaqs, settingsDTO, List.of()); + pyrisConnectorService.executeFaqDeletionWebhook(executionDTO); + return jobToken; + } + + public IngestionState getFaqIngestionState(long courseId, long faqId) { + return pyrisConnectorService.getFaqIngestionState(courseId, faqId); + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java new file mode 100644 index 000000000000..fd1462a6eb58 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqIngestionStatusUpdateDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisFaqIngestionStatusUpdateDTO(String result, List stages, long jobId) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java new file mode 100644 index 000000000000..4387daa03364 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisFaqWebhookDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents a webhook data transfer object for an FAQ in the Pyris system. + * This DTO is used to encapsulate the information related to the faqs + * providing necessary details such as faqId the content as questionTitle and questionAnswer as well as the course description. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) + +public record PyrisFaqWebhookDTO(long faqId, String questionTitle, String questionAnswer, long courseId, String courseName, String courseDescription) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java new file mode 100644 index 000000000000..a98b8568d699 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqDeletionExecutionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisWebhookFaqDeletionExecutionDTO(PyrisFaqWebhookDTO pyrisFaqWebhookDTO, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java new file mode 100644 index 000000000000..fb7f18c73989 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/faqingestionwebhook/PyrisWebhookFaqIngestionExecutionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionSettingsDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisWebhookFaqIngestionExecutionDTO(PyrisFaqWebhookDTO pyrisFaqWebhookDTO, PyrisPipelineExecutionSettingsDTO settings, List initialStages) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java new file mode 100644 index 000000000000..324e970aef85 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/FaqIngestionWebhookJob.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; + +/** + * An implementation of a PyrisJob for Faq Ingestion in Pyris. + * This job is used to reference the details of then Ingestion when Pyris sends a status update. + */ +public record FaqIngestionWebhookJob(String jobId, long courseId, long faqId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return false; + } + + @Override + public boolean canAccess(Exercise exercise) { + return false; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java similarity index 80% rename from src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java rename to src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java index 5dbfe0955520..d4fe8171ad5a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/IngestionWebhookJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/LectureIngestionWebhookJob.java @@ -7,7 +7,7 @@ * An implementation of a PyrisJob for Lecture Ingestion in Pyris. * This job is used to reference the details of then Ingestion when Pyris sends a status update. */ -public record IngestionWebhookJob(String jobId, long courseId, long lectureId, long lectureUnitId) implements PyrisJob { +public record LectureIngestionWebhookJob(String jobId, long courseId, long lectureId, long lectureUnitId) implements PyrisJob { @Override public boolean canAccess(Course course) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java index 8ed823adee2c..a729345e396b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java @@ -35,6 +35,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisFaqIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisGlobalSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; @@ -112,6 +113,7 @@ private void createInitialGlobalSettings() { initializeIrisCourseChatSettings(settings); initializeIrisLectureIngestionSettings(settings); initializeIrisCompetencyGenerationSettings(settings); + initializeIrisFaqIngestionSettings(settings); irisSettingsRepository.save(settings); } @@ -156,10 +158,28 @@ private void initializeIrisCompetencyGenerationSettings(IrisGlobalSettings setti settings.setIrisCompetencyGenerationSettings(irisCompetencyGenerationSettings); } + /** + * Get the combined Iris settings for a course. + * Combines the global settings with the course settings. + * + * @return The combined Iris settings for the course + */ public IrisGlobalSettings getGlobalSettings() { return irisSettingsRepository.findGlobalSettingsElseThrow(); } + /** + * This method initializes the Iris faq settings for a course. + * + * @param settings The course settings + * @return The combined Iris settings for the course + */ + private void initializeIrisFaqIngestionSettings(IrisGlobalSettings settings) { + var irisFaqIngestionSubSettings = settings.getIrisFaqIngestionSettings(); + irisFaqIngestionSubSettings = initializeSettings(irisFaqIngestionSubSettings, IrisFaqIngestionSubSettings::new); + settings.setIrisFaqIngestionSettings(irisFaqIngestionSubSettings); + } + /** * Save the Iris settings. Should always be used over directly calling the repository. * Automatically decides whether to save a new Iris settings object or update an existing one. @@ -266,6 +286,12 @@ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSetti null, GLOBAL )); + existingSettings.setIrisFaqIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisFaqIngestionSettings(), + settingsUpdate.getIrisFaqIngestionSettings(), + null, + GLOBAL + )); // @formatter:on return irisSettingsRepository.save(existingSettings); @@ -310,6 +336,12 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti parentSettings.irisLectureIngestionSettings(), COURSE )); + existingSettings.setIrisFaqIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisFaqIngestionSettings(), + settingsUpdate.getIrisFaqIngestionSettings(), + parentSettings.irisFaqIngestionSettings(), + COURSE + )); existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( existingSettings.getIrisCompetencyGenerationSettings(), settingsUpdate.getIrisCompetencyGenerationSettings(), @@ -585,7 +617,8 @@ public IrisCombinedSettingsDTO getCombinedIrisGlobalSettings() { irisSubSettingsService.combineTextExerciseChatSettings(settingsList, false), irisSubSettingsService.combineCourseChatSettings(settingsList, false), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, false) ); // @formatter:on } @@ -611,7 +644,8 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Course course, boolean irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, minimal) ); // @formatter:on } @@ -638,7 +672,8 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Exercise exercise, boo irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), irisSubSettingsService.combineCourseChatSettings(settingsList, minimal), irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal), + irisSubSettingsService.combineFaqIngestionSubSettings(settingsList, minimal) ); // @formatter:on } @@ -669,6 +704,7 @@ public IrisCourseSettings getDefaultSettingsFor(Course course) { settings.setIrisCourseChatSettings(new IrisCourseChatSubSettings()); settings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); settings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); + settings.setIrisFaqIngestionSettings(new IrisFaqIngestionSubSettings()); return settings; } @@ -746,6 +782,7 @@ private boolean isFeatureEnabledInSettings(IrisCombinedSettingsDTO settings, Iri case COURSE_CHAT -> settings.irisCourseChatSettings().enabled(); case COMPETENCY_GENERATION -> settings.irisCompetencyGenerationSettings().enabled(); case LECTURE_INGESTION -> settings.irisLectureIngestionSettings().enabled(); + case FAQ_INGESTION -> settings.irisFaqIngestionSettings().enabled(); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index 6d0a03b002c2..e1a01ebdf00e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java @@ -20,6 +20,7 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisFaqIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettingsType; @@ -28,6 +29,7 @@ import de.tum.cit.aet.artemis.iris.dto.IrisCombinedChatSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedCompetencyGenerationSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedCourseChatSubSettingsDTO; +import de.tum.cit.aet.artemis.iris.dto.IrisCombinedFaqIngestionSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedLectureIngestionSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedTextExerciseChatSubSettingsDTO; @@ -195,6 +197,37 @@ public IrisLectureIngestionSubSettings update(IrisLectureIngestionSubSettings cu return currentSettings; } + /** + * Updates a FAQ Ingestion sub settings object. + * If the new settings are null, the current settings will be deleted (except if the parent settings are null == if the settings are global). + * Special notes: if the new Settings are null, we will return null. That means the sub-settings will be deleted. + * + * @param currentSettings Current FAQ Ingestion sub settings. + * @param newSettings Updated FAQ Ingestion sub settings. + * @param parentSettings Parent FAQ Ingestion sub settings. + * @param settingsType Type of the settings the sub settings belong to. + * @return Updated FAQ Ingestion sub settings. + */ + public IrisFaqIngestionSubSettings update(IrisFaqIngestionSubSettings currentSettings, IrisFaqIngestionSubSettings newSettings, + IrisCombinedFaqIngestionSubSettingsDTO parentSettings, IrisSettingsType settingsType) { + if (newSettings == null) { + if (parentSettings == null) { + throw new IllegalArgumentException("Cannot delete the FAQ Ingestion settings"); + } + return null; + } + if (currentSettings == null) { + currentSettings = new IrisFaqIngestionSubSettings(); + } + + if (authCheckService.isAdmin() && (settingsType == IrisSettingsType.COURSE || settingsType == IrisSettingsType.GLOBAL)) { + currentSettings.setEnabled(newSettings.isEnabled()); + currentSettings.setAutoIngestOnFaqCreation(newSettings.getAutoIngestOnFaqCreation()); + } + + return currentSettings; + } + /** * Updates a Competency Generation sub settings object. * If the new settings are null, the current settings will be deleted (except if the parent settings are null == if the settings are global). @@ -334,6 +367,20 @@ public IrisCombinedLectureIngestionSubSettingsDTO combineLectureIngestionSubSett return new IrisCombinedLectureIngestionSubSettingsDTO(enabled); } + /** + * Combines the FAQ Ingestion settings of multiple {@link IrisSettings} objects. + * If minimal is true, the returned object will only contain the enabled and rateLimit fields. + * The minimal version can safely be sent to students. + * + * @param settingsList List of {@link IrisSettings} objects to combine. + * @param minimal Whether to return a minimal version of the combined settings. + * @return Combined Lecture Ingestion settings. + */ + public IrisCombinedFaqIngestionSubSettingsDTO combineFaqIngestionSubSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisFaqIngestionSettings); + return new IrisCombinedFaqIngestionSubSettingsDTO(enabled); + } + /** * Combines the Competency Generation settings of multiple {@link IrisSettings} objects. * If minimal is true, the returned object will only contain the enabled field. diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java index 37e2983e39e0..64b15d06e2ab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisResource.java @@ -127,4 +127,32 @@ public ResponseEntity> getStatusOfLectureUnitsIngestio } } + /** + * Retrieves the ingestion state of a specific FAQ in a course by communicating with Pyris. + * + *

+ * This method sends a GET request to the external Pyris service to fetch the current ingestion + * state of a FAQ, identified by its ID. It constructs a request using the provided + * `courseId` and `faqId` and returns the state of the ingestion process (e.g., NOT_STARTED, + * IN_PROGRESS, DONE, ERROR). + *

+ * + * @param courseId the ID of the course the FAQ belongs to + * @param faqId the ID of the FAQ for which the ingestion state is being requested + * @return a {@link ResponseEntity} containing a map with the {@link IngestionState} of the FAQ, + */ + @GetMapping("courses/{courseId}/faqs/{faqId}/ingestion-state") + @EnforceAtLeastInstructorInCourse + public ResponseEntity> getStatusOfFaqIngestion(@PathVariable long courseId, @PathVariable long faqId) { + try { + Course course = courseRepository.findByIdElseThrow(courseId); + Map responseMap = Map.of(faqId, pyrisWebhookService.getFaqIngestionState(courseId, faqId)); + return ResponseEntity.ok(responseMap); + } + catch (PyrisConnectorException e) { + log.error("Error fetching ingestion state for faq {}", faqId, e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).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 933a8ed99d11..d3b816bb7dd3 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,13 +23,15 @@ 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.consistencyCheck.PyrisConsistencyCheckStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; 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.ConsistencyCheckJob; 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.FaqIngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.LectureIngestionWebhookJob; 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; @@ -221,11 +223,35 @@ public ResponseEntity setStatusOfIngestionJob(@PathVariable String runId, if (!job.jobId().equals(runId)) { throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); } - if (!(job instanceof IngestionWebhookJob ingestionWebhookJob)) { + if (!(job instanceof LectureIngestionWebhookJob lectureIngestionWebhookJob)) { throw new ConflictException("Run ID is not an ingestion job", "Job", "invalidRunId"); } - pyrisStatusUpdateService.handleStatusUpdate(ingestionWebhookJob, statusUpdateDTO); + pyrisStatusUpdateService.handleStatusUpdate(lectureIngestionWebhookJob, statusUpdateDTO); + return ResponseEntity.ok().build(); + } + + /** + * {@code POST /api/public/pyris/webhooks/ingestion/faqs/runs/{runId}/status} : Set the status of an Ingestion job. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + * @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 + */ + @PostMapping("webhooks/ingestion/faqs/runs/{runId}/status") + @EnforceNothing + public ResponseEntity setStatusOfFaqIngestionJob(@PathVariable String runId, @RequestBody PyrisFaqIngestionStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { + PyrisJob job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, PyrisJob.class); + if (!job.jobId().equals(runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); + } + if (!(job instanceof FaqIngestionWebhookJob faqIngestionWebhookJob)) { + throw new ConflictException("Run ID is not an ingestion job", "Job", "invalidRunId"); + } + pyrisStatusUpdateService.handleStatusUpdate(faqIngestionWebhookJob, statusUpdateDTO); return ResponseEntity.ok().build(); } } diff --git a/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml b/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml new file mode 100644 index 000000000000..ddfd6f8a333c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241217122200_changelog.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index c8c467f527a3..3cc6e0e003f1 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -41,9 +41,10 @@ - - + + + diff --git a/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts index 270a2d5132e9..760895e64d45 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-settings.model.ts @@ -3,6 +3,7 @@ import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, IrisCourseChatSubSettings, + IrisFaqIngestionSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -21,6 +22,7 @@ export abstract class IrisSettings implements BaseEntity { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisGlobalSettings implements IrisSettings { @@ -31,6 +33,7 @@ export class IrisGlobalSettings implements IrisSettings { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisCourseSettings implements IrisSettings { @@ -42,6 +45,7 @@ export class IrisCourseSettings implements IrisSettings { irisCourseChatSettings?: IrisCourseChatSubSettings; irisLectureIngestionSettings?: IrisLectureIngestionSubSettings; irisCompetencyGenerationSettings?: IrisCompetencyGenerationSubSettings; + irisFaqIngestionSettings?: IrisFaqIngestionSubSettings; } export class IrisExerciseSettings implements IrisSettings { diff --git a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts index 1fc44fbf261d..f3c32d00304d 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts @@ -6,6 +6,7 @@ export enum IrisSubSettingsType { COURSE_CHAT = 'course-chat', LECTURE_INGESTION = 'lecture-ingestion', COMPETENCY_GENERATION = 'competency-generation', + FAQ_INGESTION = 'faq-ingestion', } export enum IrisEventType { @@ -46,6 +47,11 @@ export class IrisLectureIngestionSubSettings extends IrisSubSettings { autoIngestOnLectureAttachmentUpload: boolean; } +export class IrisFaqIngestionSubSettings extends IrisSubSettings { + type = IrisSubSettingsType.FAQ_INGESTION; + autoIngestOnFaqCreation: boolean; +} + export class IrisCompetencyGenerationSubSettings extends IrisSubSettings { type = IrisSubSettingsType.COMPETENCY_GENERATION; } diff --git a/src/main/webapp/app/faq/faq.component.html b/src/main/webapp/app/faq/faq.component.html index 5ec38d9468a0..7c8ab88ab683 100644 --- a/src/main/webapp/app/faq/faq.component.html +++ b/src/main/webapp/app/faq/faq.component.html @@ -40,11 +40,17 @@

} -
+
+ @if (isAtLeastInstructor && faqIngestionEnabled) { + + }
diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts index 17d721b303c1..9d1f8f3592a2 100644 --- a/src/main/webapp/app/faq/faq.component.ts +++ b/src/main/webapp/app/faq/faq.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { Faq, FaqState } from 'app/entities/faq.model'; -import { faCancel, faCheck, faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCancel, faCheck, faEdit, faFileExport, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { debounceTime, map } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -18,6 +18,10 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { AccountService } from 'app/core/auth/account.service'; import { Course } from 'app/entities/course.model'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; + import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; @Component({ @@ -35,6 +39,8 @@ export class FaqComponent implements OnInit, OnDestroy { courseId: number; hasCategories = false; isAtLeastInstructor = false; + faqIngestionEnabled = false; + irisEnabled = false; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); @@ -54,12 +60,22 @@ export class FaqComponent implements OnInit, OnDestroy { protected readonly faSort = faSort; protected readonly faCancel = faCancel; protected readonly faCheck = faCheck; + protected readonly faFileExport = faFileExport; private faqService = inject(FaqService); private route = inject(ActivatedRoute); private alertService = inject(AlertService); private sortService = inject(SortService); private accountService = inject(AccountService); + private profileService = inject(ProfileService); + private irisSettingsService = inject(IrisSettingsService); + + private profileInfoSubscription: Subscription; + + constructor() { + this.predicate = 'id'; + this.ascending = true; + } ngOnInit() { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); @@ -75,12 +91,21 @@ export class FaqComponent implements OnInit, OnDestroy { this.isAtLeastInstructor = this.accountService.isAtLeastInstructorInCourse(course); } }); + this.profileInfoSubscription = this.profileService.getProfileInfo().subscribe(async (profileInfo) => { + this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); + if (this.irisEnabled) { + this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { + this.faqIngestionEnabled = settings?.irisFaqIngestionSettings?.enabled || false; + }); + } + }); } ngOnDestroy(): void { this.dialogErrorSource.complete(); this.searchInput.complete(); this.routeDataSubscription?.unsubscribe(); + this.profileInfoSubscription?.unsubscribe(); } deleteFaq(courseId: number, faqId: number) { @@ -175,4 +200,15 @@ export class FaqComponent implements OnInit, OnDestroy { acceptProposedFaq(courseId: number, faq: Faq) { this.updateFaqState(courseId, faq, FaqState.ACCEPTED, 'artemisApp.faq.accepted'); } + + ingestFaqsInPyris() { + if (this.faqs.first()) { + this.faqService.ingestFaqsInPyris(this.courseId).subscribe({ + next: () => this.alertService.success('artemisApp.iris.ingestionAlert.allFaqsSuccess'), + error: () => { + this.alertService.error('artemisApp.iris.ingestionAlert.allFaqsError'); + }, + }); + } + } } diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index 2b8e8a5dd546..05cad6636f90 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Faq, FaqState } from 'app/entities/faq.model'; @@ -159,4 +159,15 @@ export class FaqService { observe: 'response', }); } + + /** + * Trigger the Ingestion of all Faqs in the course. + */ + ingestFaqsInPyris(courseId: number): Observable> { + const params = new HttpParams(); + return this.http.post(`api/courses/${courseId}/faqs/ingest`, null, { + params: params, + observe: 'response', + }); + } } diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html index f97278bc7a90..daf2995adb7f 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html @@ -72,6 +72,25 @@

+
+

+ +
+ @if (settingsType === COURSE) { +
+ + +
+ } +
} @if (settingsType !== EXERCISE) {
diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts index 9cb22b67a5cc..4bd67c371d66 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.ts @@ -12,6 +12,7 @@ import { IrisChatSubSettings, IrisCompetencyGenerationSubSettings, IrisCourseChatSubSettings, + IrisFaqIngestionSubSettings, IrisLectureIngestionSubSettings, IrisTextExerciseChatSubSettings, } from 'app/entities/iris/settings/iris-sub-settings.model'; @@ -43,6 +44,7 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa originalIrisSettings?: IrisSettings; public autoLectureIngestion = false; + public autoFaqIngestion = false; // Status bools isLoading = false; @@ -95,6 +97,7 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa this.fillEmptyIrisSubSettings(); this.originalIrisSettings = cloneDeep(settings); this.autoLectureIngestion = this.irisSettings?.irisLectureIngestionSettings?.autoIngestOnLectureAttachmentUpload ?? false; + this.autoFaqIngestion = this.irisSettings?.irisFaqIngestionSettings?.autoIngestOnFaqCreation ?? false; this.isDirty = false; }); this.loadParentIrisSettingsObservable().subscribe((settings) => { @@ -124,6 +127,9 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa if (!this.irisSettings.irisCompetencyGenerationSettings) { this.irisSettings.irisCompetencyGenerationSettings = new IrisCompetencyGenerationSubSettings(); } + if (!this.irisSettings.irisFaqIngestionSettings) { + this.irisSettings.irisFaqIngestionSettings = new IrisFaqIngestionSubSettings(); + } } saveIrisSettings(): void { @@ -131,6 +137,9 @@ export class IrisSettingsUpdateComponent implements OnInit, DoCheck, ComponentCa if (this.irisSettings && this.irisSettings.irisLectureIngestionSettings) { this.irisSettings.irisLectureIngestionSettings.autoIngestOnLectureAttachmentUpload = this.autoLectureIngestion; } + if (this.irisSettings && this.irisSettings.irisFaqIngestionSettings) { + this.irisSettings.irisFaqIngestionSettings.autoIngestOnFaqCreation = this.autoFaqIngestion; + } this.saveIrisSettingsObservable().subscribe( (response) => { this.isSaving = false; diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 76440a96998b..47bcbad36b3f 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -8,7 +8,8 @@ "accept": "FAQ akzeptieren", "reject": "FAQ ablehnen", "filterLabel": "Filter", - "createOrEditLabel": "FAQ erstellen oder bearbeiten" + "createOrEditLabel": "FAQ erstellen oder bearbeiten", + "ingestLabel": "FAQs an IRIS schicken" }, "created": "Die FAQ {{ title }} wurde erfolgreich erstellt", "updated": "Die FAQ {{ title }} wurde erfolgreich aktualisiert", diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 2c01b1e3679d..19e77bf84ed4 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -31,6 +31,10 @@ "title": "Vorlesungen Erfassung Einstellungen", "autoIngestOnAttachmentUpload": "Vorlesungen automatisch an Pyris senden" }, + "faqIngestionSettings": { + "title": "FAQ Ingestion Settings", + "autoIngest": "FAQs automatisch an Pyris senden" + }, "competencyGenerationSettings": "Kompetenzgenerierung Einstellungen", "proactivityBuildFailedEventEnabled": { "label": "Build-Fehler überwachen", @@ -111,7 +115,9 @@ "lectureSuccess": "Vorlesung Ingestion in Pyris erfolgreich gestartet", "lectureError": "Fehler beim Senden der Vorlesung an Pyris", "allLecturesSuccess": "Alle Vorlesungen Ingestion in Pyris erfolgreich gestartet", + "allFaqsSuccess": "Alle Faqs Ingestion in Pyris erfolgreich gestartet", "allLecturesError": "Fehler beim Senden aller Vorlesungen an Pyris", + "allFaqsError": "Fehler beim Senden aller FAQs an Pyris", "lectureNotFound": "Vorlesung kann nicht an Pyris gesendet werden, keine Vorlesung mit der angegebenen ID gefunden.", "pyrisUnavailable": "Pyris ist derzeit nicht verfügbar.", "pyrisError": "Ein Fehler ist bei der Kommunikation mit Pyris aufgetreten." diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index 98f9fc345060..0ef576fcc937 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -8,7 +8,8 @@ "accept": "Accept FAQ", "reject": "Reject FAQ", "filterLabel": "Filter", - "createOrEditLabel": "Create or edit FAQ" + "createOrEditLabel": "Create or edit FAQ", + "ingestLabel": "Send FAQs to Iris" }, "created": "The FAQ {{ title }} was successfully created", "updated": "The FAQ {{ title }} was successfully updated", diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index fd12226bb148..6ac25267bd09 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -31,6 +31,10 @@ "title": "Lecture Ingestion Settings", "autoIngestOnAttachmentUpload": "Send Lectures To Pyris Automatically" }, + "faqIngestionSettings": { + "title": "FAQ Ingestion Settings", + "autoIngest": "Send FAQs To Pyris Automatically" + }, "competencyGenerationSettings": "Competency Generation Settings", "proactivityBuildFailedEventEnabled": { "label": "Monitor submission build failures", @@ -110,8 +114,10 @@ "lectureUnitError": "Error while sending lecture unit to Pyris", "lectureSuccess": "Lecture ingestion in Pyris started successfully", "lectureError": "Error while sending lecture to Pyris", - "allLecturesSuccess": "All lectures ingestion in Pyris started successfully", + "allLecturesSuccess": "All lecture ingestion in Pyris started successfully", + "allFaqsSuccess": "All Faq ingestion in Pyris started successfully", "allLecturesError": "Error while sending all lectures to Pyris", + "allFaqsError": "Error while sending all Faqs to Pyris", "lectureNotFound": "Could not send lecture to Iris, no lecture found with the provided id.", "pyrisUnavailable": "Pyris is currently unavailable.", "pyrisError": "An error occurred while getting ingestion state from Pyris." diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java index a7d071e75553..c0d714fb54e8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.connector.IrisRequestMockProvider; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest; @@ -28,6 +29,9 @@ class FaqIntegrationTest extends AbstractSpringIntegrationIndependentTest { @Autowired private FaqRepository faqRepository; + @Autowired + private IrisRequestMockProvider irisRequestMockProvider; + private Course course1; private Course course2; @@ -46,6 +50,7 @@ void initTestCase() throws Exception { // Add users that are not in the course userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); userUtilService.createAndSaveUser(TEST_PREFIX + "instructor42"); + irisRequestMockProvider.enableMockingOfRequests(); } @@ -164,6 +169,9 @@ void testGetFaqByFaqId_shouldNotGet_IdMismatch() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void deleteFaq_shouldDeleteFAQ() throws Exception { Faq faq = faqRepository.findById(this.faq.getId()).orElseThrow(EntityNotFoundException::new); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); request.delete("/api/courses/" + faq.getCourse().getId() + "/faqs/" + faq.getId(), HttpStatus.OK); Optional faqOptional = faqRepository.findById(faq.getId()); assertThat(faqOptional).isEmpty(); 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 a71d07785f7b..3e6f17002d2c 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 @@ -36,6 +36,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.exercise.PyrisExerciseChatPipelineExecutionDTO; 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.faqingestionwebhook.PyrisWebhookFaqIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.rewriting.PyrisRewritingPipelineExecutionDTO; @@ -186,6 +187,15 @@ public void mockIngestionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/faqs")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisWebhookFaqIngestionExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockDeletionWebhookRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/lectures/delete")).andExpect(method(HttpMethod.POST)).andRespond(request -> { var mockRequest = (MockClientHttpRequest) request; @@ -195,6 +205,15 @@ public void mockDeletionWebhookRunResponse(Consumer responseConsumer) { + mockServer.expect(ExpectedCount.once(), requestTo(webhooksApiURL + "/faqs/delete")).andExpect(method(HttpMethod.POST)).andRespond(request -> { + var mockRequest = (MockClientHttpRequest) request; + var dto = mapper.readValue(mockRequest.getBodyAsString(), PyrisWebhookFaqIngestionExecutionDTO.class); + responseConsumer.accept(dto); + return MockRestResponseCreators.withRawStatus(HttpStatus.ACCEPTED.value()).createResponse(request); + }); + } + public void mockBuildFailedRunResponse(Consumer responseConsumer) { mockServer.expect(ExpectedCount.max(2), requestTo(pipelinesApiURL + "/tutor-chat/default/run?event=build_failed")).andExpect(method(HttpMethod.POST)) .andRespond(request -> { diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java index b7866bd171b3..b378805763b9 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/AbstractIrisIntegrationTest.java @@ -63,6 +63,7 @@ protected void activateIrisGlobally() { activateSubSettings(globalSettings.getIrisCourseChatSettings()); activateSubSettings(globalSettings.getIrisLectureIngestionSettings()); activateSubSettings(globalSettings.getIrisCompetencyGenerationSettings()); + activateSubSettings(globalSettings.getIrisFaqIngestionSettings()); irisSettingsRepository.save(globalSettings); } @@ -85,6 +86,7 @@ protected void activateIrisFor(Course course) { activateSubSettings(courseSettings.getIrisCourseChatSettings()); activateSubSettings(courseSettings.getIrisCompetencyGenerationSettings()); activateSubSettings(courseSettings.getIrisLectureIngestionSettings()); + activateSubSettings(courseSettings.getIrisFaqIngestionSettings()); irisSettingsRepository.save(courseSettings); } diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java new file mode 100644 index 000000000000..85b9e4e5e101 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisFaqIngestionTest.java @@ -0,0 +1,280 @@ +package de.tum.cit.aet.artemis.iris; + +import static de.tum.cit.aet.artemis.communication.FaqFactory.generateFaq; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; + +import de.tum.cit.aet.artemis.communication.FaqFactory; +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.user.util.UserUtilService; +import de.tum.cit.aet.artemis.core.util.CourseUtilService; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; +import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisStatusUpdateService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.faqingestionwebhook.PyrisFaqIngestionStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageState; + +class PyrisFaqIngestionTest extends AbstractIrisIntegrationTest { + + private static final String TEST_PREFIX = "pyrisfaqingestiontest"; + + @Autowired + private PyrisWebhookService pyrisWebhookService; + + @Autowired + private FaqRepository faqRepository; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + protected PyrisStatusUpdateService pyrisStatusUpdateService; + + @Autowired + protected PyrisJobService pyrisJobService; + + @Autowired + protected IrisSettingsRepository irisSettingsRepository; + + private Faq faq1; + + private Course course1; + + private long courseId; + + @BeforeEach + void initTestCase() throws Exception { + userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); + List courses = courseUtilService.createCoursesWithExercisesAndLectures(TEST_PREFIX, true, true, 1); + this.course1 = this.courseRepository.findByIdWithExercisesAndExerciseDetailsAndLecturesElseThrow(courses.getFirst().getId()); + long courseId = course1.getId(); + this.faq1 = generateFaq(course1, FaqState.ACCEPTED, "Faq 1 title", "Faq 1 content"); + faqRepository.save(faq1); + // Add users that are not in the course + userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); + userUtilService.createAndSaveUser(TEST_PREFIX + "tutor42"); + userUtilService.createAndSaveUser(TEST_PREFIX + "instructor42"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void autoIngestionWhenFaqIsCreatedAndAutoUpdateEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(faq1.getCourse()); + courseSettings.getIrisFaqIngestionSettings().setAutoIngestOnFaqCreation(true); + this.irisSettingsRepository.save(courseSettings); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + Faq newFaq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "title", "answer"); + Faq returnedFaq = request.postWithResponseBody("/api/courses/" + course1.getId() + "/faqs", newFaq, Faq.class, HttpStatus.CREATED); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void noAutoIngestionWhenFaqIsCreatedAndAutoUpdateEnabled() throws Exception { + + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + Faq newFaq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "title", "answer"); + Faq returnedFaq = request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs", newFaq, Faq.class, HttpStatus.CREATED); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testIngestFaqButtonInPyris() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest", Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFaqFromPyrisDatabaseWithCourseSettingsEnabled() { + activateIrisFor(faq1.getCourse()); + IrisCourseSettings courseSettings = irisSettingsService.getRawIrisSettingsFor(faq1.getCourse()); + this.irisSettingsRepository.save(courseSettings); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.deleteFaq(faq1); + assertThat(jobToken).isNotNull(); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddFaqToPyrisDBAddJobWithCourseSettingsEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest", Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddSpecificFaqToPyrisDBAddJobWithCourseSettingsEnabled() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + request.postWithResponseBody("/api/courses/" + faq1.getCourse().getId() + "/faqs/ingest?faqId=" + faq1.getId(), Optional.empty(), boolean.class, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAllStagesDoneIngestionStateDone() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAllStagesDoneRemovesDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testStageNotDoneKeepsAdditionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); + + } + + @Test + void testStageNotDoneKeepsDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO doneStage = new PyrisStageDTO("done", 1, PyrisStageState.DONE, "Stage completed successfully."); + PyrisStageDTO inProgressStage = new PyrisStageDTO("inProgressStage", 1, PyrisStageState.IN_PROGRESS, "Stage completed successfully."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(doneStage, inProgressStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNotNull(); + + } + + @Test + void testErrorStageRemovesDeletionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqDeletionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + + String jobToken = pyrisWebhookService.deleteFaq(faq1); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testErrorStageRemovesAdditionIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String jobToken = pyrisWebhookService.addFaq(faq1); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + jobToken)))); + request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + jobToken + "/status", statusUpdate, HttpStatus.OK, headers); + assertThat(pyrisJobService.getJob(jobToken)).isNull(); + + } + + @Test + void testRunIdIngestionJob() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String newJobToken = pyrisJobService.addFaqIngestionWebhookJob(123L, faq1.getId()); + String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); + MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, + HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID in URL does not match run ID in request body"); + response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + chatJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID is not an ingestion job"); + } + + @Test + void testIngestionJobDone() throws Exception { + activateIrisFor(faq1.getCourse()); + irisRequestMockProvider.mockFaqIngestionWebhookRunResponse(dto -> { + assertThat(dto.settings().authenticationToken()).isNotNull(); + }); + String newJobToken = pyrisJobService.addFaqIngestionWebhookJob(123L, faq1.getId()); + String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); + PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); + PyrisFaqIngestionStatusUpdateDTO statusUpdate = new PyrisFaqIngestionStatusUpdateDTO("Success", List.of(errorStage), faq1.getId()); + var headers = new HttpHeaders(new LinkedMultiValueMap<>(Map.of("Authorization", List.of("Bearer " + chatJobToken)))); + MockHttpServletResponse response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/runs/" + newJobToken + "/status", statusUpdate, + HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID in URL does not match run ID in request body"); + response = request.postWithoutResponseBody("/api/public/pyris/webhooks/ingestion/faqs/runs/" + chatJobToken + "/status", statusUpdate, HttpStatus.CONFLICT, headers); + assertThat(response.getContentAsString()).contains("Run ID is not an ingestion job"); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java index 636d18003de1..95715a5fdff7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisLectureIngestionTest.java @@ -277,7 +277,7 @@ void testRunIdIngestionJob() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); + String newJobToken = pyrisJobService.addLectureIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); @@ -295,7 +295,7 @@ void testIngestionJobDone() throws Exception { irisRequestMockProvider.mockIngestionWebhookRunResponse(dto -> { assertThat(dto.settings().authenticationToken()).isNotNull(); }); - String newJobToken = pyrisJobService.addIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); + String newJobToken = pyrisJobService.addLectureIngestionWebhookJob(123L, lecture1.getId(), lecture1.getLectureUnits().getFirst().getId()); String chatJobToken = pyrisJobService.addCourseChatJob(123L, 123L); PyrisStageDTO errorStage = new PyrisStageDTO("error", 1, PyrisStageState.ERROR, "Stage not broke due to error."); PyrisLectureIngestionStatusUpdateDTO statusUpdate = new PyrisLectureIngestionStatusUpdateDTO("Success", List.of(errorStage), lecture1.getLectureUnits().getFirst().getId()); diff --git a/src/test/javascript/spec/component/account/account-information.component.spec.ts b/src/test/javascript/spec/component/account/account-information.component.spec.ts index df1f4575d02c..855151d1e99e 100644 --- a/src/test/javascript/spec/component/account/account-information.component.spec.ts +++ b/src/test/javascript/spec/component/account/account-information.component.spec.ts @@ -1,55 +1,126 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AccountInformationComponent } from 'app/shared/user-settings/account-information/account-information.component'; import { AccountService } from 'app/core/auth/account.service'; -import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; -import { of } from 'rxjs'; +import { UserSettingsService } from 'app/shared/user-settings/user-settings.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of, throwError } from 'rxjs'; import { User } from 'app/core/user/user.model'; import { ArtemisTestModule } from '../../test.module'; -import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; -import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; -import { TranslateService } from '@ngx-translate/core'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { PROFILE_LOCALVC } from 'app/app.constants'; +import { HttpResponse, HttpErrorResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; describe('AccountInformationComponent', () => { let fixture: ComponentFixture; let comp: AccountInformationComponent; - let accountServiceMock: { getAuthenticationState: jest.Mock; addSshPublicKey: jest.Mock }; - let profileServiceMock: { getProfileInfo: jest.Mock }; - let translateService: TranslateService; + let accountServiceMock: { getAuthenticationState: jest.Mock }; + let userSettingsServiceMock: { updateProfilePicture: jest.Mock; removeProfilePicture: jest.Mock }; + let modalServiceMock: { open: jest.Mock }; + let alertServiceMock: { addAlert: jest.Mock }; beforeEach(async () => { - profileServiceMock = { - getProfileInfo: jest.fn(), - }; accountServiceMock = { getAuthenticationState: jest.fn(), - addSshPublicKey: jest.fn(), + }; + userSettingsServiceMock = { + updateProfilePicture: jest.fn(), + removeProfilePicture: jest.fn(), + }; + modalServiceMock = { + open: jest.fn(), + }; + alertServiceMock = { + addAlert: jest.fn(), }; await TestBed.configureTestingModule({ imports: [ArtemisTestModule], providers: [ { provide: AccountService, useValue: accountServiceMock }, - { provide: ProfileService, useValue: profileServiceMock }, - { provide: TranslateService, useClass: MockTranslateService }, - { provide: NgbModal, useClass: MockNgbModalService }, + { provide: UserSettingsService, useValue: userSettingsServiceMock }, + { provide: NgbModal, useValue: modalServiceMock }, + { provide: AlertService, useValue: alertServiceMock }, ], }).compileComponents(); fixture = TestBed.createComponent(AccountInformationComponent); comp = fixture.componentInstance; - translateService = TestBed.inject(TranslateService); - translateService.currentLang = 'en'; }); beforeEach(() => { - profileServiceMock.getProfileInfo.mockReturnValue(of({ activeProfiles: [PROFILE_LOCALVC] })); accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 99 } as User)); + comp.ngOnInit(); }); - it('should initialize with localVC profile', async () => { - comp.ngOnInit(); + it('should initialize and fetch current user', () => { expect(accountServiceMock.getAuthenticationState).toHaveBeenCalled(); + expect(comp.currentUser).toEqual({ id: 99 }); + }); + + it('should open image cropper modal when setting user image', () => { + const event = { currentTarget: { files: [new File([''], 'test.jpg', { type: 'image/jpeg' })] } } as unknown as Event; + modalServiceMock.open.mockReturnValue({ componentInstance: {}, result: Promise.resolve('data:image/jpeg;base64,test') }); + + comp.setUserImage(event); + + expect(modalServiceMock.open).toHaveBeenCalled(); + }); + + it('should call removeProfilePicture when deleting user image', () => { + userSettingsServiceMock.removeProfilePicture.mockReturnValue(of(new HttpResponse({ status: 200 }))); + + comp.deleteUserImage(); + + expect(userSettingsServiceMock.removeProfilePicture).toHaveBeenCalled(); + }); + + it('should update user image on successful upload', () => { + const userResponse = new HttpResponse({ + body: { + imageUrl: 'new-image-url', + internal: false, + }, + }); + userSettingsServiceMock.updateProfilePicture.mockReturnValue(of(userResponse)); + + comp['subscribeToUpdateProfilePictureResponse'](userSettingsServiceMock.updateProfilePicture()); + + expect(comp.currentUser!.imageUrl).toBe('new-image-url'); + }); + + it('should show error alert when image upload fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Upload failed' }, status: 400 }); + userSettingsServiceMock.updateProfilePicture.mockReturnValue(throwError(() => errorResponse)); + + comp['subscribeToUpdateProfilePictureResponse'](userSettingsServiceMock.updateProfilePicture()); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith(expect.objectContaining({ message: 'Upload failed' })); + }); + + it('should show error alert when profile picture removal fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Removal failed' }, status: 400 }); + + comp['onProfilePictureRemoveError'](errorResponse); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.anything(), + message: 'Removal failed', + disableTranslation: true, + }), + ); + }); + + it('should show error alert when profile picture upload fails', () => { + const errorResponse = new HttpErrorResponse({ error: { title: 'Upload failed' }, status: 400 }); + + comp['onProfilePictureUploadError'](errorResponse); + + expect(alertServiceMock.addAlert).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.anything(), + message: 'Upload failed', + disableTranslation: true, + }), + ); }); }); diff --git a/src/test/javascript/spec/component/faq/faq.component.spec.ts b/src/test/javascript/spec/component/faq/faq.component.spec.ts index 2bd3be3da311..f345c9ff6650 100644 --- a/src/test/javascript/spec/component/faq/faq.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq.component.spec.ts @@ -20,6 +20,12 @@ import { AlertService } from 'app/core/util/alert.service'; import { SortService } from 'app/shared/service/sort.service'; import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; import { AccountService } from 'app/core/auth/account.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; +import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; import 'jest-extended'; function createFaq(id: number, category: string, color: string): Faq { @@ -40,6 +46,8 @@ describe('FaqComponent', () => { let alertServiceStub: jest.SpyInstance; let alertService: AlertService; let sortService: SortService; + let profileService: ProfileService; + let irisSettingsService: IrisSettingsService; let faq1: Faq; let faq2: Faq; @@ -55,6 +63,10 @@ describe('FaqComponent', () => { courseId = 1; + const profileInfo = { + activeProfiles: [], + } as unknown as ProfileInfo; + TestBed.configureTestingModule({ imports: [ArtemisTestModule, MockModule(ArtemisMarkdownEditorModule), MockModule(BrowserAnimationsModule)], declarations: [FaqComponent, MockRouterLinkDirective, MockComponent(CustomExerciseCategoryBadgeComponent)], @@ -73,6 +85,7 @@ describe('FaqComponent', () => { }, }, }, + { provide: ProfileService, useValue: new MockProfileService() }, MockProvider(FaqService, { findAllByCourseId: () => { return of( @@ -110,6 +123,12 @@ describe('FaqComponent', () => { faqService = TestBed.inject(FaqService); alertService = TestBed.inject(AlertService); sortService = TestBed.inject(SortService); + + profileService = TestBed.inject(ProfileService); + irisSettingsService = TestBed.inject(IrisSettingsService); + + profileService = faqComponentFixture.debugElement.injector.get(ProfileService); + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfo)); }); }); @@ -225,4 +244,37 @@ describe('FaqComponent', () => { expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); expect(faq1.faqState).toEqual(FaqState.PROPOSED); }); + + it('should call the service to ingest faqs when ingestFaqsInPyris is called', () => { + faqComponent.faqs = [faq1]; + const ingestSpy = jest.spyOn(faqService, 'ingestFaqsInPyris').mockImplementation(() => of(new HttpResponse({ status: 200 }))); + faqComponent.ingestFaqsInPyris(); + expect(ingestSpy).toHaveBeenCalledWith(faq1.course?.id); + expect(ingestSpy).toHaveBeenCalledOnce(); + }); + + it('should log error when error occurs', () => { + alertServiceStub = jest.spyOn(alertService, 'error'); + faqComponent.faqs = [faq1]; + jest.spyOn(faqService, 'ingestFaqsInPyris').mockReturnValue(throwError(() => new Error('Error while ingesting'))); + faqComponent.ingestFaqsInPyris(); + expect(alertServiceStub).toHaveBeenCalledOnce(); + }); + + it('should set faqIngestionEnabled based on service response', () => { + faqComponent.faqs = [faq1]; + const profileInfoResponse = { + activeProfiles: [PROFILE_IRIS], + } as ProfileInfo; + const irisSettingsResponse = { + irisFaqIngestionSettings: { + enabled: true, + }, + } as IrisCourseSettings; + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of(profileInfoResponse)); + jest.spyOn(irisSettingsService, 'getCombinedCourseSettings').mockImplementation(() => of(irisSettingsResponse)); + faqComponent.ngOnInit(); + expect(irisSettingsService.getCombinedCourseSettings).toHaveBeenCalledWith(faqComponent.courseId); + expect(faqComponent.faqIngestionEnabled).toBeTrue(); + }); }); diff --git a/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts index 0b81f07452e8..aecfbc6d167e 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-course-settings-update.component.spec.ts @@ -62,7 +62,7 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(getSettingsSpy).toHaveBeenCalledWith(1); expect(getParentSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(6); }); it('Can deactivate correctly', () => { @@ -95,5 +95,6 @@ describe('IrisCourseSettingsUpdateComponent Component', () => { expect(comp.settingsUpdateComponent!.irisSettings.irisCourseChatSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisLectureIngestionSettings).toBeTruthy(); expect(comp.settingsUpdateComponent!.irisSettings.irisCompetencyGenerationSettings).toBeTruthy(); + expect(comp.settingsUpdateComponent!.irisSettings.irisFaqIngestionSettings).toBeTruthy(); }); }); diff --git a/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts index b9bf9836cbbe..e9f260992f55 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-global-settings-update.component.spec.ts @@ -49,7 +49,7 @@ describe('IrisGlobalSettingsUpdateComponent Component', () => { expect(comp.settingsUpdateComponent).toBeTruthy(); expect(getSettingsSpy).toHaveBeenCalledOnce(); - expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(5); + expect(fixture.debugElement.queryAll(By.directive(IrisCommonSubSettingsUpdateComponent))).toHaveLength(6); }); it('Can deactivate correctly', () => { diff --git a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts index d6718670380e..24931c0ee096 100644 --- a/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture-detail.component.spec.ts @@ -104,6 +104,7 @@ describe('LectureDetailComponent', () => { expect(ingestSpy).toHaveBeenCalledWith(mockLecture.course?.id, mockLecture.id); expect(ingestSpy).toHaveBeenCalledOnce(); }); + it('should log error when error occurs', () => { component.lecture = mockLecture; jest.spyOn(lectureService, 'ingestLecturesInPyris').mockReturnValue(throwError(() => new Error('Error while ingesting'))); diff --git a/src/test/javascript/spec/service/faq.service.spec.ts b/src/test/javascript/spec/service/faq.service.spec.ts index 73240db5f74d..a0b70974320b 100644 --- a/src/test/javascript/spec/service/faq.service.spec.ts +++ b/src/test/javascript/spec/service/faq.service.spec.ts @@ -257,5 +257,21 @@ describe('Faq Service', () => { expect(service.hasSearchTokens(faq1, 'title answer')).toBeTrue(); expect(service.hasSearchTokens(faq1, 'title answer missing')).toBeFalse(); }); + + it('should send a POST request to ingest faqs and return an OK response', () => { + const courseId = 123; + const expectedUrl = `api/courses/${courseId}/faqs/ingest`; + const expectedStatus = 200; + + service.ingestFaqsInPyris(courseId).subscribe((response) => { + expect(response.status).toBe(expectedStatus); + }); + + const req = httpMock.expectOne({ + url: expectedUrl, + method: 'POST', + }); + expect(req.request.method).toBe('POST'); + }); }); }); From f107b9182453b309c6e737070a8ea870fd0a70b5 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:24:38 +0100 Subject: [PATCH 13/17] Programming exercises: Improve flexibility of authentication methods in the code popup (#10147) --- .../aet/artemis/core/config/Constants.java | 4 +- .../service/gitlab/GitlabInfoContributor.java | 14 +- .../localvc/LocalVCInfoContributor.java | 17 +- .../resources/config/application-artemis.yml | 3 +- .../resources/config/application-localvc.yml | 3 - ...g-repository-buttons-detail.component.html | 2 +- .../programming-exam-summary.component.html | 5 +- ...ercise-assessment-dashboard.component.html | 2 +- .../exercise-scores.component.html | 2 +- .../participation.component.html | 6 +- .../repository-view.component.html | 5 +- .../repository-view.component.ts | 1 + ...ise-details-student-actions.component.html | 4 +- .../code-button/code-button.component.html | 68 ++-- .../code-button/code-button.component.ts | 322 ++++++++++-------- .../layouts/profiles/profile-info.model.ts | 3 +- .../layouts/profiles/profile.service.ts | 3 +- .../user-settings/user-settings.route.ts | 2 + src/main/webapp/i18n/de/exercise-actions.json | 2 - src/main/webapp/i18n/en/exercise-actions.json | 2 - .../icl/LocalVCInfoContributorTest.java | 8 +- .../shared/code-button.component.spec.ts | 311 ++++++++--------- 22 files changed, 382 insertions(+), 407 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 68f8a7064b01..c4b5ad7d1b81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -243,9 +243,7 @@ public final class Constants { public static final String INFO_SSH_KEYS_URL_DETAIL = "sshKeysURL"; - public static final String INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL = "useVersionControlAccessToken"; - - public static final String INFO_SHOW_CLONE_URL_WITHOUT_TOKEN = "showCloneUrlWithoutToken"; + public static final String INFO_CODE_BUTTON_AUTHENTICATION_MECHANISMS = "authenticationMechanisms"; public static final String REGISTRATION_ENABLED = "registrationEnabled"; diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlab/GitlabInfoContributor.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlab/GitlabInfoContributor.java index 1f815debb06b..36615df0445c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlab/GitlabInfoContributor.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlab/GitlabInfoContributor.java @@ -1,6 +1,9 @@ package de.tum.cit.aet.artemis.programming.service.gitlab; +import static de.tum.cit.aet.artemis.core.config.Constants.INFO_CODE_BUTTON_AUTHENTICATION_MECHANISMS; + import java.net.URL; +import java.util.List; import java.util.Optional; import org.springframework.beans.factory.annotation.Value; @@ -27,11 +30,8 @@ public class GitlabInfoContributor implements InfoContributor { @Value("${artemis.version-control.ssh-keys-url-path:#{null}}") private Optional gitlabSshKeysUrlPath; - @Value("${artemis.version-control.use-version-control-access-token:#{false}}") - private Boolean useVersionControlAccessToken; - - @Value("${artemis.version-control.show-clone-url-without-token:true}") - private boolean showCloneUrlWithoutToken; + @Value("${artemis.version-control.repository-authentication-mechanisms:password,token,ssh}") + private List orderedAuthenticationMechanisms; @Override public void contribute(Info.Builder builder) { @@ -54,8 +54,6 @@ public void contribute(Info.Builder builder) { builder.withDetail(Constants.INFO_SSH_KEYS_URL_DETAIL, sshKeysUrl); } } - - builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, useVersionControlAccessToken); - builder.withDetail(Constants.INFO_SHOW_CLONE_URL_WITHOUT_TOKEN, showCloneUrlWithoutToken); + builder.withDetail(INFO_CODE_BUTTON_AUTHENTICATION_MECHANISMS, orderedAuthenticationMechanisms); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCInfoContributor.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCInfoContributor.java index 148eca2b7c41..a8f421b1babe 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCInfoContributor.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCInfoContributor.java @@ -1,10 +1,12 @@ package de.tum.cit.aet.artemis.programming.service.localvc; +import static de.tum.cit.aet.artemis.core.config.Constants.INFO_CODE_BUTTON_AUTHENTICATION_MECHANISMS; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,11 +30,8 @@ public class LocalVCInfoContributor implements InfoContributor { @Value("${server.url}") private String artemisServerUrl; - @Value("${artemis.version-control.use-version-control-access-token:false}") - private boolean useVcsAccessToken; - - @Value("${artemis.version-control.show-clone-url-without-token:true}") - private boolean showCloneUrlWithoutToken; + @Value("${artemis.version-control.repository-authentication-mechanisms:password,token,ssh}") + private List orderedAuthenticationMechanisms; @Value("${artemis.version-control.ssh-port:7921}") private int sshPort; @@ -45,12 +44,8 @@ public void contribute(Info.Builder builder) { // Store name of the version control system builder.withDetail(Constants.VERSION_CONTROL_NAME, "Local VC"); - // Show the access token in case it is available in the clone URL - // with the account.service.ts and its check if the access token is required - // TODO: Find a better way to test this in LocalVCInfoContributorTest - builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, useVcsAccessToken); - builder.withDetail(Constants.INFO_SHOW_CLONE_URL_WITHOUT_TOKEN, showCloneUrlWithoutToken); - + // Store the authentication mechanisms that should be used by the code-button and their order + builder.withDetail(INFO_CODE_BUTTON_AUTHENTICATION_MECHANISMS, orderedAuthenticationMechanisms); // Store ssh url template try { var serverUri = new URI(artemisServerUrl); diff --git a/src/main/resources/config/application-artemis.yml b/src/main/resources/config/application-artemis.yml index 844ce17ed2ef..7b5bc0a3a5b7 100644 --- a/src/main/resources/config/application-artemis.yml +++ b/src/main/resources/config/application-artemis.yml @@ -58,7 +58,8 @@ artemis: # ssh-private-key-folder-path: # the path to the folder in which the private ssh key file (e.g. id_rsa) is stored that can be used to clone git repos on the version control server # ssh-private-key-password: # the password for the private ssh key default-branch: main # The branch that should be used as default branch for all newly created repositories. This does NOT have to be equal to the default branch of the VCS - use-version-control-access-token: true # for Gitlab and LocalVC setups. For gitlab: a Gitlab-API token can be generated for each user and used as part of the Git clone URL shown to students to allow for password-less Git operations via HTTP. For LocalVC: Artemis generates access tokens for users to use repositories similar to the gitlab setup + use-version-control-access-token: true # for Gitlab setups. For gitlab: a Gitlab-API token can be generated for each user and used as part of the Git clone URL shown to students to allow for password-less Git operations via HTTP. + repository-authentication-mechanisms: token,ssh,password # the order of authentication mechanisms in the code button pop up. Removing parts removes them from the drop-down continuous-integration: user: # e.g. ga12abc password: diff --git a/src/main/resources/config/application-localvc.yml b/src/main/resources/config/application-localvc.yml index 76489b18a61f..c9bf984c65f2 100644 --- a/src/main/resources/config/application-localvc.yml +++ b/src/main/resources/config/application-localvc.yml @@ -12,6 +12,3 @@ artemis: url: http://localhost:8000 build-agent-git-username: buildjob_user # Replace with more secure credentials for production. Required for https access to localvc. This config must be set for build agents and localvc. build-agent-git-password: buildjob_password # Replace with more secure credentials for production. Required for https access to localvc. This config must be set for build agents and localvc. You can also use an ssh key - use-version-control-access-token: true - show-clone-url-without-token: true - diff --git a/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html index 95f0a53976b1..6ae4345aca37 100644 --- a/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html +++ b/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html @@ -1,6 +1,6 @@ @if (detail.data.participation?.repositoryUri && detail.data.exerciseId) {
- +
} @else { diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html index 2a0df39f1353..b79a70129a38 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html @@ -6,11 +6,12 @@ @if (submission) {
} +

Programming exam summary


diff --git a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html index fbfe3529f40c..8478f4362378 100644 --- a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html +++ b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html @@ -218,8 +218,8 @@

} @else { } diff --git a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.html b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.html index 999ab5fe4108..f09c6ea4af29 100644 --- a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.html +++ b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.html @@ -316,9 +316,9 @@
} @else { } } diff --git a/src/main/webapp/app/exercises/shared/participation/participation.component.html b/src/main/webapp/app/exercises/shared/participation/participation.component.html index c4897def98da..dcf0c7e77da1 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.component.html +++ b/src/main/webapp/app/exercises/shared/participation/participation.component.html @@ -120,7 +120,7 @@

- @if (value) { + @if (value && getRepositoryLink(row, value)) { @if (!localVCEnabled) { Repository Link @@ -128,8 +128,8 @@

} diff --git a/src/main/webapp/app/localvc/repository-view/repository-view.component.html b/src/main/webapp/app/localvc/repository-view/repository-view.component.html index 24c332323ed4..45a8470fbeff 100644 --- a/src/main/webapp/app/localvc/repository-view/repository-view.component.html +++ b/src/main/webapp/app/localvc/repository-view/repository-view.component.html @@ -45,11 +45,10 @@

} diff --git a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts index 6bd2912052b1..4df3a8ceebe5 100644 --- a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts +++ b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts @@ -74,6 +74,7 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { repositoryUri: string; repositoryType: ProgrammingExerciseInstructorRepositoryType | 'USER'; enableVcsAccessLog = false; + isInCourseManagement = false; allowVcsAccessLog = false; result: Result; resultHasInlineFeedback = false; diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html index 456601853875..4199b73ea81a 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html @@ -127,9 +127,9 @@ [smallButtons]="smallButtons" [participations]="exercise.studentParticipations!" [exercise]="exercise" - [routerLinkForRepositoryView]="repositoryLink + '/repository/' + exercise.studentParticipations![0].id" + [routerLinkForRepositoryView]="[repositoryLink, 'repository', exercise.studentParticipations![0].id!]" [hideLabelMobile]="true" - [useParticipationVcsAccessToken]="true" + [repositoryUri]="''" /> } @if (theiaEnabled) { diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 80b5035a21be..3295f238cdfc 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -5,65 +5,63 @@ class="code-button" [jhiFeatureToggle]="FeatureToggle.ProgrammingExercises" [buttonLabel]="'artemisApp.exerciseActions.code' | artemisTranslate" - [buttonLoading]="loading" - [smallButton]="smallButtons" - [hideLabelMobile]="hideLabelMobile" + [buttonLoading]="loading()" + [smallButton]="smallButtons()" + [hideLabelMobile]="hideLabelMobile()" [ngbPopover]="popContent" [autoClose]="'outside'" + (click)="onClick()" placement="right auto" container="body" > - @if (useSsh && !doesUserHaveSSHkeys) { + @if (this.currentState === States.SSH && !doesUserHaveSSHkeys) {
} - @if (useSsh && areAnySshKeysExpired) { + @if (this.currentState === States.SSH && areAnySshKeysExpired) {
} - @if (useToken && tokenMissing) { + @if (this.currentState === States.Token && isInCourseManagement && !userTokenPresent) {
} - @if (useToken && tokenExpired) { + @if (this.currentState === States.Token && isInCourseManagement && userTokenPresent && !userTokenStillValid) {
} - @if (participations && participations.length > 1) { + @if (participations().length > 1) {
} -
{{ cloneHeadline | artemisTranslate }}
+
+
- @if (sshEnabled) { -
- -
{{ getHttpOrSshRepositoryUri() }} 
- @if (!localVCEnabled) { + @if (!localVCEnabled()) { {{ cloneHeadline | artemisTranslate }}

> - } @else if (!!routerLinkForRepositoryView) { + } @else { @@ -86,12 +84,6 @@
{{ cloneHeadline | artemisTranslate }}
} - @if (repositoryPassword) { -

- {{ 'artemisApp.exerciseActions.repositoryPassword' | artemisTranslate }} {{ repositoryPassword }} - {{ 'artemisApp.exerciseActions.hoverToShow' | artemisTranslate }} -

- }
- - } - } -
- @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { - - - @if (!isReadOnlyMode) { - - - - } - - } - @if (!isEmojiCount) { - @if (!isAnswerOfAnnouncement && (isAtLeastTutorInCourse || isAuthorOfOriginalPost)) { - - } - @if (mayEdit) { - - } - @if (mayDelete) { - - } - - } -
- diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts deleted file mode 100644 index e68da18c1d0b..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, output } from '@angular/core'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { faCheck, faPencilAlt, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { AsyncPipe, KeyValuePipe, NgClass } from '@angular/common'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; - -@Component({ - selector: 'jhi-answer-post-reactions-bar', - templateUrl: './answer-post-reactions-bar.component.html', - styleUrls: ['../posting-reactions-bar.component.scss'], - imports: [ - NgbTooltip, - EmojiComponent, - CdkOverlayOrigin, - FaIconComponent, - CdkConnectedOverlay, - EmojiPickerComponent, - NgClass, - ConfirmIconComponent, - AsyncPipe, - KeyValuePipe, - ArtemisTranslatePipe, - ReactingUsersOnPostingPipe, - ], -}) -export class AnswerPostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges { - @Input() isReadOnlyMode = false; - @Input() isEmojiCount = false; - @Input() isLastAnswer = false; - - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() postingUpdated = new EventEmitter(); - - // Icons - readonly farSmile = faSmile; - readonly faCheck = faCheck; - readonly faPencilAlt = faPencilAlt; - - isAuthorOfOriginalPost: boolean; - isAnswerOfAnnouncement: boolean; - mayDelete: boolean; - mayEdit: boolean; - - mayDeleteOutput = output(); - mayEditOutput = output(); - - ngOnInit() { - super.ngOnInit(); - this.setMayEdit(); - this.setMayDelete(); - } - - ngOnChanges() { - super.ngOnChanges(); - this.setMayEdit(); - this.setMayDelete(); - } - - isAnyReactionCountAboveZero(): boolean { - return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); - } - - /** - * invokes the metis service to delete an answer post - */ - deletePosting(): void { - this.isDeleteEvent.emit(true); - } - - /** - * builds and returns a Reaction model out of an emojiId and thereby sets the answerPost property properly - * @param emojiId emojiId to build the model for - */ - buildReaction(emojiId: string): Reaction { - const reaction = new Reaction(); - reaction.emojiId = emojiId; - reaction.answerPost = this.posting; - return reaction; - } - - setMayDelete(): void { - // determines if the current user is the author of the original post, that the answer belongs to - this.isAuthorOfOriginalPost = this.metisService.metisUserIsAuthorOfPosting(this.posting.post!); - this.isAnswerOfAnnouncement = getAsChannelDTO(this.posting.post?.conversation)?.isAnnouncementChannel ?? false; - const isCourseWideChannel = getAsChannelDTO(this.posting.post?.conversation)?.isCourseWide ?? false; - const canDeletePost = this.isAnswerOfAnnouncement ? this.metisService.metisUserIsAtLeastInstructorInCourse() : this.metisService.metisUserIsAtLeastTutorInCourse(); - const mayDeleteOtherUsersAnswer = - (isCourseWideChannel && canDeletePost) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); - this.mayDelete = !this.isReadOnlyMode && (this.isAuthorOfPosting || (mayDeleteOtherUsersAnswer && canDeletePost)); - this.mayDeleteOutput.emit(this.mayDelete); - } - - setMayEdit() { - this.mayEdit = this.isAuthorOfPosting; - this.mayEditOutput.emit(this.mayEdit); - } - - editPosting() { - this.openPostingCreateEditModal.emit(); - } - - /** - * toggles the resolvesPost property of an answer post if the user is at least tutor in a course or the user is the author of the original post, - * delegates the update to the metis service - */ - toggleResolvesPost(): void { - if (this.isAtLeastTutorInCourse || this.isAuthorOfOriginalPost) { - this.posting.resolvesPost = !this.posting.resolvesPost; - this.metisService.updateAnswerPost(this.posting).subscribe(); - } - } -} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html b/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html deleted file mode 100644 index 60a95dfbd683..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html +++ /dev/null @@ -1,149 +0,0 @@ -
- @if (hoverBar && sortedAnswerPosts.length === 0) { -
- -
- } - @if (!isCommunicationPage) { - @if (sortedAnswerPosts.length) { - - @if (showAnswers) { -
- -
- } @else { - -
- -
- } - } - } @else { - @if (!isThreadSidebar) { - - @if (!showAnswers && sortedAnswerPosts.length) { -
- -
- } - } - } - @for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { - @if (isEmojiCount) { -
- -
- } - } -
- @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { - - - - - @if (!readOnlyMode) { - - } - - - } - - @if (!isEmojiCount && mayEdit) { - - } - - @if (!isEmojiCount && mayDelete) { - - } - @if (!isEmojiCount && (displayPriority === DisplayPriority.PINNED || canPin)) { - - } - @if (!isEmojiCount) { - - } -
-
- @if (isEmojiCount && getShowNewMessageIcon()) { -
- } -
-
diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts deleted file mode 100644 index 963c4f99ac14..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, inject, output } from '@angular/core'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { Post } from 'app/entities/metis/post.model'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { TranslateDirective } from 'app/shared/language/translate.directive'; -import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; -import { DisplayPriority } from 'app/shared/metis/metis.util'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import { faArrowRight, faPencilAlt, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import dayjs from 'dayjs/esm'; -import { getAsChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; -import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; -import { AccountService } from 'app/core/auth/account.service'; -import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; -import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; -import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; -import { AsyncPipe, KeyValuePipe } from '@angular/common'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; - -@Component({ - selector: 'jhi-post-reactions-bar', - templateUrl: './post-reactions-bar.component.html', - styleUrls: ['../posting-reactions-bar.component.scss'], - imports: [ - FaIconComponent, - TranslateDirective, - EmojiComponent, - NgbTooltip, - CdkOverlayOrigin, - CdkConnectedOverlay, - EmojiPickerComponent, - PostCreateEditModalComponent, - ConfirmIconComponent, - AsyncPipe, - KeyValuePipe, - ArtemisTranslatePipe, - ReactingUsersOnPostingPipe, - ], -}) -export class PostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges, OnDestroy { - private accountService = inject(AccountService); - - pinTooltip: string; - displayPriority: DisplayPriority; - canPin = false; - readonly DisplayPriority = DisplayPriority; - - // Icons - faSmile = faSmile; - faArrowRight = faArrowRight; - faPencilAlt = faPencilAlt; - faTrash = faTrashAlt; - - @Input() readOnlyMode = false; - @Input() showAnswers: boolean; - @Input() sortedAnswerPosts: AnswerPost[]; - @Input() isCommunicationPage: boolean; - @Input() lastReadDate?: dayjs.Dayjs; - @Input() previewMode: boolean; - @Input() isEmojiCount = false; - @Input() hoverBar = true; - - @Output() showAnswersChange = new EventEmitter(); - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() closePostingCreateEditModal = new EventEmitter(); - @Output() openThread = new EventEmitter(); - @Output() canPinOutput = new EventEmitter(); - - @ViewChild(PostCreateEditModalComponent) postCreateEditModal?: PostCreateEditModalComponent; - @ViewChild('createEditModal') createEditModal!: PostCreateEditModalComponent; - - isAtLeastInstructorInCourse: boolean; - mayDeleteOutput = output(); - mayEditOutput = output(); - mayEdit: boolean; - mayDelete: boolean; - - isAnyReactionCountAboveZero(): boolean { - return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); - } - - openAnswerView() { - this.showAnswersChange.emit(true); - this.openPostingCreateEditModal.emit(); - } - - closeAnswerView() { - this.showAnswersChange.emit(false); - this.closePostingCreateEditModal.emit(); - } - - /** - * on initialization: call resetTooltipsAndPriority - */ - ngOnInit() { - super.ngOnInit(); - - const currentConversation = this.metisService.getCurrentConversation(); - this.setCanPin(currentConversation); - this.setMayDelete(); - this.setMayEdit(); - this.resetTooltipsAndPriority(); - } - - ngOnDestroy() { - this.postCreateEditModal?.modalRef?.close(); - } - - /** - * Checks whether the user can pin the message in the conversation - * - * @param currentConversation the conversation the post belongs to - */ - private setCanPin(currentConversation: ConversationDTO | undefined) { - if (!currentConversation) { - this.canPin = this.metisService.metisUserIsAtLeastTutorInCourse(); - return; - } - - if (isChannelDTO(currentConversation)) { - this.canPin = currentConversation.hasChannelModerationRights ?? false; - } else if (isGroupChatDTO(currentConversation)) { - this.canPin = currentConversation.creator?.id === this.accountService.userIdentity?.id; - } else if (isOneToOneChatDTO(currentConversation)) { - this.canPin = true; - } - this.canPinOutput.emit(this.canPin); - } - - /** - * on changes: call resetTooltipsAndPriority - */ - ngOnChanges() { - super.ngOnChanges(); - this.resetTooltipsAndPriority(); - this.setMayDelete(); - this.setMayEdit(); - } - - /** - * builds and returns a Reaction model out of an emojiId and thereby sets the post property properly - * @param emojiId emojiId to build the model for - */ - buildReaction(emojiId: string): Reaction { - const reaction = new Reaction(); - reaction.emojiId = emojiId; - reaction.post = this.posting; - return reaction; - } - - /** - * changes the state of the displayPriority property on a post to PINNED by invoking the metis service - * in case the displayPriority is already set to PINNED, it will be changed to NONE - */ - togglePin() { - if (this.displayPriority === DisplayPriority.PINNED) { - this.displayPriority = DisplayPriority.NONE; - } else { - this.displayPriority = DisplayPriority.PINNED; - } - this.posting.displayPriority = this.displayPriority; - this.metisService.updatePostDisplayPriority(this.posting.id!, this.displayPriority).subscribe(); - } - - checkIfPinned(): DisplayPriority { - return this.displayPriority; - } - - /** - * provides the tooltip for the pin icon dependent on the user authority and the pin state of a posting - * - */ - getPinTooltip(): string { - if (this.canPin && this.displayPriority === DisplayPriority.PINNED) { - return 'artemisApp.metis.removePinPostTooltip'; - } - if (this.canPin && this.displayPriority !== DisplayPriority.PINNED) { - return 'artemisApp.metis.pinPostTooltip'; - } - return 'artemisApp.metis.pinnedPostTooltip'; - } - - getShowNewMessageIcon(): boolean { - let showIcon = false; - // iterate over all answer posts - this.sortedAnswerPosts.forEach((answerPost) => { - // check if the answer post is newer than the last read date - const isAuthor = this.metisService.metisUserIsAuthorOfPosting(answerPost); - if (!isAuthor && !!(!this.lastReadDate || (this.lastReadDate && answerPost.creationDate && answerPost.creationDate.isAfter(this.lastReadDate!)))) { - showIcon = true; - } - }); - return showIcon; - } - - private resetTooltipsAndPriority() { - this.displayPriority = this.posting.displayPriority!; - this.pinTooltip = this.getPinTooltip(); - } - - /** - * invokes the metis service to delete a post - */ - deletePosting(): void { - this.isDeleteEvent.emit(true); - } - - editPosting() { - if (this.posting.title != '') { - this.createEditModal.open(); - } else { - this.isModalOpen.emit(); - } - } - - setMayDelete(): void { - this.isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); - const isCourseWideChannel = getAsChannelDTO(this.posting.conversation)?.isCourseWide ?? false; - const isAnswerOfAnnouncement = getAsChannelDTO(this.posting.conversation)?.isAnnouncementChannel ?? false; - const isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); - const canDeleteAnnouncement = isAnswerOfAnnouncement ? this.metisService.metisUserIsAtLeastInstructorInCourse() : true; - const mayDeleteOtherUsersAnswer = - (isCourseWideChannel && isAtLeastTutorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); - this.mayDelete = !this.readOnlyMode && !this.previewMode && (this.isAuthorOfPosting || mayDeleteOtherUsersAnswer) && canDeleteAnnouncement; - this.mayDeleteOutput.emit(this.mayDelete); - } - - setMayEdit(): void { - this.mayEdit = this.isAuthorOfPosting; - this.mayEditOutput.emit(this.mayEdit); - } -} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html new file mode 100644 index 000000000000..144f0e3859cb --- /dev/null +++ b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html @@ -0,0 +1,172 @@ +
+ @if (getPostingType() === 'post') { + @if (hoverBar() && sortedAnswerPosts()?.length === 0) { +
+ +
+ } + @if (!isCommunicationPage() && sortedAnswerPosts()?.length) { + @if (showAnswers()) { +
+ +
+ } @else { +
+ +
+ } + } @else if (!isThreadSidebar() && !showAnswers() && sortedAnswerPosts()?.length) { +
+ +
+ } + } + + @for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { + @if (isEmojiCount()) { +
+ +
+ } + } + +
+ + @if ((isAnyReactionCountAboveZero() && isEmojiCount()) || !isEmojiCount()) { + + + + + + + + } + + + @if (!isEmojiCount() && mayEdit) { + + } + + + @if (!isEmojiCount() && mayDelete) { + + } + + + @if (getPostingType() === 'answerPost' && !isEmojiCount()) { + @if (!isAnswerOfAnnouncement && (isAtLeastTutorInCourse || isAuthorOfOriginalPost)) { + + } + } + + + @if (getPostingType() === 'post' && !isEmojiCount() && (displayPriority === DisplayPriority.PINNED || canPin)) { + + } + + + @if (!isEmojiCount()) { + + } +
+ + + @if (getPostingType() === 'post' && isEmojiCount() && getShowNewMessageIcon()) { +
+ } +
diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts new file mode 100644 index 000000000000..1dc6ddce5c0e --- /dev/null +++ b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts @@ -0,0 +1,484 @@ +import { Component, OnChanges, OnInit, inject, input, output, viewChild } from '@angular/core'; +import { Posting } from 'app/entities/metis/posting.model'; +import { MetisService } from 'app/shared/metis/metis.service'; +import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; +import { Reaction } from 'app/entities/metis/reaction.model'; +import { PLACEHOLDER_USER_REACTED, ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; +import { faArrowRight, faBookmark, faCheck, faPencilAlt, faSmile, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; +import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; +import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { AsyncPipe, KeyValuePipe, NgClass } from '@angular/common'; +import { AccountService } from 'app/core/auth/account.service'; +import { DisplayPriority } from '../metis.util'; +import { Post } from 'app/entities/metis/post.model'; +import { Conversation, ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; +import { getAsChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; +import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; +import { AnswerPost } from 'app/entities/metis/answer-post.model'; +import dayjs from 'dayjs'; +import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +const PIN_EMOJI_ID = 'pushpin'; +const ARCHIVE_EMOJI_ID = 'open_file_folder'; +const HEAVY_MULTIPLICATION_ID = 'heavy_multiplication_x'; + +const SPEECH_BALLOON_UNICODE = '1F4AC'; +const ARCHIVE_EMOJI_UNICODE = '1F4C2'; +const PIN_EMOJI_UNICODE = '1F4CC'; +const HEAVY_MULTIPLICATION_UNICODE = '2716'; + +/** + * event triggered by the emoji mart component, including EmojiData + */ +interface ReactionEvent { + $event: Event; + emoji?: EmojiData; +} + +/** + * represents the amount of users that reacted + * hasReacted indicates if the currently logged-in user is among those counted users + */ +interface ReactionMetaData { + count: number; + hasReacted: boolean; + reactingUsers: string[]; +} + +/** + * data structure used for displaying emoji reactions with metadata on postings + */ +interface ReactionMetaDataMap { + [emojiId: string]: ReactionMetaData; +} + +@Component({ + selector: 'jhi-posting-reactions-bar', + templateUrl: './posting-reactions-bar.component.html', + styleUrls: ['./posting-reactions-bar.component.scss'], + imports: [ + NgbTooltip, + EmojiComponent, + CdkOverlayOrigin, + FaIconComponent, + TranslateDirective, + CdkConnectedOverlay, + EmojiPickerComponent, + ConfirmIconComponent, + AsyncPipe, + KeyValuePipe, + ArtemisTranslatePipe, + ReactingUsersOnPostingPipe, + NgClass, + PostCreateEditModalComponent, + ], +}) +export class PostingReactionsBarComponent implements OnInit, OnChanges { + protected metisService = inject(MetisService); + private accountService = inject(AccountService); + + pinEmojiId: string = PIN_EMOJI_ID; + archiveEmojiId: string = ARCHIVE_EMOJI_ID; + closeCrossId: string = HEAVY_MULTIPLICATION_ID; + + posting = input(); + isThreadSidebar = input(); + isEmojiCount = input(false); + isReadOnlyMode = input(false); + + openPostingCreateEditModal = output(); + closePostingCreateEditModal = output(); + reactionsUpdated = output(); + isModalOpen = output(); + + showReactionSelector = false; + isAtLeastTutorInCourse: boolean; + isAuthorOfPosting: boolean; + isAuthorOfOriginalPost: boolean; + isAnswerOfAnnouncement: boolean; + isAtLeastInstructorInCourse: boolean; + mayDeleteOutput = output(); + mayEditOutput = output(); + canPinOutput = output(); + showAnswers = input(); + sortedAnswerPosts = input(); + isCommunicationPage = input(); + lastReadDate = input(); + previewMode = input(); + hoverBar = input(true); + + showAnswersChange = output(); + isLastAnswer = input(false); + postingUpdated = output(); + mayEdit: boolean; + mayDelete: boolean; + pinTooltip: string; + displayPriority: DisplayPriority; + canPin = false; + readonly DisplayPriority = DisplayPriority; + + isDeleteEvent = output(); + readonly onBookmarkClicked = output(); + openThread = output(); + + // Icons + readonly faBookmark = faBookmark; + readonly faSmile = faSmile; + readonly faCheck = faCheck; + readonly faPencilAlt = faPencilAlt; + readonly faArrowRight = faArrowRight; + readonly faTrash = faTrashAlt; + + createEditModal = viewChild.required('createEditModal'); + + /** + * on initialization: updates the current posting and its reactions, + * invokes metis service to check user authority + */ + ngOnInit() { + this.updatePostingWithReactions(); + this.isAuthorOfPosting = this.metisService.metisUserIsAuthorOfPosting(this.posting() as Posting); + this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); + this.isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); + this.isAnswerOfAnnouncement = + this.getPostingType() === 'answerPost' ? (getAsChannelDTO((this.posting() as AnswerPost).post?.conversation)?.isAnnouncementChannel ?? false) : false; + this.isAuthorOfOriginalPost = this.getPostingType() === 'answerPost' ? this.metisService.metisUserIsAuthorOfPosting((this.posting() as AnswerPost).post!) : false; + + if (this.getPostingType() === 'post') { + const currentConversation = this.metisService.getCurrentConversation(); + this.setCanPin(currentConversation); + this.resetTooltipsAndPriority(); + } + this.setMayDelete(); + this.setMayEdit(); + } + + /** + * on changes: updates the current posting and its reactions, + * invokes metis service to check user authority + */ + ngOnChanges() { + this.updatePostingWithReactions(); + this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); + if (this.getPostingType() === 'post') { + this.resetTooltipsAndPriority(); + } + this.setMayDelete(); + this.setMayEdit(); + } + + /* + * icons (as svg paths) to be used as category preview image in emoji mart selector + */ + categoriesIcons: { [key: string]: string } = { + // category 'recent' (would show recently used emojis) is overwritten by a preselected set of emojis for that course, + // therefore category icon is an asterisk (indicating customization) instead of a clock (indicating the "recently used"-use case) + recent: `M10 1h3v21h-3zm10.186 4l1.5 2.598L3.5 18.098 2 15.5zM2 7.598L3.5 5l18.186 10.5-1.5 2.598z`, + }; + + /** + * Checks whether the user can pin the message in the conversation + * + * @param currentConversation the conversation the post belongs to + */ + setCanPin(currentConversation: ConversationDTO | undefined) { + if (!currentConversation) { + this.canPin = this.metisService.metisUserIsAtLeastTutorInCourse(); + return; + } + + if (isChannelDTO(currentConversation)) { + this.canPin = currentConversation.hasChannelModerationRights ?? false; + } else if (isGroupChatDTO(currentConversation)) { + this.canPin = currentConversation.creator?.id === this.accountService.userIdentity?.id; + } else if (isOneToOneChatDTO(currentConversation)) { + this.canPin = true; + } + this.canPinOutput.emit(this.canPin); + } + + private resetTooltipsAndPriority() { + this.displayPriority = (this.posting() as Post).displayPriority!; + this.pinTooltip = this.getPinTooltip(); + } + + getShowNewMessageIcon(): boolean { + let showIcon = false; + // iterate over all answer posts + (this.sortedAnswerPosts() as unknown as AnswerPost[]).forEach((answerPost: Posting) => { + // check if the answer post is newer than the last read date + const isAuthor = this.metisService.metisUserIsAuthorOfPosting(answerPost); + const lastReadDate = this.lastReadDate?.(); + const creationDate = answerPost.creationDate; + + if (lastReadDate && creationDate) { + const lastReadDateDayJs = dayjs(lastReadDate); + // @ts-ignore + if (!isAuthor && creationDate.isAfter(lastReadDateDayJs)) { + showIcon = true; + } + } + }); + return showIcon; + } + + /** + * provides the tooltip for the pin icon dependent on the user authority and the pin state of a posting + * + */ + getPinTooltip(): string { + if (this.canPin && this.displayPriority === DisplayPriority.PINNED) { + return 'artemisApp.metis.removePinPostTooltip'; + } + if (this.canPin && this.displayPriority !== DisplayPriority.PINNED) { + return 'artemisApp.metis.pinPostTooltip'; + } + return 'artemisApp.metis.pinnedPostTooltip'; + } + + /** + * currently predefined fixed set of emojis that should be used within a course, + * they will be listed on first page of the emoji-mart selector + */ + selectedCourseEmojis = ['smile', 'joy', 'sunglasses', 'tada', 'rocket', 'heavy_plus_sign', 'thumbsup', 'memo', 'coffee', 'recycle']; + + /** + * emojis that have a predefined meaning, i.e. pin and archive emoji, + * should not appear in the emoji-mart selector + */ + emojisToShowFilter: (emoji: string | EmojiData) => boolean = (emoji) => { + if (typeof emoji === 'string') { + return emoji !== PIN_EMOJI_UNICODE && emoji !== ARCHIVE_EMOJI_UNICODE && emoji !== SPEECH_BALLOON_UNICODE && emoji !== HEAVY_MULTIPLICATION_UNICODE; + } else { + return ( + emoji.unified !== PIN_EMOJI_UNICODE && + emoji.unified !== ARCHIVE_EMOJI_UNICODE && + emoji.unified !== SPEECH_BALLOON_UNICODE && + emoji.unified !== HEAVY_MULTIPLICATION_UNICODE + ); + } + }; + + /** + * map that lists associated reaction (by emojiId) for the current posting together with its count + * and a flag that indicates if the current user has used this reaction + */ + reactionMetaDataMap: ReactionMetaDataMap = {}; + + /** + * builds and returns a Reaction model out of an emojiId and thereby sets the answerPost property properly + * @param emojiId emojiId to build the model for + */ + buildReaction(emojiId: string): Reaction { + const reaction = new Reaction(); + reaction.emojiId = emojiId; + if (this.getPostingType() === 'answerPost') { + reaction.answerPost = this.posting() as AnswerPost; + } else { + reaction.post = this.posting() as Post; + } + return reaction; + } + + /** + * updates the reaction based on the ReactionEvent emitted by the emoji-mart selector component + */ + selectReaction(reactionEvent: ReactionEvent): void { + if (reactionEvent.emoji != undefined) { + this.addOrRemoveReaction(reactionEvent.emoji.id); + } + } + + /** + * opens the emoji selector overlay if user clicks the '.reaction-button' + * closes the emoji selector overly if user clicks the '.reaction-button' again or somewhere outside the overlay + */ + toggleEmojiSelect() { + this.showReactionSelector = !this.showReactionSelector; + } + + /** + * updates the reaction based when a displayed emoji reaction is clicked, + * i.e. when agree on an existing reaction (+1) or removing own reactions (-1) + */ + updateReaction(emojiId: string): void { + if (emojiId != undefined) { + this.addOrRemoveReaction(emojiId); + } + } + + /** + * adds or removes a reaction by invoking the metis service, + * depending on if the current user already reacted with the given emojiId (remove) or not (add) + * @param emojiId emojiId representing the reaction to be added/removed + */ + addOrRemoveReaction(emojiId: string): void { + const existingReactionIdx = (this.posting() as Posting).reactions + ? (this.posting() as Posting).reactions!.findIndex((reaction) => reaction.user?.id === this.metisService.getUser().id && reaction.emojiId === emojiId) + : -1; + if ((this.posting() as Posting).reactions && existingReactionIdx > -1) { + const reactionToDelete = (this.posting() as Posting).reactions![existingReactionIdx]; + this.metisService.deleteReaction(reactionToDelete).subscribe(() => { + (this.posting() as Posting).reactions = (this.posting() as Posting).reactions?.filter((reaction) => reaction.id !== reactionToDelete.id); + this.updatePostingWithReactions(); + this.showReactionSelector = false; + this.reactionsUpdated.emit((this.posting() as Posting).reactions!); + }); + } else { + const reactionToCreate = this.buildReaction(emojiId); + this.metisService.createReaction(reactionToCreate).subscribe(() => { + this.updatePostingWithReactions(); + this.showReactionSelector = false; + this.reactionsUpdated.emit((this.posting() as Posting).reactions || []); + }); + } + } + + /** + * updates the posting's reactions by calling the build function for the reactionMetaDataMap if there are any reaction on the posting + */ + updatePostingWithReactions(): void { + if ((this.posting() as Posting).reactions && (this.posting() as Posting).reactions!.length > 0) { + // filter out emoji for pin and archive as they should not be listed in the reactionMetaDataMap + const filteredReactions = (this.posting() as Posting).reactions!.filter( + (reaction: Reaction) => reaction.emojiId !== this.pinEmojiId || reaction.emojiId !== this.archiveEmojiId, + ); + this.reactionMetaDataMap = this.buildReactionMetaDataMap(filteredReactions); + } else { + this.reactionMetaDataMap = {}; + } + } + + /** + * builds the ReactionMetaDataMap data structure out of a given array of reactions + * @param reactions array of reactions associated to the current posting + */ + buildReactionMetaDataMap(reactions: Reaction[]): ReactionMetaDataMap { + return reactions.reduce((metaDataMap: ReactionMetaDataMap, reaction: Reaction) => { + const hasReacted = reaction.user?.id === this.metisService.getUser().id; + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const reactingUser = hasReacted ? PLACEHOLDER_USER_REACTED : reaction.user?.name!; + const reactionMetaData: ReactionMetaData = { + count: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].count + 1 : 1, + hasReacted: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].hasReacted || hasReacted : hasReacted, + reactingUsers: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].reactingUsers.concat(reactingUser) : [reactingUser], + }; + return { ...metaDataMap, [reaction.emojiId!]: reactionMetaData }; + }, {}); + } + + protected bookmarkPosting() { + this.onBookmarkClicked.emit(); + } + + isAnyReactionCountAboveZero(): boolean { + return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); + } + + /** + * invokes the metis service to delete posting + */ + deletePosting(): void { + this.isDeleteEvent.emit(true); + } + + /** + * changes the state of the displayPriority property on a post to PINNED by invoking the metis service + * in case the displayPriority is already set to PINNED, it will be changed to NONE + */ + togglePin() { + if (this.canPin) { + if (this.displayPriority === DisplayPriority.PINNED) { + this.displayPriority = DisplayPriority.NONE; + } else { + this.displayPriority = DisplayPriority.PINNED; + } + (this.posting() as Post).displayPriority = this.displayPriority; + this.metisService.updatePostDisplayPriority((this.posting() as Posting).id!, this.displayPriority).subscribe(); + } + } + + /** + * toggles the resolvesPost property of an answer post if the user is at least tutor in a course or the user is the author of the original post, + * delegates the update to the metis service + */ + toggleResolvesPost(): void { + if (this.isAtLeastTutorInCourse || this.isAuthorOfOriginalPost) { + (this.posting() as AnswerPost).resolvesPost = !(this.posting() as AnswerPost).resolvesPost; + this.metisService.updateAnswerPost(this.posting() as AnswerPost).subscribe(); + } + } + + checkIfPinned(): DisplayPriority { + return this.displayPriority; + } + + openAnswerView() { + this.showAnswersChange.emit(true); + this.openPostingCreateEditModal.emit(); + } + + closeAnswerView() { + this.showAnswersChange.emit(false); + this.closePostingCreateEditModal.emit(); + } + + setMayEdit(): void { + this.mayEdit = this.isAuthorOfPosting; + this.mayEditOutput.emit(this.mayEdit); + } + + editPosting() { + if (this.getPostingType() === 'post') { + if ((this.posting() as Post)!.title != '') { + this.createEditModal().open(); + } else { + this.isModalOpen.emit(); + } + } else { + this.openPostingCreateEditModal.emit(); + } + } + + setMayDelete(): void { + const conversation = this.getConversation(); + const channel = getAsChannelDTO(conversation); + + const isAnswerOfAnnouncement = this.getPostingType() === 'answerPost' ? (channel?.isAnnouncementChannel ?? false) : false; + const isCourseWide = channel?.isCourseWide ?? false; + + const canDeleteAnnouncement = isAnswerOfAnnouncement ? this.isAtLeastInstructorInCourse : true; + const mayDeleteOtherUsers = + (isCourseWide && this.isAtLeastTutorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); + + this.mayDelete = !this.isReadOnlyMode() && !this.previewMode() && (this.isAuthorOfPosting || mayDeleteOtherUsers) && canDeleteAnnouncement; + this.mayDeleteOutput.emit(this.mayDelete); + } + + private getConversation(): Conversation | undefined { + if (this.getPostingType() === 'answerPost') { + return (this.posting() as AnswerPost).post?.conversation; + } else { + return (this.posting() as Post).conversation; + } + } + + getPostingType(): 'post' | 'answerPost' { + return this.posting() && 'post' in this.posting()! ? 'answerPost' : 'post'; + } + + getSaved(): boolean { + return (this.posting() as Posting)?.isSaved; + } + + getResolvesPost(): boolean { + return (this.posting() as AnswerPost)?.resolvesPost; + } +} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts deleted file mode 100644 index d0cc31c1d752..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Directive, EventEmitter, Input, OnChanges, OnInit, Output, inject, output } from '@angular/core'; -import { Posting } from 'app/entities/metis/posting.model'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { PLACEHOLDER_USER_REACTED } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { faBookmark } from '@fortawesome/free-solid-svg-icons'; -import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; - -const PIN_EMOJI_ID = 'pushpin'; -const ARCHIVE_EMOJI_ID = 'open_file_folder'; -const HEAVY_MULTIPLICATION_ID = 'heavy_multiplication_x'; - -const SPEECH_BALLOON_UNICODE = '1F4AC'; -const ARCHIVE_EMOJI_UNICODE = '1F4C2'; -const PIN_EMOJI_UNICODE = '1F4CC'; -const HEAVY_MULTIPLICATION_UNICODE = '2716'; - -/** - * event triggered by the emoji mart component, including EmojiData - */ -interface ReactionEvent { - $event: Event; - emoji?: EmojiData; -} - -/** - * represents the amount of users that reacted - * hasReacted indicates if the currently logged-in user is among those counted users - */ -interface ReactionMetaData { - count: number; - hasReacted: boolean; - reactingUsers: string[]; -} - -/** - * data structure used for displaying emoji reactions with metadata on postings - */ -interface ReactionMetaDataMap { - [emojiId: string]: ReactionMetaData; -} - -@Directive() -export abstract class PostingsReactionsBarDirective implements OnInit, OnChanges { - protected metisService = inject(MetisService); - - pinEmojiId: string = PIN_EMOJI_ID; - archiveEmojiId: string = ARCHIVE_EMOJI_ID; - closeCrossId: string = HEAVY_MULTIPLICATION_ID; - - @Input() posting: T; - @Input() isThreadSidebar: boolean; - - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() reactionsUpdated = new EventEmitter(); - @Output() isModalOpen = new EventEmitter(); - - showReactionSelector = false; - currentUserIsAtLeastTutor: boolean; - isAtLeastTutorInCourse: boolean; - isAuthorOfPosting: boolean; - - isDeleteEvent = output(); - readonly onBookmarkClicked = output(); - - // Icons - readonly farBookmark = farBookmark; - readonly faBookmark = faBookmark; - - /* - * icons (as svg paths) to be used as category preview image in emoji mart selector - */ - categoriesIcons: { [key: string]: string } = { - // category 'recent' (would show recently used emojis) is overwritten by a preselected set of emojis for that course, - // therefore category icon is an asterisk (indicating customization) instead of a clock (indicating the "recently used"-use case) - recent: `M10 1h3v21h-3zm10.186 4l1.5 2.598L3.5 18.098 2 15.5zM2 7.598L3.5 5l18.186 10.5-1.5 2.598z`, - }; - - /** - * currently predefined fixed set of emojis that should be used within a course, - * they will be listed on first page of the emoji-mart selector - */ - selectedCourseEmojis = ['smile', 'joy', 'sunglasses', 'tada', 'rocket', 'heavy_plus_sign', 'thumbsup', 'memo', 'coffee', 'recycle']; - - /** - * emojis that have a predefined meaning, i.e. pin and archive emoji, - * should not appear in the emoji-mart selector - */ - emojisToShowFilter: (emoji: string | EmojiData) => boolean = (emoji) => { - if (typeof emoji === 'string') { - return emoji !== PIN_EMOJI_UNICODE && emoji !== ARCHIVE_EMOJI_UNICODE && emoji !== SPEECH_BALLOON_UNICODE && emoji !== HEAVY_MULTIPLICATION_UNICODE; - } else { - return ( - emoji.unified !== PIN_EMOJI_UNICODE && - emoji.unified !== ARCHIVE_EMOJI_UNICODE && - emoji.unified !== SPEECH_BALLOON_UNICODE && - emoji.unified !== HEAVY_MULTIPLICATION_UNICODE - ); - } - }; - - /** - * map that lists associated reaction (by emojiId) for the current posting together with its count - * and a flag that indicates if the current user has used this reaction - */ - reactionMetaDataMap: ReactionMetaDataMap = {}; - - /** - * on initialization: updates the current posting and its reactions, - * invokes metis service to check user authority - */ - ngOnInit(): void { - this.updatePostingWithReactions(); - this.currentUserIsAtLeastTutor = this.metisService.metisUserIsAtLeastTutorInCourse(); - this.isAuthorOfPosting = this.metisService.metisUserIsAuthorOfPosting(this.posting); - this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); - } - - deletePosting(): void { - this.metisService.deletePost(this.posting); - } - - /** - * on changes: updates the current posting and its reactions, - * invokes metis service to check user authority - */ - ngOnChanges() { - this.updatePostingWithReactions(); - this.currentUserIsAtLeastTutor = this.metisService.metisUserIsAtLeastTutorInCourse(); - } - - abstract buildReaction(emojiId: string): Reaction; - - /** - * updates the reaction based on the ReactionEvent emitted by the emoji-mart selector component - */ - selectReaction(reactionEvent: ReactionEvent): void { - if (reactionEvent.emoji != undefined) { - this.addOrRemoveReaction(reactionEvent.emoji.id); - } - } - - /** - * opens the emoji selector overlay if user clicks the '.reaction-button' - * closes the emoji selector overly if user clicks the '.reaction-button' again or somewhere outside the overlay - */ - toggleEmojiSelect() { - this.showReactionSelector = !this.showReactionSelector; - } - - /** - * updates the reaction based when a displayed emoji reaction is clicked, - * i.e. when agree on an existing reaction (+1) or removing own reactions (-1) - */ - updateReaction(emojiId: string): void { - if (emojiId != undefined) { - this.addOrRemoveReaction(emojiId); - } - } - - /** - * adds or removes a reaction by invoking the metis service, - * depending on if the current user already reacted with the given emojiId (remove) or not (add) - * @param emojiId emojiId representing the reaction to be added/removed - */ - addOrRemoveReaction(emojiId: string): void { - const existingReactionIdx = this.posting.reactions - ? this.posting.reactions.findIndex((reaction) => reaction.user?.id === this.metisService.getUser().id && reaction.emojiId === emojiId) - : -1; - if (this.posting.reactions && existingReactionIdx > -1) { - const reactionToDelete = this.posting.reactions[existingReactionIdx]; - this.metisService.deleteReaction(reactionToDelete).subscribe(() => { - this.posting.reactions = this.posting.reactions?.filter((reaction) => reaction.id !== reactionToDelete.id); - this.updatePostingWithReactions(); - this.showReactionSelector = false; - this.reactionsUpdated.emit(this.posting.reactions); - }); - } else { - const reactionToCreate = this.buildReaction(emojiId); - this.metisService.createReaction(reactionToCreate).subscribe(() => { - this.updatePostingWithReactions(); - this.showReactionSelector = false; - this.reactionsUpdated.emit(this.posting.reactions); - }); - } - } - - /** - * updates the posting's reactions by calling the build function for the reactionMetaDataMap if there are any reaction on the posting - */ - updatePostingWithReactions(): void { - if (this.posting.reactions && this.posting.reactions.length > 0) { - // filter out emoji for pin and archive as they should not be listed in the reactionMetaDataMap - const filteredReactions = this.posting.reactions.filter((reaction: Reaction) => reaction.emojiId !== this.pinEmojiId || reaction.emojiId !== this.archiveEmojiId); - this.reactionMetaDataMap = this.buildReactionMetaDataMap(filteredReactions); - } else { - this.reactionMetaDataMap = {}; - } - } - - /** - * builds the ReactionMetaDataMap data structure out of a given array of reactions - * @param reactions array of reactions associated to the current posting - */ - buildReactionMetaDataMap(reactions: Reaction[]): ReactionMetaDataMap { - return reactions.reduce((metaDataMap: ReactionMetaDataMap, reaction: Reaction) => { - const hasReacted = reaction.user?.id === this.metisService.getUser().id; - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const reactingUser = hasReacted ? PLACEHOLDER_USER_REACTED : reaction.user?.name!; - const reactionMetaData: ReactionMetaData = { - count: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].count + 1 : 1, - hasReacted: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].hasReacted || hasReacted : hasReacted, - reactingUsers: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].reactingUsers.concat(reactingUser) : [reactingUser], - }; - return { ...metaDataMap, [reaction.emojiId!]: reactionMetaData }; - }, {}); - } - - protected bookmarkPosting() { - this.onBookmarkClicked.emit(); - } -} diff --git a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts index 01e4f96bba36..efcc2ed8bf78 100644 --- a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts @@ -4,7 +4,6 @@ import { DebugElement, input, runInInjectionContext } from '@angular/core'; import { MockComponent, MockDirective, MockModule, MockPipe, ngMocks } from 'ng-mocks'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { By } from '@angular/platform-browser'; -import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component'; import { PostingContentComponent } from 'app/shared/metis/posting-content/posting-content.components'; import { metisPostExerciseUser1, metisResolvingAnswerPostUser1 } from '../../../../helpers/sample/metis-sample-data'; import { OverlayModule } from '@angular/cdk/overlay'; @@ -27,9 +26,12 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient } from '@angular/common/http'; import { MockSyncStorage } from '../../../../helpers/mocks/service/mock-sync-storage.service'; -import { SessionStorageService } from 'ngx-webstorage'; +import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; import { MockMetisConversationService } from '../../../../helpers/mocks/service/mock-metis-conversation.service'; +import { AccountService } from '../../../../../../../main/webapp/app/core/auth/account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; describe('AnswerPostComponent', () => { let component: AnswerPostComponent; @@ -50,7 +52,6 @@ describe('AnswerPostComponent', () => { MockComponent(PostingContentComponent), MockComponent(PostingHeaderComponent), MockComponent(AnswerPostCreateEditModalComponent), - MockComponent(AnswerPostReactionsBarComponent), ArtemisDatePipe, ArtemisTranslatePipe, MockDirective(TranslateDirective), @@ -63,6 +64,8 @@ describe('AnswerPostComponent', () => { { provide: TranslateService, useClass: MockTranslateService }, { provide: SessionStorageService, useClass: MockSyncStorage }, { provide: MetisConversationService, useClass: MockMetisConversationService }, + { provide: AccountService, useClass: MockAccountService }, + { provide: LocalStorageService, useClass: MockLocalStorageService }, ], }) .compileComponents() @@ -118,7 +121,7 @@ describe('AnswerPostComponent', () => { component.posting = metisResolvingAnswerPostUser1; fixture.detectChanges(); - const reactionsBar = debugElement.query(By.css('jhi-answer-post-reactions-bar')); + const reactionsBar = debugElement.query(By.css('jhi-posting-reactions-bar')); expect(reactionsBar).not.toBeNull(); }); diff --git a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts index 5866fbdeb437..40af94515518 100644 --- a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts @@ -4,8 +4,8 @@ import { DebugElement } from '@angular/core'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { PostComponent } from 'app/shared/metis/post/post.component'; import { getElement } from '../../../../helpers/utils/general.utils'; -import { PostingFooterComponent } from '../../../../../../../main/webapp/app/shared/metis/posting-footer/posting-footer.component'; -import { PostingHeaderComponent } from '../../../../../../../main/webapp/app/shared/metis/posting-header/posting-header.component'; +import { PostingFooterComponent } from 'app/shared/metis/posting-footer/posting-footer.component'; +import { PostingHeaderComponent } from 'app/shared/metis/posting-header/posting-header.component'; import { PostingContentComponent } from 'app/shared/metis/posting-content/posting-content.components'; import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service'; import { MetisService } from 'app/shared/metis/metis.service'; @@ -33,17 +33,20 @@ import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat import { HttpResponse } from '@angular/common/http'; import { MockRouter } from '../../../../helpers/mocks/mock-router'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DOCUMENT } from '@angular/common'; import { Posting, PostingType } from 'app/entities/metis/posting.model'; import { Post } from 'app/entities/metis/post.model'; -import { ArtemisTranslatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-translate.pipe'; -import { ArtemisDatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-date.pipe'; -import { TranslateDirective } from '../../../../../../../main/webapp/app/shared/language/translate.directive'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; import { TranslateService } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import dayjs from 'dayjs/esm'; +import { AccountService } from 'app/core/auth/account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; +import { LocalStorageService } from 'ngx-webstorage'; describe('PostComponent', () => { let component: PostComponent; @@ -71,6 +74,8 @@ describe('PostComponent', () => { MockProvider(MetisConversationService), MockProvider(OneToOneChatService), { provide: TranslateService, useClass: MockTranslateService }, + { provide: AccountService, useClass: MockAccountService }, + { provide: LocalStorageService, useClass: MockLocalStorageService }, ], declarations: [ PostComponent, @@ -80,7 +85,6 @@ describe('PostComponent', () => { MockComponent(PostingContentComponent), MockComponent(PostingFooterComponent), MockComponent(AnswerPostCreateEditModalComponent), - MockComponent(PostReactionsBarComponent), MockRouterLinkDirective, MockQueryParamsDirective, TranslatePipeMock, diff --git a/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts index bfdc7d6e567b..3efd5fc951c0 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MockComponent, MockModule } from 'ng-mocks'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; import { ArtemisCoursesRoutingModule } from 'app/overview/courses-routing.module'; import { MetisService } from 'app/shared/metis/metis.service'; import { PostService } from 'app/shared/metis/post.service'; @@ -46,7 +45,6 @@ describe('PostingFooterComponent', () => { PostingFooterComponent, TranslatePipeMock, MockComponent(FaIconComponent), - MockComponent(PostReactionsBarComponent), MockComponent(PostComponent), MockComponent(AnswerPostComponent), MockComponent(AnswerPostCreateEditModalComponent), diff --git a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts deleted file mode 100644 index 96517283b95c..000000000000 --- a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; -import { OverlayModule } from '@angular/cdk/overlay'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ReactionService } from 'app/shared/metis/reaction.service'; -import { MockReactionService } from '../../../../../helpers/mocks/service/mock-reaction.service'; -import { AccountService } from 'app/core/auth/account.service'; -import { MockAccountService } from '../../../../../helpers/mocks/service/mock-account.service'; -import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { EmojiModule } from '@ctrl/ngx-emoji-mart/ngx-emoji'; -import { PickerModule } from '@ctrl/ngx-emoji-mart'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { TranslateService } from '@ngx-translate/core'; -import { MockTranslateService, TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; -import { Router } from '@angular/router'; -import { MockRouter } from '../../../../../helpers/mocks/mock-router'; -import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mock-local-storage.service'; -import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { By } from '@angular/platform-browser'; -import { metisCourse, metisPostInChannel, metisResolvingAnswerPostUser1, metisUser1, post } from '../../../../../helpers/sample/metis-sample-data'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; -import { NotificationService } from 'app/shared/notification/notification.service'; -import { MockNotificationService } from '../../../../../helpers/mocks/service/mock-notification.service'; -import { provideHttpClient } from '@angular/common/http'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { getElement } from '../../../../../helpers/utils/general.utils'; -import { DebugElement } from '@angular/core'; -import { UserRole } from 'app/shared/metis/metis.util'; - -describe('AnswerPostReactionsBarComponent', () => { - let component: AnswerPostReactionsBarComponent; - let fixture: ComponentFixture; - let debugElement: DebugElement; - let metisService: MetisService; - let answerPost: AnswerPost; - let reactionToCreate: Reaction; - let reactionToDelete: Reaction; - let metisServiceUserIsAtLeastTutorMock: jest.SpyInstance; - let metisServiceUserIsAtLeastInstructorMock: jest.SpyInstance; - let metisServiceUserPostingAuthorMock: jest.SpyInstance; - let metisServiceUpdateAnswerPostMock: jest.SpyInstance; - - beforeEach(() => { - return TestBed.configureTestingModule({ - imports: [MockModule(OverlayModule), MockModule(EmojiModule), MockModule(PickerModule), MockModule(NgbTooltipModule)], - declarations: [ - AnswerPostReactionsBarComponent, - TranslatePipeMock, - MockPipe(ReactingUsersOnPostingPipe), - MockComponent(FaIconComponent), - MockComponent(EmojiComponent), - MockComponent(ConfirmIconComponent), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - MockProvider(SessionStorageService), - { provide: NotificationService, useClass: MockNotificationService }, - { provide: MetisService, useClass: MetisService }, - { provide: ReactionService, useClass: MockReactionService }, - { provide: AccountService, useClass: MockAccountService }, - { provide: TranslateService, useClass: MockTranslateService }, - { provide: Router, useClass: MockRouter }, - { provide: LocalStorageService, useClass: MockLocalStorageService }, - ], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(AnswerPostReactionsBarComponent); - metisService = TestBed.inject(MetisService); - debugElement = fixture.debugElement; - metisServiceUserIsAtLeastTutorMock = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); - metisServiceUserIsAtLeastInstructorMock = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); - metisServiceUserPostingAuthorMock = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); - metisServiceUpdateAnswerPostMock = jest.spyOn(metisService, 'updateAnswerPost'); - component = fixture.componentInstance; - answerPost = new AnswerPost(); - answerPost.id = 1; - answerPost.author = metisUser1; - reactionToDelete = new Reaction(); - reactionToDelete.id = 1; - reactionToDelete.emojiId = 'smile'; - reactionToDelete.user = metisUser1; - reactionToDelete.answerPost = answerPost; - answerPost.reactions = [reactionToDelete]; - component.posting = answerPost; - metisService.setCourse(metisCourse); - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - function getEditButton(): DebugElement | null { - return debugElement.query(By.css('button.reaction-button.clickable.px-2.fs-small.edit')); - } - - function getDeleteButton(): DebugElement | null { - return debugElement.query(By.css('.delete')); - } - - function getResolveButton(): DebugElement | null { - return debugElement.query(By.css('#toggleElement')); - } - - it('should invoke metis service method with correctly built reaction to create it', () => { - component.ngOnInit(); - fixture.detectChanges(); - const metisServiceCreateReactionSpy = jest.spyOn(metisService, 'createReaction'); - reactionToCreate = new Reaction(); - reactionToCreate.emojiId = '+1'; - reactionToCreate.answerPost = component.posting; - component.addOrRemoveReaction(reactionToCreate.emojiId); - expect(metisServiceCreateReactionSpy).toHaveBeenCalledWith(reactionToCreate); - expect(component.showReactionSelector).toBeFalse(); - }); - - it('should display edit and delete options to post author', () => { - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - expect(getEditButton()).not.toBeNull(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should display the delete option to instructor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(getEditButton()).toBeNull(); - }); - - it('should display the edit option to user if s/he is the author of posting', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - metisServiceUserPostingAuthorMock.mockReturnValue(true); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(getEditButton()).not.toBeNull(); - }); - - it('should display edit and delete options to tutor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getEditButton()).toBeNull(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should not display edit and delete options to users that are neither author or tutor', () => { - metisServiceUserIsAtLeastTutorMock.mockReturnValue(false); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - fixture.detectChanges(); - expect(getEditButton()).toBeNull(); - expect(getDeleteButton()).toBeNull(); - }); - - it('should emit event to create embedded view when edit icon is clicked', () => { - component.posting = metisResolvingAnswerPostUser1; - const openPostingCreateEditModalEmitSpy = jest.spyOn(component.openPostingCreateEditModal, 'emit'); - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - getElement(debugElement, '.edit').click(); - expect(openPostingCreateEditModalEmitSpy).toHaveBeenCalledOnce(); - }); - - it('should invoke metis service method with own reaction to delete it', () => { - component.posting!.author!.id = 99; - component.ngOnInit(); - fixture.detectChanges(); - const metisServiceCreateReactionSpy = jest.spyOn(metisService, 'deleteReaction'); - component.addOrRemoveReaction(reactionToDelete.emojiId!); - expect(metisServiceCreateReactionSpy).toHaveBeenCalledWith(reactionToDelete); - }); - - it('should invoke metis service method with own reaction to remove it', () => { - component.ngOnInit(); - const addOrRemoveSpy = jest.spyOn(component, 'addOrRemoveReaction'); - component.updateReaction(reactionToDelete.emojiId!); - expect(addOrRemoveSpy).toHaveBeenCalledWith(reactionToDelete.emojiId!); - }); - - it('answer now button should be invisible if answer is not the last one', () => { - component.posting = post; - component.isLastAnswer = false; - fixture.detectChanges(); - const answerNowButton = fixture.debugElement.query(By.css('.reply-btn')); - expect(answerNowButton).toBeNull(); - }); - - it('should invoke metis service when toggle resolve is clicked', () => { - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - expect(getResolveButton()).not.toBeNull(); - const previousState = component.posting.resolvesPost; - component.toggleResolvesPost(); - expect(component.posting.resolvesPost).toEqual(!previousState); - expect(metisServiceUpdateAnswerPostMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts similarity index 54% rename from src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts rename to src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts index bedc64c1611d..830fa68b6fda 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts @@ -1,42 +1,53 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MetisService } from 'app/shared/metis/metis.service'; -import { DebugElement } from '@angular/core'; +import { DebugElement, input, runInInjectionContext } from '@angular/core'; import { Post } from 'app/entities/metis/post.model'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; -import { getElement } from '../../../../../helpers/utils/general.utils'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; +import { getElement } from '../../../../helpers/utils/general.utils'; import { Reaction } from 'app/entities/metis/reaction.model'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ReactionService } from 'app/shared/metis/reaction.service'; -import { MockReactionService } from '../../../../../helpers/mocks/service/mock-reaction.service'; +import { MockReactionService } from '../../../../helpers/mocks/service/mock-reaction.service'; import { AccountService } from 'app/core/auth/account.service'; -import { MockAccountService } from '../../../../../helpers/mocks/service/mock-account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DisplayPriority, UserRole } from 'app/shared/metis/metis.util'; -import { MockTranslateService, TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; +import { MockTranslateService, TranslatePipeMock } from '../../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { Router } from '@angular/router'; -import { MockRouter } from '../../../../../helpers/mocks/mock-router'; -import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mock-local-storage.service'; +import { MockRouter } from '../../../../helpers/mocks/mock-router'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { By } from '@angular/platform-browser'; import { PLACEHOLDER_USER_REACTED, ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { metisAnnouncement, metisCourse, metisPostExerciseUser1, metisPostInChannel, metisUser1, sortedAnswerArray } from '../../../../../helpers/sample/metis-sample-data'; +import { + metisAnnouncement, + metisCourse, + metisPostExerciseUser1, + metisPostInChannel, + metisResolvingAnswerPostUser1, + metisUser1, + sortedAnswerArray, + unApprovedAnswerPost1, +} from '../../../../helpers/sample/metis-sample-data'; import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { NotificationService } from 'app/shared/notification/notification.service'; -import { MockNotificationService } from '../../../../../helpers/mocks/service/mock-notification.service'; +import { MockNotificationService } from '../../../../helpers/mocks/service/mock-notification.service'; import { ConversationDTO, ConversationType } from 'app/entities/metis/conversation/conversation.model'; import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { User } from 'app/core/user/user.model'; import { provideHttpClient } from '@angular/common/http'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; +import { PostingReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.component'; +import { Posting } from 'app/entities/metis/posting.model'; +import { AnswerPost } from 'app/entities/metis/answer-post.model'; -describe('PostReactionsBarComponent', () => { - let component: PostReactionsBarComponent; - let fixture: ComponentFixture; +describe('PostingReactionsBarComponent', () => { + let component: PostingReactionsBarComponent; + let fixture: ComponentFixture>; let debugElement: DebugElement; let metisService: MetisService; let accountService: AccountService; @@ -44,6 +55,7 @@ describe('PostReactionsBarComponent', () => { let metisServiceUserIsAtLeastTutorStub: jest.SpyInstance; let metisServiceUserIsAtLeastInstructorStub: jest.SpyInstance; let metisServiceUserIsAuthorOfPostingStub: jest.SpyInstance; + let metisServiceUpdateAnswerPostMock: jest.SpyInstance; let post: Post; let reactionToCreate: Reaction; let reactionToDelete: Reaction; @@ -57,7 +69,6 @@ describe('PostReactionsBarComponent', () => { return TestBed.configureTestingModule({ imports: [MockDirective(NgbTooltip)], declarations: [ - PostReactionsBarComponent, TranslatePipeMock, MockPipe(ReactingUsersOnPostingPipe), MockComponent(FaIconComponent), @@ -80,7 +91,7 @@ describe('PostReactionsBarComponent', () => { }) .compileComponents() .then(() => { - fixture = TestBed.createComponent(PostReactionsBarComponent); + fixture = TestBed.createComponent(PostingReactionsBarComponent); metisService = TestBed.inject(MetisService); accountService = TestBed.inject(AccountService); debugElement = fixture.debugElement; @@ -89,18 +100,21 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastTutorStub = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); metisServiceUserIsAtLeastInstructorStub = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); metisServiceUserIsAuthorOfPostingStub = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); + metisServiceUpdateAnswerPostMock = jest.spyOn(metisService, 'updateAnswerPost'); post = new Post(); post.id = 1; post.author = metisUser1; post.displayPriority = DisplayPriority.NONE; - component.sortedAnswerPosts = sortedAnswerArray; + runInInjectionContext(fixture.debugElement.injector, () => { + component.sortedAnswerPosts = input(sortedAnswerArray); + component.posting = input(post); + }); reactionToDelete = new Reaction(); reactionToDelete.id = 1; reactionToDelete.emojiId = 'smile'; reactionToDelete.user = metisUser1; reactionToDelete.post = post; post.reactions = [reactionToDelete]; - component.posting = post; metisService.setCourse(metisCourse); }); }); @@ -117,11 +131,15 @@ describe('PostReactionsBarComponent', () => { return debugElement.query(By.css('jhi-confirm-icon')); } + function getResolveButton(): DebugElement | null { + return debugElement.query(By.css('#toggleElement')); + } + it('should initialize user authority and reactions correctly', () => { metisCourse.isAtLeastTutor = false; metisService.setCourse(metisCourse); component.ngOnInit(); - expect(component.currentUserIsAtLeastTutor).toBeFalse(); + expect(component.isAtLeastTutorInCourse).toBeFalse(); fixture.detectChanges(); const reaction = getElement(debugElement, 'ngx-emoji'); expect(reaction).toBeDefined(); @@ -135,11 +153,12 @@ describe('PostReactionsBarComponent', () => { }); it('should display edit and delete options to the author when not in read-only or preview mode', () => { - component.readOnlyMode = false; - component.previewMode = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.posting = input({ id: 1, title: 'Test Post' } as Post); + }); jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(true); - component.posting = { id: 1, title: 'Test Post' } as Post; - component.ngOnInit(); fixture.detectChanges(); @@ -148,9 +167,12 @@ describe('PostReactionsBarComponent', () => { }); it('should display the delete option to user with channel moderation rights when not the author', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + component.posting = input({ id: 1, title: 'Test Post' } as Post); + }); const channelConversation = { type: ConversationType.CHANNEL, @@ -168,9 +190,11 @@ describe('PostReactionsBarComponent', () => { }); it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + }); const channelConversation = { type: ConversationType.CHANNEL, @@ -188,17 +212,19 @@ describe('PostReactionsBarComponent', () => { }); it('should display the edit option to user if s/he is the author of posting', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + }); const channelConversation = { type: ConversationType.CHANNEL, hasChannelModerationRights: true, } as ChannelDTO; - jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(true); - jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); component.ngOnInit(); @@ -207,12 +233,49 @@ describe('PostReactionsBarComponent', () => { expect(getEditButton()).not.toBeNull(); }); + it('should display the delete option to tutor if posting is in course-wide channel from a student', () => { + metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel }, authorRole: UserRole.USER } as AnswerPost); + component.isEmojiCount = input(false); + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should display edit and delete options to post author', () => { + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + fixture.detectChanges(); + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(getEditButton()).toBeNull(); + }); + it('should not display edit and delete options when user is not the author and lacks permissions', () => { - component.readOnlyMode = false; - component.previewMode = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.posting = input({ conversation: { isCourseWide: false } } as Post); + }); jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(false); jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); - component.posting = { conversation: { isCourseWide: false } } as Post; component.ngOnInit(); fixture.detectChanges(); @@ -225,8 +288,15 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisPostInChannel, authorRole: UserRole.USER }); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).toBeNull(); @@ -236,7 +306,9 @@ describe('PostReactionsBarComponent', () => { it('should not display edit and delete options to tutor if posting is announcement', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = metisAnnouncement; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisAnnouncement); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).toBeNull(); @@ -246,7 +318,9 @@ describe('PostReactionsBarComponent', () => { it('should display edit and delete options to instructor if his posting is announcement', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); - component.posting = metisAnnouncement; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisAnnouncement); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).not.toBeNull(); @@ -257,8 +331,15 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisPostInChannel, authorRole: UserRole.USER }); + }); component.ngOnInit(); fixture.detectChanges(); @@ -271,16 +352,17 @@ describe('PostReactionsBarComponent', () => { { type: ConversationType.GROUP_CHAT, creator: { id: 99 } }, { type: ConversationType.ONE_TO_ONE }, ])('should initialize user authority and reactions correctly with same user', (dto: ConversationDTO) => { - component.posting!.author!.id = 99; jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(dto); jest.spyOn(accountService, 'userIdentity', 'get').mockReturnValue({ id: 99 } as User); reactionToDelete.user = { id: 99 } as User; post.reactions = [reactionToDelete]; - component.posting = post; - + runInInjectionContext(fixture.debugElement.injector, () => { + post.author!!.id = 99; + component.posting = input(post); + component.isEmojiCount = input(true); + }); component.ngOnInit(); - component.isEmojiCount = true; fixture.detectChanges(); expect(component.reactionMetaDataMap).toEqual({ smile: { @@ -308,14 +390,17 @@ describe('PostReactionsBarComponent', () => { const metisServiceCreateReactionMock = jest.spyOn(metisService, 'createReaction'); reactionToCreate = new Reaction(); reactionToCreate.emojiId = '+1'; - reactionToCreate.post = component.posting; + reactionToCreate.post = component.posting(); component.addOrRemoveReaction(reactionToCreate.emojiId); expect(metisServiceCreateReactionMock).toHaveBeenCalledWith(reactionToCreate); expect(component.showReactionSelector).toBeFalsy(); }); it('should invoke metis service method with own reaction to delete it', () => { - component.posting!.author!.id = 99; + post.author!.id = 99; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); component.ngOnInit(); fixture.detectChanges(); const metisServiceDeleteReactionMock = jest.spyOn(metisService, 'deleteReaction'); @@ -324,14 +409,21 @@ describe('PostReactionsBarComponent', () => { expect(component.showReactionSelector).toBeFalsy(); }); + it('should invoke metis service method with own reaction to remove it', () => { + component.ngOnInit(); + const addOrRemoveSpy = jest.spyOn(component, 'addOrRemoveReaction'); + component.updateReaction(reactionToDelete.emojiId!); + expect(addOrRemoveSpy).toHaveBeenCalledWith(reactionToDelete.emojiId!); + }); + it('should invoke metis service method when pin icon is toggled', () => { jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue({ type: ConversationType.CHANNEL, hasChannelModerationRights: true } as ChannelDTO); component.ngOnInit(); fixture.detectChanges(); const pinEmoji = getElement(debugElement, '.pin'); pinEmoji.click(); - component.posting.displayPriority = DisplayPriority.PINNED; - expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(component.posting.id!, DisplayPriority.PINNED); + (component.posting() as Post)!.displayPriority = DisplayPriority.PINNED; + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(component.posting()!.id!, DisplayPriority.PINNED); component.ngOnChanges(); // set correct tooltips for tutor and post that is pinned and not archived expect(component.pinTooltip).toBe('artemisApp.metis.removePinPostTooltip'); @@ -340,7 +432,10 @@ describe('PostReactionsBarComponent', () => { it('should show non-clickable pin emoji with correct tooltip for student when post is pinned', () => { metisCourse.isAtLeastTutor = false; metisService.setCourse(metisCourse); - component.posting.displayPriority = DisplayPriority.PINNED; + post.displayPriority = DisplayPriority.PINNED; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); component.ngOnInit(); fixture.detectChanges(); const pinEmoji = getElement(debugElement, '.pin.reaction-button--not-hoverable'); @@ -352,25 +447,31 @@ describe('PostReactionsBarComponent', () => { }); it('should display button to show single answer', () => { - component.posting = post; - component.sortedAnswerPosts = [metisPostExerciseUser1]; - component.showAnswers = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.sortedAnswerPosts = input([metisPostExerciseUser1]); + component.showAnswers = input(false); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.expand-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('showSingleAnswer'); }); it('should display button to show multiple answers', () => { - component.posting = post; - component.showAnswers = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.showAnswers = input(false); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.expand-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('showMultipleAnswers'); }); it('should display button to collapse answers', () => { - component.posting = post; - component.showAnswers = true; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.showAnswers = input(true); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.collapse-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('collapseAnswers'); @@ -395,4 +496,113 @@ describe('PostReactionsBarComponent', () => { expect(showAnswersChangeSpy).toHaveBeenCalledWith(false); expect(closePostingCreateEditModalSpy).toHaveBeenCalled(); }); + + it('should not display edit and delete options to users that are neither author or tutor', () => { + metisServiceUserIsAtLeastTutorStub.mockReturnValue(false); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); + fixture.detectChanges(); + expect(getEditButton()).toBeNull(); + expect(getDeleteButton()).toBeNull(); + }); + + it('should emit event to create embedded view when edit icon is clicked', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisResolvingAnswerPostUser1); + }); + const openPostingCreateEditModalEmitSpy = jest.spyOn(component.openPostingCreateEditModal, 'emit'); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + fixture.detectChanges(); + getElement(debugElement, '.edit').click(); + expect(openPostingCreateEditModalEmitSpy).toHaveBeenCalledOnce(); + }); + + it('answer now button should be invisible if answer is not the last one', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.isLastAnswer = input(false); + }); + fixture.detectChanges(); + const answerNowButton = fixture.debugElement.query(By.css('.reply-btn')); + expect(answerNowButton).toBeNull(); + }); + + it('should invoke metis service when toggle resolve is clicked', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + unApprovedAnswerPost1.post = post; + component.posting = input(unApprovedAnswerPost1); + component.isEmojiCount = input(false); + + metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); + fixture.detectChanges(); + expect(getResolveButton()).not.toBeNull(); + const previousState = (component.posting() as AnswerPost).resolvesPost; + component.toggleResolvesPost(); + expect(component.getResolvesPost()).toEqual(!previousState); + expect(metisServiceUpdateAnswerPostMock).toHaveBeenCalledOnce(); + }); + }); + + it('should create a Reaction with answerPost when posting type is answerPost', () => { + const answerPost = new AnswerPost(); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(answerPost); + }); + + const reaction = component.buildReaction('thumbsup'); + expect(reaction.answerPost).toBe(answerPost); + expect(reaction.post).toBeUndefined(); + }); + + it('should create a Reaction with post when posting type is post', () => { + const post = new Post(); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); + + const reaction = component.buildReaction('thumbsup'); + expect(reaction.post).toBe(post); + expect(reaction.answerPost).toBeUndefined(); + }); + + it('should not toggle pin when user has no permission', () => { + const channelConversation = { + type: ConversationType.CHANNEL, + hasChannelModerationRights: false, + } as ChannelDTO; + component.setCanPin(channelConversation); + fixture.detectChanges(); + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).not.toHaveBeenCalled(); + }); + + it('should emit isDeleteEvent when deletePosting is called', () => { + const spy = jest.spyOn(component.isDeleteEvent, 'emit'); + component.deletePosting(); + expect(spy).toHaveBeenCalledWith(true); + }); + + it('should toggle pin and update displayPriority when user has permission', () => { + jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse').mockReturnValue(true); + + const moderatorChannel = { + type: ConversationType.CHANNEL, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(moderatorChannel); + + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); + component.ngOnInit(); + expect(component.displayPriority).toBe(DisplayPriority.NONE); + + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(post.id!, DisplayPriority.PINNED); + expect(component.displayPriority).toBe(DisplayPriority.PINNED); + + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(post.id!, DisplayPriority.NONE); + expect(component.displayPriority).toBe(DisplayPriority.NONE); + }); }); From df408b5b58ab417781d00b49d757bcb1412683e4 Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:31:57 +0100 Subject: [PATCH 16/17] Integrated code lifecycle: Allow admins to clear distributed data (#10111) --- .../web/admin/AdminBuildJobQueueResource.java | 18 +++++++ .../localci/SharedQueueManagementService.java | 17 ++++++- ...gent-clear-distributed-data.component.html | 26 ++++++++++ ...-agent-clear-distributed-data.component.ts | 36 ++++++++++++++ ...build-agent-pause-all-modal.component.html | 23 +++++++++ .../build-agent-pause-all-modal.component.ts | 29 ++++++++++++ .../build-agent-summary.component.html | 31 ++---------- .../build-agent-summary.component.ts | 47 +++++++++++++++---- .../build-agents/build-agents.service.ts | 11 +++++ src/main/webapp/i18n/de/buildAgents.json | 13 ++++- src/main/webapp/i18n/en/buildAgents.json | 13 ++++- ...t-clear-distributed-data.component.spec.ts | 46 ++++++++++++++++++ ...ld-agent-pause-all-modal.component.spec.ts | 38 +++++++++++++++ .../build-agent-summary.component.spec.ts | 19 ++++++++ .../build-agents/build-agents.service.spec.ts | 26 ++++++++++ 15 files changed, 354 insertions(+), 39 deletions(-) create mode 100644 src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.html create mode 100644 src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.ts create mode 100644 src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.html create mode 100644 src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.ts create mode 100644 src/test/javascript/spec/component/localci/build-agents/build-agent-clear-distributed-data.component.spec.ts create mode 100644 src/test/javascript/spec/component/localci/build-agents/build-agent-pause-all-modal.component.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index 07c7579751ca..f1b04ec0bd24 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java @@ -271,4 +271,22 @@ public ResponseEntity resumeAllBuildAgents() { localCIBuildJobQueueService.resumeAllBuildAgents(); return ResponseEntity.noContent().build(); } + + /** + * {@code PUT /api/admin/clear-distributed-data} : Clear all distributed data. + * This endpoint allows administrators to clear all distributed data. See {@link SharedQueueManagementService#clearDistributedData()}. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @return {@link ResponseEntity} with status code 200 (OK) if the distributed data was successfully cleared + * or an appropriate error response if something went wrong + */ + @DeleteMapping("clear-distributed-data") + public ResponseEntity clearDistributedData() { + log.debug("REST request to clear distributed data"); + localCIBuildJobQueueService.clearDistributedData(); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 1ed01bf6e7ac..1525ca42dfac 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -38,6 +38,7 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.DockerImageBuild; +import de.tum.cit.aet.artemis.buildagent.dto.ResultQueueItem; import de.tum.cit.aet.artemis.core.dto.SortingOrder; import de.tum.cit.aet.artemis.core.dto.pageablesearch.FinishedBuildJobPageableSearchDTO; import de.tum.cit.aet.artemis.core.service.ProfileService; @@ -61,6 +62,8 @@ public class SharedQueueManagementService { private IQueue queue; + private IQueue resultQueue; + /** * Map of build jobs currently being processed across all nodes */ @@ -100,6 +103,7 @@ public void init() { this.resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); this.buildAgentInformation.addEntryListener(new BuildAgentListener(), false); this.updateBuildAgentCapacity(); + this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); } /** @@ -306,6 +310,18 @@ public void cancelAllJobsForParticipation(long participationId) { } } + /** + * Clear all build related data from the distributed data structures. + * This method should only be called by an admin user. + */ + public void clearDistributedData() { + queue.clear(); + processingJobs.clear(); + dockerImageCleanupInfo.clear(); + resultQueue.clear(); + buildAgentInformation.clear(); + } + /** * Get all finished build jobs that match the search criteria. * @@ -407,7 +423,6 @@ private long getBuildJobRemainingDuration(BuildJobQueueItem buildJob, ZonedDateT return 0; } return Duration.between(now, estimatedCompletionDate).toSeconds(); - } private class BuildAgentListener diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.html new file mode 100644 index 000000000000..159a9f2313ec --- /dev/null +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.html @@ -0,0 +1,26 @@ + diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.ts new file mode 100644 index 000000000000..f26ae61e081a --- /dev/null +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component.ts @@ -0,0 +1,36 @@ +import { Component, computed, inject, model } from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { faTimes, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'jhi-build-agent-clear-distributed-data', + standalone: true, + imports: [FaIconComponent, TranslateDirective, FormsModule], + templateUrl: './build-agent-clear-distributed-data.component.html', +}) +export class BuildAgentClearDistributedDataComponent { + private activeModal = inject(NgbActiveModal); + + confirmationText = model(''); + + protected readonly faTimes = faTimes; + protected readonly faTrash = faTrash; + + private readonly expectedConfirmationText = 'CLEAR DATA'; + + buttonEnabled = computed(() => this.confirmationText() === this.expectedConfirmationText); + + /** + * Closes the modal by dismissing it + */ + cancel() { + this.activeModal.dismiss('cancel'); + } + + confirm() { + this.activeModal.close(true); + } +} diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.html new file mode 100644 index 000000000000..531a965db672 --- /dev/null +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.html @@ -0,0 +1,23 @@ + diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.ts new file mode 100644 index 000000000000..ee45c774e189 --- /dev/null +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component.ts @@ -0,0 +1,29 @@ +import { Component, inject } from '@angular/core'; +import { faPause, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'jhi-build-agent-pause-all-modal', + standalone: true, + imports: [FaIconComponent, TranslateDirective], + templateUrl: './build-agent-pause-all-modal.component.html', +}) +export class BuildAgentPauseAllModalComponent { + private activeModal = inject(NgbActiveModal); + + protected readonly faTimes = faTimes; + protected readonly faPause = faPause; + + /** + * Closes the modal by dismissing it + */ + cancel() { + this.activeModal.dismiss('cancel'); + } + + confirm() { + this.activeModal.close(true); + } +} diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html index 9f75be4268e7..b30f91f690bb 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html @@ -13,10 +13,14 @@

- + @@ -128,28 +132,3 @@

} - - - - - - - diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts index 01d0b2b48389..215ef2ed7333 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts @@ -3,15 +3,17 @@ import { BuildAgentInformation, BuildAgentStatus } from 'app/entities/programmin import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; -import { faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faPause, faPlay, faTimes, faTrash } from '@fortawesome/free-solid-svg-icons'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { Router } from '@angular/router'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { AlertService, AlertType } from 'app/core/util/alert.service'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisDataTableModule } from 'app/shared/data-table/data-table.module'; import { NgxDatatableModule } from '@siemens/ngx-datatable'; +import { BuildAgentPauseAllModalComponent } from 'app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component'; +import { BuildAgentClearDistributedDataComponent } from 'app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component'; @Component({ selector: 'jhi-build-agents', @@ -39,6 +41,7 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { protected readonly faTimes = faTimes; protected readonly faPause = faPause; protected readonly faPlay = faPlay; + protected readonly faTrash = faTrash; ngOnInit() { this.routerLink = this.router.url; @@ -97,11 +100,25 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { } } - displayPauseBuildAgentModal(modal: any) { - this.modalService.open(modal); + displayPauseBuildAgentModal() { + const modalRef: NgbModalRef = this.modalService.open(BuildAgentPauseAllModalComponent as Component); + modalRef.result.then((result) => { + if (result) { + this.pauseAllBuildAgents(); + } + }); } - pauseAllBuildAgents(modal?: any) { + displayClearDistributedDataModal() { + const modalRef: NgbModalRef = this.modalService.open(BuildAgentClearDistributedDataComponent as Component, { size: 'lg' }); + modalRef.result.then((result) => { + if (result) { + this.clearDistributedData(); + } + }); + } + + pauseAllBuildAgents() { this.buildAgentsService.pauseAllBuildAgents().subscribe({ next: () => { this.load(); @@ -117,9 +134,6 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { }); }, }); - if (modal) { - modal.close(); - } } resumeAllBuildAgents() { @@ -139,4 +153,21 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { }, }); } + + clearDistributedData() { + this.buildAgentsService.clearDistributedData().subscribe({ + next: () => { + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.distributedDataCleared', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.distributedDataClearFailed', + }); + }, + }); + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index bfe25213e61c..4945db48fede 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -73,4 +73,15 @@ export class BuildAgentsService { }), ); } + + /** + * Clears distributed data. This includes BuildJobQueue, ProcessingJobs, resultQueue, build agent Information, docker image clean up. + */ + clearDistributedData(): Observable { + return this.http.delete(`${this.adminResourceUrl}/clear-distributed-data`).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to clear distributed data\n${err.message}`)); + }), + ); + } } diff --git a/src/main/webapp/i18n/de/buildAgents.json b/src/main/webapp/i18n/de/buildAgents.json index bad9a78469d3..33e4512e31ce 100644 --- a/src/main/webapp/i18n/de/buildAgents.json +++ b/src/main/webapp/i18n/de/buildAgents.json @@ -24,13 +24,22 @@ "buildAgentResumeFailed": "Fortsetzen des Build-Agenten fehlgeschlagen.", "buildAgentWithoutName": "Der Name des Build-Agenten ist erforderlich.", "buildAgentsPaused": "Anfrage zum Anhalten aller Build-Agenten erfolgreich gesendet. Die Agenten akzeptieren keine neuen Build-Jobs und werden die aktuellen Build-Jobs entweder ordnungsgemäß beenden oder nach einer konfigurierbaren Anzahl von Sekunden abbrechen.", - "buildAgentsResumed": "Anfrage zum Fortsetzen aller Build-Agenten erfolgreich gesendet. Die Agenten werden wieder neue Build-Jobs annehmen." + "buildAgentsResumed": "Anfrage zum Fortsetzen aller Build-Agenten erfolgreich gesendet. Die Agenten werden wieder neue Build-Jobs annehmen.", + "distributedDataCleared": "Verteilte Daten erfolgreich gelöscht.", + "distributedDataClearFailed": "Löschen der verteilten Daten fehlgeschlagen." }, "pause": "Anhalten", "resume": "Fortsetzen", "pauseAll": "Alle Agenten Anhalten", "resumeAll": "Alle Agenten fortsetzen", - "pauseAllWarning": "Du bist dabei, alle Build-Agenten anzuhalten. Dies wird verhindern, dass sie neue Build-Jobs verarbeiten.\nBist du sicher, dass du fortfahren möchtest?" + "pauseAllWarning": "Du bist dabei, alle Build-Agenten anzuhalten. Dies wird verhindern, dass sie neue Build-Jobs verarbeiten.\nBist du sicher, dass du fortfahren möchtest?", + "clearDistributedData": { + "title": "Verteilte Daten löschen", + "descriptionFirst": "Diese Aktion wird alle verteilten Daten löschen. Dazu gehören die BuildJob Queue, die processingJobs Map, die DockerImageCleanUp Map, die BuildAgents Map und die BuildJobResults Queue.", + "descriptionSecond": "Diese Aktion ist irreversibel und führt zu Datenverlust. Das System benötigt einige Minuten, um wieder in den Normalbetrieb zurückzukehren.", + "descriptionThird": "Wenn du sicher bist, dass du fortfahren möchtest, gib bitte 'CLEAR DATA' in das untenstehende Eingabefeld ein und klicke auf die Schaltfläche 'Daten löschen'.", + "clearData": "Daten löschen" + } } } } diff --git a/src/main/webapp/i18n/en/buildAgents.json b/src/main/webapp/i18n/en/buildAgents.json index ce0cf7620104..bb3cdeda829a 100644 --- a/src/main/webapp/i18n/en/buildAgents.json +++ b/src/main/webapp/i18n/en/buildAgents.json @@ -24,13 +24,22 @@ "buildAgentResumeFailed": "Failed to resume build agent.", "buildAgentWithoutName": "Build agent name is required.", "buildAgentsPaused": "Pause request sent to all build agents. The agents will not accept new build jobs and will gracefully finish the current build jobs or will cancel them after a configurable seconds.", - "buildAgentsResumed": "Resume request sent to all build agents. The agents will start accepting new build jobs." + "buildAgentsResumed": "Resume request sent to all build agents. The agents will start accepting new build jobs.", + "distributedDataCleared": "Distributed data cleared successfully.", + "distributedDataClearFailed": "Failed to clear distributed data." }, "pause": "Pause", "resume": "Resume", "pauseAll": "Pause All Agents", "resumeAll": "Resume All Agents", - "pauseAllWarning": "You are about to pause all build agents. This will prevent them from processing any new build jobs.\nAre you sure you want to proceed?" + "pauseAllWarning": "You are about to pause all build agents. This will prevent them from processing any new build jobs.\nAre you sure you want to proceed?", + "clearDistributedData": { + "title": "Clear Distributed Data", + "descriptionFirst": "This action will clear all distributed data. This includes the buildJob queue, the processingJobs map, the dockerImageCleanUp map, the buildAgents map, and the buildJobResults queue.", + "descriptionSecond": "This action is irreversible and will lead in data loss. The system will take a few minutes to return to normal operation.", + "descriptionThird": "If you are sure you want to proceed, please type 'CLEAR DATA' in the input field below and click the 'Clear Data' button.", + "clearData": "Clear Data" + } } } } diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-clear-distributed-data.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-clear-distributed-data.component.spec.ts new file mode 100644 index 000000000000..d2bf010188bf --- /dev/null +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-clear-distributed-data.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BuildAgentClearDistributedDataComponent } from '../../../../../../main/webapp/app/localci/build-agents/build-agent-summary/build-agent-clear-distributed-data/build-agent-clear-distributed-data.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ArtemisTestModule } from '../../../test.module'; + +describe('BuildAgentClearDistributedDataComponent', () => { + let component: BuildAgentClearDistributedDataComponent; + let fixture: ComponentFixture; + const activeModal: NgbActiveModal = { + dismiss: jest.fn(), + close: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, BuildAgentClearDistributedDataComponent], + providers: [{ provide: NgbActiveModal, useValue: activeModal }], + }).compileComponents(); + + fixture = TestBed.createComponent(BuildAgentClearDistributedDataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should dismiss on cancel', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.cancel(); + expect(dismissSpy).toHaveBeenCalledWith('cancel'); + }); + + it('should have button enabled when confirmation text is correct', () => { + component.confirmationText.set('clear data'); + expect(component.buttonEnabled()).toBeFalsy(); + + component.confirmationText.set('CLEAR DATA'); + expect(component.buttonEnabled()).toBeTruthy(); + }); + + it('should close on confirm', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.confirm(); + expect(closeSpy).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-pause-all-modal.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-pause-all-modal.component.spec.ts new file mode 100644 index 000000000000..fe9af54c797e --- /dev/null +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-pause-all-modal.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BuildAgentPauseAllModalComponent } from '../../../../../../main/webapp/app/localci/build-agents/build-agent-summary/build-agent-pause-all-modal/build-agent-pause-all-modal.component'; +import { ArtemisTestModule } from '../../../test.module'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +describe('BuildAgentPauseAllModalComponent', () => { + let component: BuildAgentPauseAllModalComponent; + let fixture: ComponentFixture; + const activeModal: NgbActiveModal = { + dismiss: jest.fn(), + close: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, BuildAgentPauseAllModalComponent], + providers: [{ provide: NgbActiveModal, useValue: activeModal }], + }).compileComponents(); + + fixture = TestBed.createComponent(BuildAgentPauseAllModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should dismiss on cancel', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.cancel(); + expect(dismissSpy).toHaveBeenCalledWith('cancel'); + }); + + it('should close on confirm', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.confirm(); + expect(closeSpy).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts index 40967076b0c9..d53f6034166e 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts @@ -28,6 +28,7 @@ describe('BuildAgentSummaryComponent', () => { getBuildAgentSummary: jest.fn().mockReturnValue(of([])), pauseAllBuildAgents: jest.fn().mockReturnValue(of({})), resumeAllBuildAgents: jest.fn().mockReturnValue(of({})), + clearDistributedData: jest.fn().mockReturnValue(of({})), }; const repositoryInfo: RepositoryInfo = { @@ -255,4 +256,22 @@ describe('BuildAgentSummaryComponent', () => { message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', }); }); + + it('should call correct service method when clearing distributed data', () => { + component.clearDistributedData(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.distributedDataCleared', + }); + }); + + it('should show alert when error in clearing distributed data', () => { + mockBuildAgentsService.clearDistributedData.mockReturnValue(throwError(() => new Error())); + + component.clearDistributedData(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.distributedDataClearFailed', + }); + }); }); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts index 69fb9bca2acd..513f9802e881 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts @@ -232,6 +232,32 @@ describe('BuildAgentsService', () => { } }); + it('should clear distributed data', () => { + service.clearDistributedData().subscribe(); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/clear-distributed-data`); + expect(req.request.method).toBe('DELETE'); + req.flush({}); + }); + + it('should handle clear distributed data error', async () => { + const errorMessage = 'Failed to clear distributed data'; + + const observable = lastValueFrom(service.clearDistributedData()); + + // Set up the expected HTTP request and flush the response with an error. + const req = httpMock.expectOne(`${service.adminResourceUrl}/clear-distributed-data`); + expect(req.request.method).toBe('DELETE'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + afterEach(() => { httpMock.verify(); // Verify that there are no outstanding requests. }); From 833e46ce69fed4461333629f9f8cc3f922d50018 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 31 Jan 2025 22:37:41 +0100 Subject: [PATCH 17/17] Development: Update test coverage thresholds --- gradle/jacoco.gradle | 38 +++++++++++++++++++------------------- jest.config.js | 8 ++++---- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 9efd486f9cbd..4bd04aa6b888 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -1,80 +1,80 @@ ext { AggregatedCoverageThresholds = [ - "INSTRUCTION": 0.895, + "INSTRUCTION": 0.89, "CLASS": 56 ]; // (Isolated) thresholds when executing each module on its own ModuleCoverageThresholds = [ "assessment" : [ - "INSTRUCTION": 0.779, + "INSTRUCTION": 0.77, "CLASS": 8 ], "athena" : [ - "INSTRUCTION": 0.856, + "INSTRUCTION": 0.85, "CLASS": 2 ], "atlas" : [ - "INSTRUCTION": 0.850, + "INSTRUCTION": 0.85, "CLASS": 12 ], "buildagent" : [ - "INSTRUCTION": 0.313, + "INSTRUCTION": 0.31, "CLASS": 13 ], "communication": [ - "INSTRUCTION": 0.890, + "INSTRUCTION": 0.89, "CLASS": 7 ], "core" : [ - "INSTRUCTION": 0.657, + "INSTRUCTION": 0.65, "CLASS": 69 ], "exam" : [ - "INSTRUCTION": 0.914, + "INSTRUCTION": 0.91, "CLASS": 1 ], "exercise" : [ - "INSTRUCTION": 0.649, + "INSTRUCTION": 0.64, "CLASS": 9 ], "fileupload" : [ - "INSTRUCTION": 0.906, + "INSTRUCTION": 0.90, "CLASS": 1 ], "iris" : [ - "INSTRUCTION": 0.760, + "INSTRUCTION": 0.74, "CLASS": 25 ], "lecture" : [ - "INSTRUCTION": 0.867, + "INSTRUCTION": 0.86, "CLASS": 0 ], "lti" : [ - "INSTRUCTION": 0.770, + "INSTRUCTION": 0.77, "CLASS": 3 ], "modeling" : [ - "INSTRUCTION": 0.891, + "INSTRUCTION": 0.89, "CLASS": 2 ], "plagiarism" : [ - "INSTRUCTION": 0.760, + "INSTRUCTION": 0.76, "CLASS": 1 ], "programming" : [ - "INSTRUCTION": 0.863, + "INSTRUCTION": 0.86, "CLASS": 12 ], "quiz" : [ - "INSTRUCTION": 0.784, + "INSTRUCTION": 0.78, "CLASS": 6 ], "text" : [ - "INSTRUCTION": 0.847, + "INSTRUCTION": 0.84, "CLASS": 0 ], "tutorialgroup": [ - "INSTRUCTION": 0.915, + "INSTRUCTION": 0.91, "CLASS": 0 ], ] diff --git a/jest.config.js b/jest.config.js index b43abf32ab01..2b1953d429b9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -91,10 +91,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 88.82, - branches: 74.45, - functions: 82.97, - lines: 88.84, + statements: 88.87, + branches: 74.51, + functions: 83.09, + lines: 88.89, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'],