diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3a64a36f2dfc..56943ec183d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,19 +7,19 @@ - [ ] I tested **all** changes and their related features with **all** corresponding user types on a test server. - [ ] This is a small issue that I tested locally and was confirmed by another developer on a test server. -- [ ] Language: I followed the [guidelines for inclusive, diversity-sensitive, and appreciative language](https://ls1intum.github.io/Artemis/dev/guidelines/language-guidelines/). -- [ ] I chose a title conforming to the [naming conventions for pull requests](https://ls1intum.github.io/Artemis/dev/development-process/#naming-conventions-for-github-pull-requests). +- [ ] Language: I followed the [guidelines for inclusive, diversity-sensitive, and appreciative language](https://docs.artemis.cit.tum.de/dev/guidelines/language-guidelines/). +- [ ] I chose a title conforming to the [naming conventions for pull requests](https://docs.artemis.cit.tum.de/dev/development-process/#naming-conventions-for-github-pull-requests). #### Server - [ ] **Important**: I implemented the changes with a very good performance and prevented too many (unnecessary) database calls. -- [ ] I followed the [coding and design guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/server/). +- [ ] I followed the [coding and design guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/server/). - [ ] I added multiple integration tests (Spring) related to the features (with a high test coverage). -- [ ] I added pre-authorization annotations according to the [guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/server/#rest-endpoint-best-practices-for-authorization) and checked the course groups for all new REST Calls (security). +- [ ] I added pre-authorization annotations according to the [guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/server/#rest-endpoint-best-practices-for-authorization) and checked the course groups for all new REST Calls (security). - [ ] I documented the Java code using JavaDoc style. #### Client - [ ] **Important**: I implemented the changes with a very good performance, prevented too many (unnecessary) REST calls and made sure the UI is responsive, even with large data. -- [ ] I followed the [coding and design guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/client/). -- [ ] Following the [theming guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/client-design/), I specified colors only in the theming variable files and checked that the changes look consistent in both the light and the dark theme. -- [ ] I added multiple integration tests (Jest) related to the features (with a high test coverage), while following the [test guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/client-tests/). +- [ ] I followed the [coding and design guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/client/). +- [ ] Following the [theming guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/client-design/), I specified colors only in the theming variable files and checked that the changes look consistent in both the light and the dark theme. +- [ ] I added multiple integration tests (Jest) related to the features (with a high test coverage), while following the [test guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/client-tests/). - [ ] I added `authorities` to all new routes and checked the course groups for displaying navigation elements (links, buttons). - [ ] I documented the TypeScript code using JSDoc style. - [ ] I added multiple screenshots/screencasts of my UI changes. @@ -57,9 +57,22 @@ Prerequisites: 1. Log in to Artemis 2. Participate in the exam as a student -3. Make sure that the UI of the programming exercise in the exam mode stays unchanged. You can use the [exam mode documentation](https://ls1intum.github.io/Artemis/user/exam_mode/) as reference. +3. Make sure that the UI of the programming exercise in the exam mode stays unchanged. You can use the [exam mode documentation](https://docs.artemis.cit.tum.de/user/exam_mode/) as reference. 4. ... +### Testserver States +> [!NOTE] +> These badges show the state of the test servers. +> Green = Currently available, Red = Currently locked + +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test1) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test2) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test3) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test4) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test5) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test6) +![](https://byob.yarr.is/ls1intum/Artemis/artemis-test9) + ### Review Progress diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88a0060022dc..fe61ec7b4d7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,7 @@ on: - 'docs/**' - '.github/**' - '!.github/workflows/build.yml' + - '!.github/workflows/testserver.yml' push: branches: - develop diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dc6e103ffd29..648be63e90c0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,21 +35,45 @@ jobs: build-command: make html dirhtml - uses: actions/upload-artifact@v3 with: - name: Documentation + name: documentation path: docs/_build/html/ - - uses: actions/upload-pages-artifact@v2 - with: - path: docs/_build/dirhtml/ # Deployment job deploy: if: github.ref == 'refs/heads/develop' environment: - name: github-pages - url: "https://ls1intum.github.io/Artemis" + name: documentation + url: "https://docs.artemis.cit.tum.de" runs-on: ubuntu-latest needs: docs steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v2 + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: documentation + path: public + - name: Copy site to server + uses: appleboy/scp-action@master + with: + host: docs.artemis.cit.tum.de + username: ${{ secrets.DOCS_SSH_USER }} + key: ${{ secrets.DOCS_SSH_PRIVATE }} + proxy_host: ${{ secrets.PROXY_HOST }} + proxy_username: ${{ secrets.PROXY_USERNAME }} + proxy_key: ${{ secrets.PROXY_KEY }} + proxy_port: ${{ secrets.PROXY_PORT }} + source: "public" + target: ${{ secrets.DOCS_HOME }} + - name: Move site to www + uses: appleboy/ssh-action@master + with: + host: docs.artemis.cit.tum.de + username: ${{ secrets.DOCS_SSH_USER }} + key: ${{ secrets.DOCS_SSH_PRIVATE }} + proxy_host: ${{ secrets.PROXY_HOST }} + proxy_username: ${{ secrets.PROXY_USERNAME }} + proxy_key: ${{ secrets.PROXY_KEY }} + proxy_port: ${{ secrets.PROXY_PORT }} + script: | + rm -rf ${{ secrets.DOCS_WWW }}/* + mv -f public/* ${{ secrets.DOCS_WWW }}/ diff --git a/.github/workflows/pullrequest-closed.yml b/.github/workflows/pullrequest-closed.yml index 6f7be87a217b..de82d9eef64d 100644 --- a/.github/workflows/pullrequest-closed.yml +++ b/.github/workflows/pullrequest-closed.yml @@ -19,21 +19,78 @@ jobs: tag: pr-${{ github.event.pull_request.number }} untagged-older-than: 28 - # If a PR is closed it should no longer lock any testservers - remove-testserver-locks: + # If a PR is closed the testserver lock should be removed and corresponding badges updated + process_labels: + name: Process labels runs-on: ubuntu-latest + outputs: + labels: ${{ steps.process.outputs.labels }} + badges: ${{ steps.process.outputs.badges }} steps: - - uses: actions-ecosystem/action-remove-labels@v1 + - name: Process labels + id: process + uses: actions/github-script@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - github_token: ${{ secrets.GITHUB_TOKEN }} - labels: | - lock:artemis-test1 - lock:artemis-test2 - lock:artemis-test3 - lock:artemis-test4 - lock:artemis-test5 - lock:artemis-test6 - lock:artemis-test7 - lock:artemis-test8 - lock:artemis-test9 - lock:artemis-test10 + script: | + const labelsToRemove = []; + const labelsToProcess = []; + + // Get the PR number + const prNumber = context.payload.pull_request.number; + + // Iterate through labels on the PR + for (const label of context.payload.pull_request.labels) { + const labelName = label.name; + const regex = /^lock:artemis-test(\d+)$/; + + if (regex.test(labelName)) { + // Extract the part after "lock:" using capture groups + const extractedLabel = labelName.match(regex)[1]; + labelsToProcess.push(extractedLabel); + labelsToRemove.push(labelName); + } + } + + // Do something with the extracted labels + console.log('Badges to process:', labelsToProcess); + console.log('Labels to remove:', labelsToRemove); + + // Use the labelsToRemove array to remove the matching labels + core.setOutput('badges', JSON.stringify(labelsToProcess)); + core.setOutput('labels', labelsToRemove.join(', ')); + + + remove_labels: + name: Remove labels + needs: process_labels + runs-on: ubuntu-latest + if: ${{ needs.process_labels.outputs.labels != '' }} + + steps: + - name: Remove labels + uses: actions-ecosystem/action-remove-labels@v1 + with: + labels: ${{ needs.process_labels.outputs.labels }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + update_badges: + name: Update badges + needs: process_labels + runs-on: ubuntu-latest + strategy: + matrix: + badge: ${{ fromJson(needs.process_labels.outputs.badges) }} + if: ${{ needs.process_labels.outputs.labels != '' }} + + steps: + - name: Update badge + uses: RubbaBoy/BYOB@v1.3.0 + with: + NAME: "artemis-test${{ matrix.badge }}" + LABEL: "artemis-test${{ matrix.badge }}.artemis.cit.tum.de" + STATUS: ${{ github.event.pull_request.head.ref }} + COLOR: green + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pullrequest-unlabeled.yml b/.github/workflows/pullrequest-unlabeled.yml new file mode 100644 index 000000000000..9037060bd8ea --- /dev/null +++ b/.github/workflows/pullrequest-unlabeled.yml @@ -0,0 +1,32 @@ +name: Pull Request Label Removal + +on: + pull_request_target: + types: + - unlabeled + +jobs: + update_badges: + name: Update test server badges + runs-on: ubuntu-latest + + steps: + - name: Get badge id + id: env + uses: actions/github-script@v6 + with: + script: | + const labelName = context.payload.label.name; + const badge = labelName.replace(/^lock:artemis-test/, ''); + core.setOutput('BADGE', badge); + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update badge + uses: RubbaBoy/BYOB@v1.3.0 + with: + NAME: "artemis-test${{ steps.env.outputs.BADGE }}" + LABEL: "artemis-test${{ steps.env.outputs.BADGE }}.artemis.cit.tum.de" + STATUS: ${{ github.event.pull_request.head.ref }} + COLOR: green + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/testserver.yml b/.github/workflows/testserver.yml index dd384324d94b..aa25bc7518f1 100644 --- a/.github/workflows/testserver.yml +++ b/.github/workflows/testserver.yml @@ -82,7 +82,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '### ❌ Unable to deploy to test servers ❌\nThe docker build needs to run through before deploying.' + body: '### ⚠️ Unable to deploy to test servers ⚠️\nThe docker build needs to run through before deploying.' }) core.setFailed('The build needs to run through first. Please wait for the build to finish and then try again.') @@ -96,38 +96,38 @@ jobs: include: # Commented out environments are not yet available and will be enabled in the future - #- environment: artemis-test1.artemis.cit.tum.de - # label-identifier: artemis-test1 - # url: https://artemis-test1.artemis.cit.tum.de - # user: deployment - # hosts: artemis-test1.artemis.cit.tum.de - # folder: /opt/artemis - # host_keys: | - # artemis-test1.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDS+lFrN7hvzFjESxSQkmLbBdra9dWo33izxLRO2x5oTQwcbZA66Y3cQungIV460aQHwH+K0ALQuEc5EA7LegfFXo//t3kqXEALf0xRkO5tLnJEBpwnjpiOj8GFVIR8XOopKHf3zLo7/rmCPmdN0TKKigw9PcowB3Nf9TlpXvVtpkNTqnjwxfsLqvVjTf+8ji48Xe8zOhAH4zyJEc1KFM7XNdYYJPUctQyOoF+9QrTEW2GLYRkb2IqL8RDd09riUrjtsQJdoK21ATPC07j6XqGCgZxZQfmq4NlqEg2euQt45l0ZkVp3sQG70U0NkwKsLLhABWOzKDWvix7mJ/YDXCkk4Q8Badus1vIBAZYuTATfsEwXnQEnKTl8i4l7CE6U4PZLhwOgxcLBU9E3YKLjCsp8wfEV0n/wyoxXKUSH7Lb0jPQ4JMVUgNT5Rkdt0RE3YMWxVLrQNs6u33m2LrXt3lmiZsGpFa08RcjnSdFtHonL/CkwU00kckoMwRPgUniJKmM= - # artemis-test1.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMk4f5/x4grLMOY9jQCp3SfSjF81iWYRCbXJHBMWzG3TbQq9d2zW8bGCak5TGwPCDnjfBPRRSF57LZJlNaf3wPE= - # artemis-test1.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBGzlZUARYQBdq85FtCe4IALac1dSWoNsvQZmeCjPmS/ + - environment: artemis-test1.artemis.cit.tum.de + label-identifier: artemis-test1 + url: https://artemis-test1.artemis.cit.tum.de + user: deployment + hosts: artemis-test1.artemis.cit.tum.de + folder: /opt/artemis + host_keys: | + artemis-test1.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDS+lFrN7hvzFjESxSQkmLbBdra9dWo33izxLRO2x5oTQwcbZA66Y3cQungIV460aQHwH+K0ALQuEc5EA7LegfFXo//t3kqXEALf0xRkO5tLnJEBpwnjpiOj8GFVIR8XOopKHf3zLo7/rmCPmdN0TKKigw9PcowB3Nf9TlpXvVtpkNTqnjwxfsLqvVjTf+8ji48Xe8zOhAH4zyJEc1KFM7XNdYYJPUctQyOoF+9QrTEW2GLYRkb2IqL8RDd09riUrjtsQJdoK21ATPC07j6XqGCgZxZQfmq4NlqEg2euQt45l0ZkVp3sQG70U0NkwKsLLhABWOzKDWvix7mJ/YDXCkk4Q8Badus1vIBAZYuTATfsEwXnQEnKTl8i4l7CE6U4PZLhwOgxcLBU9E3YKLjCsp8wfEV0n/wyoxXKUSH7Lb0jPQ4JMVUgNT5Rkdt0RE3YMWxVLrQNs6u33m2LrXt3lmiZsGpFa08RcjnSdFtHonL/CkwU00kckoMwRPgUniJKmM= + artemis-test1.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMk4f5/x4grLMOY9jQCp3SfSjF81iWYRCbXJHBMWzG3TbQq9d2zW8bGCak5TGwPCDnjfBPRRSF57LZJlNaf3wPE= + artemis-test1.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBGzlZUARYQBdq85FtCe4IALac1dSWoNsvQZmeCjPmS/ - #- environment: artemis-test2.artemis.cit.tum.de - # label-identifier: artemis-test2 - # url: https://artemis-test2.artemis.cit.tum.de - # user: deployment - # hosts: artemis-test2.artemis.cit.tum.de - # folder: /opt/artemis - # host_keys: | - # artemis-test2.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDUqNIPYUJBuItIfGzVu8jtpNWerFoqNq34cPU/+w5biwCrQA/RMIRt0H49ETc4vgc3rN85//FJ9j2x4jGfyS3jxj/ind6PX1QIBjGl09s2TFz53Z8OwcAw9iXJrtKqZGfxr8sbVfboDGa/kyr8E+YUJo+6bPusLQPSAJn8GUGgcNKHQSX7A4sEIeq2uU1RqGKDWJSxyECUxyubfNB6LCZd3ezEP1MPDnvhoF/5cEP4QxeTsVIkwIIAE8oVxNM7Ni8xqkl8sUM//SdzglFsK2gE5eSZ5OpQ5h/Cc3Oo1z7LnwlMwo8fGnhAcoUpDjuKD/2AdIhkyW0B4xUKbKVO94kVSuBUXErYqF4bHByGgjkzR0JTEwk5+shlUjoEA6DBxBO08CudJcTDUhk5+8fRwOzxfSTakr8sOfakgo7W6fBl3P4lHSdsd7VqKINcR3A9QYSXeiEeqliXnTkDSsZw4ux9JyuLle1DHPbTuH8f+vEosdxda+djm3FeijYTe4QS87k= - # artemis-test2.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDRCF0zH3u38zVPNUhJI7zIDsXa3ONiU0GeCv/ybjawkh4alBPnlXtdH0cG1JPtR/Jz/gau92dcqiIFtqdDCDkg= - # artemis-test2.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMV70ACU6GdLtf1MwUklNltC78UoOPsasZruYh0Ord6n + - environment: artemis-test2.artemis.cit.tum.de + label-identifier: artemis-test2 + url: https://artemis-test2.artemis.cit.tum.de + user: deployment + hosts: artemis-test2.artemis.cit.tum.de + folder: /opt/artemis + host_keys: | + artemis-test2.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDUqNIPYUJBuItIfGzVu8jtpNWerFoqNq34cPU/+w5biwCrQA/RMIRt0H49ETc4vgc3rN85//FJ9j2x4jGfyS3jxj/ind6PX1QIBjGl09s2TFz53Z8OwcAw9iXJrtKqZGfxr8sbVfboDGa/kyr8E+YUJo+6bPusLQPSAJn8GUGgcNKHQSX7A4sEIeq2uU1RqGKDWJSxyECUxyubfNB6LCZd3ezEP1MPDnvhoF/5cEP4QxeTsVIkwIIAE8oVxNM7Ni8xqkl8sUM//SdzglFsK2gE5eSZ5OpQ5h/Cc3Oo1z7LnwlMwo8fGnhAcoUpDjuKD/2AdIhkyW0B4xUKbKVO94kVSuBUXErYqF4bHByGgjkzR0JTEwk5+shlUjoEA6DBxBO08CudJcTDUhk5+8fRwOzxfSTakr8sOfakgo7W6fBl3P4lHSdsd7VqKINcR3A9QYSXeiEeqliXnTkDSsZw4ux9JyuLle1DHPbTuH8f+vEosdxda+djm3FeijYTe4QS87k= + artemis-test2.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDRCF0zH3u38zVPNUhJI7zIDsXa3ONiU0GeCv/ybjawkh4alBPnlXtdH0cG1JPtR/Jz/gau92dcqiIFtqdDCDkg= + artemis-test2.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMV70ACU6GdLtf1MwUklNltC78UoOPsasZruYh0Ord6n - #- environment: artemis-test3.artemis.cit.tum.de - # label-identifier: artemis-test3 - # url: https://artemis-test3.artemis.cit.tum.de - # user: deployment - # hosts: artemis-test3.artemis.cit.tum.de - # folder: /opt/artemis - # host_keys: | - # artemis-test3.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3zNbNkbYMUbNKCtVSa1GH7ovysowYQQHQK3owHbfjyKsaS6lTO2o16mMe3pu0+CwMdsJqBqn5Lt6M9HyCW74WwwWbOcUrcSqE/37hx5Ja2YzCwucTpvUCR3WVrmwBEYCoS5ZdQfFmyfVQtqjCCI5DdRk1wgY00eLoc8d6YOb6XnmMTs41WcyXFl8ffjhG3jMGlQILI8zhyZqnYB8HwadRAp8Oa6+DyGhOBAV5d3S0AZqjMKNoBGSAXCfs/nG3jBigxNCV7zeIjuhi2Ize/GY+gMcCrvWhjs/lXERII7RDIlcZogyN9+rGRX8X8okMoS1YONxzWPFb6XQajeDriESQ5txyHXpbFwcSLSU8DzleS9UZMah99knMs0Fyzu0q4rbCS1PtaAJfSOLjVgp67j3DNkXV+P5CZaSYI7hl377u0aTTHB5W3Myn7kXrNL2vjRk/mui+/Ds/+PPCJERWJAhCYp+CGj/itcOKPJqyfLL3ejzpqhRDzDTWCHU2cUnE2PE= - # artemis-test3.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBF3JQ7fPlW+Rua4JXCl4Dska45h4PhClWvmcFQHGA4H0bLEG+lVXuw5yuRk9lDD88pvzStFewk9EbmJ8Sja0zKo= - # artemis-test3.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGE67ADXnCgvbyJfqDAmSvegYwmCzfOyAUKBbJpwh7oU + - environment: artemis-test3.artemis.cit.tum.de + label-identifier: artemis-test3 + url: https://artemis-test3.artemis.cit.tum.de + user: deployment + hosts: artemis-test3.artemis.cit.tum.de + folder: /opt/artemis + host_keys: | + artemis-test3.artemis.cit.tum.de ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3zNbNkbYMUbNKCtVSa1GH7ovysowYQQHQK3owHbfjyKsaS6lTO2o16mMe3pu0+CwMdsJqBqn5Lt6M9HyCW74WwwWbOcUrcSqE/37hx5Ja2YzCwucTpvUCR3WVrmwBEYCoS5ZdQfFmyfVQtqjCCI5DdRk1wgY00eLoc8d6YOb6XnmMTs41WcyXFl8ffjhG3jMGlQILI8zhyZqnYB8HwadRAp8Oa6+DyGhOBAV5d3S0AZqjMKNoBGSAXCfs/nG3jBigxNCV7zeIjuhi2Ize/GY+gMcCrvWhjs/lXERII7RDIlcZogyN9+rGRX8X8okMoS1YONxzWPFb6XQajeDriESQ5txyHXpbFwcSLSU8DzleS9UZMah99knMs0Fyzu0q4rbCS1PtaAJfSOLjVgp67j3DNkXV+P5CZaSYI7hl377u0aTTHB5W3Myn7kXrNL2vjRk/mui+/Ds/+PPCJERWJAhCYp+CGj/itcOKPJqyfLL3ejzpqhRDzDTWCHU2cUnE2PE= + artemis-test3.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBF3JQ7fPlW+Rua4JXCl4Dska45h4PhClWvmcFQHGA4H0bLEG+lVXuw5yuRk9lDD88pvzStFewk9EbmJ8Sja0zKo= + artemis-test3.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGE67ADXnCgvbyJfqDAmSvegYwmCzfOyAUKBbJpwh7oU - environment: artemis-test4.artemis.cit.tum.de label-identifier: artemis-test4 @@ -206,9 +206,9 @@ jobs: # artemis-test10.artemis.cit.tum.de ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCICiCLtljuYwnBxGKurZjMuDsYFfSJ/5UD8gaDa1+CWPqmM4cFTniw+ORglDpaySlusPbXwll+K0JPkIm8E6+Y= # artemis-test10.artemis.cit.tum.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAF8QXj0d2na/rBaVTIXfGu3HgtFppoE04Oj1Od2O3kD outputs: - #TS1: ${{ steps.filter.outputs.artemis-test1 || '' }} - #TS2: ${{ steps.filter.outputs.artemis-test2 || '' }} - #TS3: ${{ steps.filter.outputs.artemis-test3 || '' }} + TS1: ${{ steps.filter.outputs.artemis-test1 || '' }} + TS2: ${{ steps.filter.outputs.artemis-test2 || '' }} + TS3: ${{ steps.filter.outputs.artemis-test3 || '' }} TS4: ${{ steps.filter.outputs.artemis-test4 || '' }} TS5: ${{ steps.filter.outputs.artemis-test5 || '' }} TS6: ${{ steps.filter.outputs.artemis-test6 || '' }} @@ -252,6 +252,7 @@ jobs: deploy: needs: [ process-matrix ] runs-on: ubuntu-latest + concurrency: test-servers-deploy strategy: fail-fast: false matrix: @@ -293,7 +294,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: `### ❌ Unable to deploy to test servers ❌\nTestserver "${{ matrix.environment }}" is already in use by PR #${issues[0].number}.` + body: `#### ⚠️ Unable to deploy to test servers ⚠️\nTestserver "${{ matrix.environment }}" is already in use by PR #${issues[0].number}.` }) core.setFailed(`Testserver "${{ matrix.environment }}" is already in use by PR #${issues[0].number}.`); } else if (issues.length > 1) { @@ -301,16 +302,9 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '### ❌ Unable to deploy to test servers ❌\nTestserver "${{ matrix.environment }}" is already in use by multiple PRs. Check PRs with label "lock:${{ matrix.label-identifier }}"!' + body: '#### ⚠️ Unable to deploy to test servers ⚠️\nTestserver "${{ matrix.environment }}" is already in use by multiple PRs. Check PRs with label "lock:${{ matrix.label-identifier }}"!' }) core.setFailed('Testserver "${{ matrix.environment }}" is already in use by multiple PRs. Check PRs with label "lock:${{ matrix.label-identifier }}"!'); - } else if (context.issue && context.issue.number) { - await github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ['lock:${{ matrix.label-identifier }}'] - }) } - name: Compute Tag @@ -362,13 +356,34 @@ jobs: run: | for host in $DEPLOYMENT_HOSTS; do ./artemis-server-cli docker-deploy "$DEPLOYMENT_USER@$host" -g "$GATEWAY_USER@$GATEWAY_HOST" -t $TAG -b $GITHUB_HEAD_REF -d $DEPLOYMENT_FOLDER -y - sleep 20 done + - name: Add "lock:${{ matrix.environment }}" label + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + if (context.issue && context.issue.number) { + await github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['lock:${{ matrix.label-identifier }}'] + }) + } + + - name: Update badge + uses: RubbaBoy/BYOB@v1.3.0 + with: + NAME: ${{ matrix.label-identifier }} + LABEL: ${{ matrix.environment }} + STATUS: ${{ github.event.pull_request.head.ref }} + COLOR: red + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Check that the build job has run successfully before deploying add-error-label: - needs: [ check-build-status, filter-matrix, process-matrix, deploy ] + needs: [ get-labels, check-build-status, filter-matrix, process-matrix, deploy ] runs-on: ubuntu-latest if: ${{ failure() }} steps: diff --git a/README.md b/README.md index 69f118e11e07..df42a8d889cf 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![Build & Deploy](https://github.com/ls1intum/Artemis/actions/workflows/build.yml/badge.svg?event=push)](https://github.com/ls1intum/Artemis/actions/workflows/build.yml) [![Test](https://github.com/ls1intum/Artemis/actions/workflows/test.yml/badge.svg?event=push)](https://github.com/ls1intum/Artemis/actions/workflows/test.yml) -[![Documentation](https://github.com/ls1intum/Artemis/actions/workflows/docs.yml/badge.svg?event=push)](https://ls1intum.github.io/Artemis) +[![Documentation](https://github.com/ls1intum/Artemis/actions/workflows/docs.yml/badge.svg?event=push)](https://docs.artemis.cit.tum.de) [![Code Quality Status](https://app.codacy.com/project/badge/Grade/89860aea5fa74d998ec884f1a875ed0c)](https://www.codacy.com/gh/ls1intum/Artemis?utm_source=github.com&utm_medium=referral&utm_content=ls1intum/Artemis&utm_campaign=Badge_Grade) [![Coverage Status](https://app.codacy.com/project/badge/Coverage/89860aea5fa74d998ec884f1a875ed0c)](https://www.codacy.com/gh/ls1intum/Artemis?utm_source=github.com&utm_medium=referral&utm_content=ls1intum/Artemis&utm_campaign=Badge_Coverage) [![Latest version)](https://img.shields.io/github/v/tag/ls1intum/Artemis?label=%20Latest%20version&sort=semver)](https://github.com/ls1intum/Artemis/releases/latest) ## Main features -1. **[Programming exercises](https://ls1intum.github.io/Artemis/user/exercises/programming/)** with version control, automatic individual feedback (and assessment) based on test cases and static code analysis (executed using continuous integration). +1. **[Programming exercises](https://docs.artemis.cit.tum.de/user/exercises/programming/)** with version control, automatic individual feedback (and assessment) based on test cases and static code analysis (executed using continuous integration). * **Instant**: Students receive immediate and individual feedback on submissions. Instructors can customize feedback messages easily, hide feedback during the working time (e.g., with hidden tests) * **Interactive:** Instructors integrate interactive instructions based on tasks and UML diagrams directly into the dynamic problem statements. They can define hints for difficult exercise parts. * **Independent**: Instructors can customize programming exercises to support any programming language. To simplify the setup, Artemis includes sophisticated templates for the most common languages (e.g., Java, Python, C, Haskell, Kotlin, VHDL, Assembler, Swift, Ocaml, ...) @@ -18,46 +18,46 @@ * **Policies**: Instructors can define submission policies (e.g., penalties after 10 attempts) to prevent that students try out all possibilities without thinking. * **Grading**: Instructors have many options to configure grading, analyze the results based on tests and static code analysis categories and re-evaluate the results * **Secure**: Test cases and student code run in Docker environments on build agents. Test frameworks such as [Ares](https://github.com/ls1intum/Ares) simplify the creation of structural and dynamic tests and prevent that students can cheat. -2. **[Quiz exercises](https://ls1intum.github.io/Artemis/user/exercises/quiz/)** with multiple choice, drag and drop, and short answer questions +2. **[Quiz exercises](https://docs.artemis.cit.tum.de/user/exercises/quiz/)** with multiple choice, drag and drop, and short answer questions * **Modeling quizzes**: Instructors can easily create drag and drop quizzes based on UML models * **Different modes**: Quizzes support a live mode (rated) during lectures, a practice mode for students to repeat the quiz as often as they want, and an exam mode (see below) -3. **[Modeling exercises](https://ls1intum.github.io/Artemis/user/exercises/modeling/)** based on the easy-to-use online modeling editor [Apollon](https://apollon.ase.in.tum.de) with semi-automatic assessment using machine learning concepts +3. **[Modeling exercises](https://docs.artemis.cit.tum.de/user/exercises/modeling/)** based on the easy-to-use online modeling editor [Apollon](https://apollon.ase.in.tum.de) with semi-automatic assessment using machine learning concepts * **Multiple diagram types**: Artemis supports 7 UML diagram types (e.g. class diagrams) and 4 additional diagrams (e.g. flow charts) * **Easy to use**: Students can create models easily using drag and drop, they can explain the models using additional text. * **Integrated feedback**: Reviews can provide feedback and points directly next to the model elements. -4. **[Text exercises](https://ls1intum.github.io/Artemis/user/exercises/textual/)** with manual, semi-automatic assessment based on supervised machine learning and natural language processing (NLP) using [Athena](https://github.com/ls1intum/Athena) +4. **[Text exercises](https://docs.artemis.cit.tum.de/user/exercises/textual/)** with manual, semi-automatic assessment based on supervised machine learning and natural language processing (NLP) using [Athena](https://github.com/ls1intum/Athena) * **Integrated feedback**: Reviews can provide feedback and points directly next to the text segments. * **Language detection**: Artemis detects the language of the submission and shows the word and character count. -5. **[File upload exercises](https://ls1intum.github.io/Artemis/user/exercises/file-upload/)** with manual assessment -6. **[Exam mode](https://ls1intum.github.io/Artemis/user/exam_mode/)**: Instructors can create online exams with exercise variants, integrated plagiarism checks, test runs and student reviews. You can find more information on [Exam mode student features](https://artemis.cit.tum.de/features/students) and on [Exam mode instructor features](https://artemis.cit.tum.de/features/instructors). -7. **[Grading](https://ls1intum.github.io/Artemis/user/grading/)**: Instructors can configure grade keys for courses and exams to automatically calculate grades and display them to students. Grades can be easily exported as csv files to upload them into university systems (such as Campus online). Instructors can optionally define bonus configurations for final exams to improve student grades according to their grades from a midterm exam or course exercises. -8. **[Assessment](https://ls1intum.github.io/Artemis/user/exercises/assessment/)**: Artemis uses double-blind grading and structured grading criteria to improve consistency and fairness. It integrates an assessment training process (based on example submissions and example assessments defined by the instructor), has a grading leader board, and allows students to rate the assessments. Students can complain or ask for more feedback. -9. **[Communication](https://ls1intum.github.io/Artemis/user/communication/)**: Instructors can post announcements. Students can ask questions, post comments, and react to other posts. Tutors can filter unanswered questions. -10. **[Notifications](https://ls1intum.github.io/Artemis/user/notifications)**: Artemis supports customizable web and email notifications. Users can enable and disable different notification types. -11. **[Team Exercises](https://ls1intum.github.io/Artemis/user/exercises/team-exercises/)**: Instructors can configure team exercises with real time collaboration and dedicated tutors per team. -12. **[Lectures](https://ls1intum.github.io/Artemis/user/lectures/)**: Instructors can upload lecture slides, divide lectures into units, integrate video streams, lecture recordings, and exercises into lectures, and define competencies. -13. **[Integrated Markdown Editor](https://ls1intum.github.io/Artemis/user/markdown-support/)**: Markdown is used to format text content across the platform using an integrated markdown editor. -14. **[Plagiarism checks](https://ls1intum.github.io/Artemis/user/plagiarism-check/)**: Artemis integrates plagiarism checks for programming exercises (based on [JPlag](https://github.com/jplag/JPlag)), text exercises, and modeling exercises in courses and exams. It allows notifying students about identified plagiarism. Students can review and comment on the allegation. -15. **[Learning analytics](https://ls1intum.github.io/Artemis/user/learning-analytics/)**: Artemis integrated different statistics for students to compare themselves to the course average. It allows instructors to evaluate the average student performance based on exercises and competencies. -16. **[Adaptive Learning](https://ls1intum.github.io/Artemis/user/adaptive-learning/)**: Artemis allows instructors and students to define and track competencies. Students can monitor their progress towards these goals, while instructors can provide tailored feedback. This approach integrates lectures and exercises under overarching learning objectives. -17. **[Tutorial Groups](https://ls1intum.github.io/Artemis/user/tutorialgroups/)**: Artemis support the management of tutorial groups of a course. This includes planning the sessions, assigning responsible tutors, registering students and tracking the attendance. +5. **[File upload exercises](https://docs.artemis.cit.tum.de/user/exercises/file-upload/)** with manual assessment +6. **[Exam mode](https://docs.artemis.cit.tum.de/user/exam_mode/)**: Instructors can create online exams with exercise variants, integrated plagiarism checks, test runs and student reviews. You can find more information on [Exam mode student features](https://artemis.cit.tum.de/features/students) and on [Exam mode instructor features](https://artemis.cit.tum.de/features/instructors). +7. **[Grading](https://docs.artemis.cit.tum.de/user/grading/)**: Instructors can configure grade keys for courses and exams to automatically calculate grades and display them to students. Grades can be easily exported as csv files to upload them into university systems (such as Campus online). Instructors can optionally define bonus configurations for final exams to improve student grades according to their grades from a midterm exam or course exercises. +8. **[Assessment](https://docs.artemis.cit.tum.de/user/exercises/assessment/)**: Artemis uses double-blind grading and structured grading criteria to improve consistency and fairness. It integrates an assessment training process (based on example submissions and example assessments defined by the instructor), has a grading leader board, and allows students to rate the assessments. Students can complain or ask for more feedback. +9. **[Communication](https://docs.artemis.cit.tum.de/user/communication/)**: Instructors can post announcements. Students can ask questions, post comments, and react to other posts. Tutors can filter unanswered questions. +10. **[Notifications](https://docs.artemis.cit.tum.de/user/notifications)**: Artemis supports customizable web and email notifications. Users can enable and disable different notification types. +11. **[Team Exercises](https://docs.artemis.cit.tum.de/user/exercises/team-exercises/)**: Instructors can configure team exercises with real time collaboration and dedicated tutors per team. +12. **[Lectures](https://docs.artemis.cit.tum.de/user/lectures/)**: Instructors can upload lecture slides, divide lectures into units, integrate video streams, lecture recordings, and exercises into lectures, and define competencies. +13. **[Integrated Markdown Editor](https://docs.artemis.cit.tum.de/user/markdown-support/)**: Markdown is used to format text content across the platform using an integrated markdown editor. +14. **[Plagiarism checks](https://docs.artemis.cit.tum.de/user/plagiarism-check/)**: Artemis integrates plagiarism checks for programming exercises (based on [JPlag](https://github.com/jplag/JPlag)), text exercises, and modeling exercises in courses and exams. It allows notifying students about identified plagiarism. Students can review and comment on the allegation. +15. **[Learning analytics](https://docs.artemis.cit.tum.de/user/learning-analytics/)**: Artemis integrated different statistics for students to compare themselves to the course average. It allows instructors to evaluate the average student performance based on exercises and competencies. +16. **[Adaptive Learning](https://docs.artemis.cit.tum.de/user/adaptive-learning/)**: Artemis allows instructors and students to define and track competencies. Students can monitor their progress towards these goals, while instructors can provide tailored feedback. This approach integrates lectures and exercises under overarching learning objectives. +17. **[Tutorial Groups](https://docs.artemis.cit.tum.de/user/tutorialgroups/)**: Artemis support the management of tutorial groups of a course. This includes planning the sessions, assigning responsible tutors, registering students and tracking the attendance. 18. **[Iris](https://artemis.cit.tum.de/about-iris)**: Artemis integrates Iris, a chatbot that supports students and instructors with common questions and tasks. -19. **[Scalable](https://ls1intum.github.io/Artemis/user/scaling/)**: Artemis scales to multiple courses with thousands of students. In fact, the largest course had 2,400 students. Administrators can easily scale Artemis with additional build agents in the continuous integration environment. -20. **[High user satisfaction](https://ls1intum.github.io/Artemis/user/user-experience/)**: Artemis is easy to use, provides guided tutorials. Developers focus on usability, user experience, and performance. +19. **[Scalable](https://docs.artemis.cit.tum.de/user/scaling/)**: Artemis scales to multiple courses with thousands of students. In fact, the largest course had 2,400 students. Administrators can easily scale Artemis with additional build agents in the continuous integration environment. +20. **[High user satisfaction](https://docs.artemis.cit.tum.de/user/user-experience/)**: Artemis is easy to use, provides guided tutorials. Developers focus on usability, user experience, and performance. 21. **Customizable**: It supports multiple instructors, editors, and tutors per course and allows instructors to customize many course settings -22. **[Open-source](https://ls1intum.github.io/Artemis/dev/open-source/)**: Free to use with a large community and many active maintainers. +22. **[Open-source](https://docs.artemis.cit.tum.de/dev/open-source/)**: Free to use with a large community and many active maintainers. ## Roadmap The Artemis development team prioritizes the following issues in the future. We welcome feature requests from students, tutors, instructors, and administrators. We are happy to discuss any suggestions for improvements. -* **Short term**: Further enhance the usability and user experience in different places based on best practices -* **Short term**: Further improve discussions and provide communication channels -* **Short term**: Improve the integration of learning analytics for students and instructors -* **Short term**: Simplify the setup of Artemis -* **Medium term**: Improve semi-automatic assessment by reusing knowledge from previous exercises and by offering it for the manual assessment of programming exercises -* **Medium term**: Integrate adaptive learning with different exercise difficulties and the automatic generation of hints -* **Medium term**: Further improve the security of the involved systems (e.g. when executing code of programming exercises) +* **Short term**: Further improve the communication features +* **Short term**: Add learning paths based on adaptive learning with different exercise difficulties and the automatic generation of hints +* **Short term**: Add more learning analytics features while preserving data privacy +* **Short term**: Add instructor assistance based on Generative AI +* **Medium term**: Simplify the setup of Artemis +* **Medium term**: Add feedback assistance based on Generative AI +* **Medium term**: Add the possibility to use Iris for questions on all exercise types, lectures, and larning performance aspects * **Long term**: Microservices, Kubernetes based deployment, and micro frontends * **Long term**: Allow students to take notes on lecture slides and support the automatic updates of lecture slides * **Long term**: Develop an exchange platform for exercises @@ -67,38 +67,36 @@ The Artemis development team prioritizes the following issues in the future. We ### Development setup, coding, and design guidelines -* [How to set up your local development environment](https://ls1intum.github.io/Artemis/dev/setup/) -* [Server coding and design guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/server/) -* [Client coding and design guidelines](https://ls1intum.github.io/Artemis/dev/guidelines/client/) -* [Code Review Guidelines](https://ls1intum.github.io/Artemis/dev/development-process/#review) +* [How to set up your local development environment](https://docs.artemis.cit.tum.de/dev/setup/) +* [Server coding and design guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/server/) +* [Client coding and design guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/client/) +* [Code Review Guidelines](https://docs.artemis.cit.tum.de/dev/development-process/#review) ### Documentation -The Artemis documentation is available [here](https://ls1intum.github.io/Artemis/). +The Artemis documentation is available [here](https://docs.artemis.cit.tum.de/). You can find a guide on [how to write documentation](docs/README.md). ### Server setup -You can set up Artemis in conjunction with either [`GitLab and Jenkins`](https://ls1intum.github.io/Artemis/dev/setup/#jenkins-and-gitlab-setup), [`GitLab and GitLab CI (experimental)`](https://ls1intum.github.io/Artemis/dev/setup/#gitlab-ci-and-gitlab-setup), [`Jira, Bitbucket and Bamboo`](https://ls1intum.github.io/Artemis/dev/setup/#bamboo-bitbucket-and-jira-setup), or with [`local CI and local VC`](https://ls1intum.github.io/Artemis/dev/setup/#local-ci-and-local-vc-setup). +You can set up Artemis in conjunction with either [`GitLab and Jenkins`](https://docs.artemis.cit.tum.de/dev/setup/#jenkins-and-gitlab-setup), [`GitLab and GitLab CI (experimental)`](https://docs.artemis.cit.tum.de/dev/setup/#gitlab-ci-and-gitlab-setup), [`Jira, Bitbucket and Bamboo`](https://docs.artemis.cit.tum.de/dev/setup/#bamboo-bitbucket-and-jira-setup), or with [`local CI and local VC`](https://docs.artemis.cit.tum.de/dev/setup/#local-ci-and-local-vc-setup). Artemis uses these external tools for user management and the configuration of programming exercises. ### Administration setup -If needed, you can configure self service [user registration](https://ls1intum.github.io/Artemis/admin/registration). +If needed, you can configure self service [user registration](https://docs.artemis.cit.tum.de/admin/registration). ### Contributing Please read the guide on [how to contribute](CONTRIBUTING.md) to Artemis. -Once the PR is ready to merge, notifiy the responsible feature maintainer: +Once the PR is ready to merge, notify the responsible feature maintainer: -
- Feature Maintainers +#### Maintainers -| Feature | Maintainer | -| ------------------------------ | ----------------------------------------------------------------------------------------------- | -| Programming exercises | [@mtze](https://github.com/mtze) | -| Ares | [@MarkusPaulsen](https://github.com/MarkusPaulsen) | +| Feature / Aspect | Maintainer | +|--------------------------------| ----------------------------------------------------------------------------------------------- | +| Programming exercises | [@krusche](https://github.com/krusche) | | Quiz exercises | [@Santia-go](https://github.com/Santia-go) | | Modeling exercises (+ Apollon) | [@krusche](https://github.com/krusche) | | Text exercises | [@maximiliansoelch](https://github.com/maximiliansoelch) | @@ -106,19 +104,21 @@ Once the PR is ready to merge, notifiy the responsible feature maintainer: | Exam mode | [@krusche](https://github.com/krusche) | | Grading | [@maximiliansoelch](https://github.com/maximiliansoelch) | | Assessment | [@maximiliansoelch](https://github.com/maximiliansoelch) | -| Communication | [@bassner](https://github.com/bassner) | -| Notifications | [@mtze](https://github.com/mtze) [@bassner](https://github.com/bassner) | +| Communication | [@krusche](https://github.com/krusche) | +| Notifications | [@krusche](https://github.com/krusche) | | Team Exercises | [@krusche](https://github.com/krusche) | | Lectures | [@maximiliansoelch](https://github.com/maximiliansoelch) [@bassner](https://github.com/bassner) | | Integrated Markdown Editor | [@maximiliansoelch](https://github.com/maximiliansoelch) [@bassner](https://github.com/bassner) | | Plagiarism checks | [@MarkusPaulsen](https://github.com/MarkusPaulsen) | | Learning analytics | [@bassner](https://github.com/bassner) | +| Adaptive learning | [@bassner](https://github.com/bassner) | | Tutorial Groups | [@Santia-go](https://github.com/Santia-go) | +| Iris | [@bassner](https://github.com/bassner) | +| Scalability | [@mtze](https://github.com/mtze) | | Usability + Performance | [@bassner](https://github.com/bassner) | | Infrastructure | [@mtze](https://github.com/mtze) | | Mobile apps (iOS + Android) | [@krusche](https://github.com/krusche) [@maximiliansoelch](https://github.com/maximiliansoelch) | - -
+ ### Building for production @@ -134,7 +134,7 @@ This will create a Artemis-.war file in the folder `build/libs`. The bu java -jar build/libs/*.war --spring.profiles.active=dev,artemis,bamboo,bitbucket,jira ``` -(You might need to copy a yml file into the folder build/libs before, also see [development setup](https://ls1intum.github.io/Artemis/dev/setup/)) +(You might need to copy a yml file into the folder build/libs before, also see [development setup](https://docs.artemis.cit.tum.de/dev/setup/)) Then navigate to [http://localhost:8080](http://localhost:8080) in your browser. @@ -160,7 +160,7 @@ While Artemis includes generic adapters to these three external systems with a d ### Server architecture -The following UML component diagram shows more details of the Artemis application server architecture and its REST interfaces to the application client. +The following simplified UML component diagram exemplary shows more details of the Artemis application server architecture and its REST interfaces to the application client. ![Server Architecture](docs/dev/system-design/ServerArchitecture.png "Server Architecture") @@ -174,7 +174,7 @@ The Continuous Integration Server typically delegates the build jobs to local bu ### Data model -The Artemis application server uses the following (simplified) data model in the MySQL database. It supports multiple courses with multiple exercises. Each student in the participating student group can participate in the exercise by clicking the **Start Exercise** button. +The Artemis application server uses the following (simplified) data model in the MySQL database (notice that the actual data model is more complex by now). It supports multiple courses with multiple exercises. Each student in the participating student group can participate in the exercise by clicking the **Start Exercise** button. Then a repository and a build plan for the student (User) will be created and configured. The initialization state helps to track the progress of this complex operation and allows recovering from errors. A student can submit multiple solutions by committing and pushing the source code changes to a given example code into the version control system or using the user interface. The continuous integration server automatically tests each submission and notifies the Artemis application server when a new result exists. In addition, teaching assistants can assess student solutions and "manually" create results. diff --git a/build.gradle b/build.gradle index 799346b9ab13..913dafcd158d 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ plugins { } group = "de.tum.in.www1.artemis" -version = "6.6.1" +version = "6.6.2" description = "Interactive Learning with Individual Feedback" sourceCompatibility=17 diff --git a/docker/artemis.yml b/docker/artemis.yml index 19cfb0e0bf12..624874392aba 100644 --- a/docker/artemis.yml +++ b/docker/artemis.yml @@ -6,7 +6,7 @@ services: artemis-app: container_name: artemis-app # look in the docs for more information about how to build your Artemis code with Docker - # https://ls1intum.github.io/Artemis/dev/setup/# + # https://docs.artemis.cit.tum.de/dev/setup/# image: ghcr.io/ls1intum/artemis build: context: .. diff --git a/docker/artemis/config/dev.env b/docker/artemis/config/dev.env index 4dc4617261bd..cc9259a8a670 100644 --- a/docker/artemis/config/dev.env +++ b/docker/artemis/config/dev.env @@ -7,5 +7,5 @@ SPRING_PROFILES_ACTIVE: artemis,scheduling,athena,dev,docker # The following enables the Java Remote Debugging port. More infos in the documentation: -# https://ls1intum.github.io/Artemis/dev/setup.html#debugging-with-docker +# https://docs.artemis.cit.tum.de/dev/setup.html#debugging-with-docker _JAVA_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 diff --git a/docker/artemis/config/postgres.env b/docker/artemis/config/postgres.env index 2c8a07e95f37..ffde45960540 100644 --- a/docker/artemis/config/postgres.env +++ b/docker/artemis/config/postgres.env @@ -3,7 +3,7 @@ # ---------------------------------------------------------------------------------------------------------------------- SPRING_DATASOURCE_URL="jdbc:postgresql://artemis-postgres:5432/Artemis?sslmode=disable" -SPRING_DATASOURCE_USERNAME="postgres" +SPRING_DATASOURCE_USERNAME="Artemis" SPRING_JPA_DATABASE_PLATFORM="org.hibernate.dialect.PostgreSQL10Dialect" SPRING_JPA_DATABASE="POSTGRESQL" diff --git a/docker/postgres.yml b/docker/postgres.yml index 9feba0d54638..02414d73fa8e 100644 --- a/docker/postgres.yml +++ b/docker/postgres.yml @@ -20,7 +20,7 @@ services: expose: - "5432" healthcheck: - test: pg_isready -U postgres -d Artemis + test: pg_isready -U Artemis -d Artemis interval: 5s timeout: 3s retries: 30 diff --git a/docker/postgres/default.env b/docker/postgres/default.env index d92a5b5f2722..e3e272a815fe 100644 --- a/docker/postgres/default.env +++ b/docker/postgres/default.env @@ -1,4 +1,4 @@ POSTGRES_HOST_AUTH_METHOD=trust -POSTGRES_USER=postgres +POSTGRES_USER=Artemis POSTGRES_DB=Artemis PGDATA=/var/lib/postgresql/data/pgdata diff --git a/docker/test-server-mysql-localci.yml b/docker/test-server-mysql-localci.yml new file mode 100644 index 000000000000..f98b5dcc7a2f --- /dev/null +++ b/docker/test-server-mysql-localci.yml @@ -0,0 +1,58 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Setup for a test server with MySQL & LocalCI +# ---------------------------------------------------------------------------------------------------------------------- +# It is designed to take in a lot of environment variables to take in all the configuration of the test server. +# ---------------------------------------------------------------------------------------------------------------------- + +services: + artemis-app: + extends: + file: ./artemis.yml + service: artemis-app + image: ghcr.io/ls1intum/artemis:${ARTEMIS_DOCKER_TAG:-latest} + depends_on: + mysql: + condition: service_healthy + pull_policy: always + restart: always + group_add: + - ${DOCKER_GROUP_ID:-0} + env_file: + - ${ARTEMIS_ENV_FILE:-./artemis/config/prod.env} + volumes: + - ${ARTEMIS_VOLUME_MOUNT:-./.docker-data/artemis-data}:/opt/artemis/data + - ${ARTEMIS_LEGAL_MOUNT:-./.docker-data/artemis-legal}:/opt/artemis/legal + - ${ARTEMIS_DATA_EXPORT_MOUNT:-./.docker-data/artemis-data-exports}:/opt/artemis/data-exports + - /var/run/docker.sock:/var/run/docker.sock + + mysql: + extends: + file: ./mysql.yml + service: mysql + restart: always + user: "1337:1337" + env_file: + - ${DATABASE_ENV_FILE:-./mysql/default.env} + volumes: + - ${DATABASE_VOLUME_MOUNT:-./.docker-data/artemis-mysql-data}:/var/lib/mysql + + nginx: + extends: + file: ./nginx.yml + service: nginx + depends_on: + artemis-app: + condition: service_started + restart: always + volumes: + - type: bind + source: ${NGINX_PROXY_SSL_CERTIFICATE_PATH:-../src/test/cypress/certs/artemis-nginx+4.pem} + target: "/certs/fullchain.pem" + - type: bind + source: ${NGINX_PROXY_SSL_CERTIFICATE_KEY_PATH:-../src/test/cypress/certs/artemis-nginx+4-key.pem} + target: "/certs/priv_key.pem" + +networks: + artemis: + driver: "bridge" + name: artemis diff --git a/docker/test-server-postgresql-localci.yml b/docker/test-server-postgresql-localci.yml new file mode 100644 index 000000000000..719c4c9d7889 --- /dev/null +++ b/docker/test-server-postgresql-localci.yml @@ -0,0 +1,57 @@ +# ---------------------------------------------------------------------------------------------------------------------- +# Setup for a test server with Postgres & LocalCI +# ---------------------------------------------------------------------------------------------------------------------- +# It is designed to take in a lot of environment variables to take in all the configuration of the test server. +# ---------------------------------------------------------------------------------------------------------------------- + +services: + artemis-app: + extends: + file: ./artemis.yml + service: artemis-app + image: ghcr.io/ls1intum/artemis:${ARTEMIS_DOCKER_TAG:-latest} + depends_on: + postgres: + condition: service_healthy + pull_policy: always + restart: always + group_add: + - ${DOCKER_GROUP_ID:-0} + env_file: + - ${ARTEMIS_ENV_FILE:-./artemis/config/prod.env} + volumes: + - ${ARTEMIS_VOLUME_MOUNT:-./.docker-data/artemis-data}:/opt/artemis/data + - ${ARTEMIS_LEGAL_MOUNT:-./.docker-data/artemis-legal}:/opt/artemis/legal + - ${ARTEMIS_DATA_EXPORT_MOUNT:-./.docker-data/artemis-data-exports}:/opt/artemis/data-exports + - /var/run/docker.sock:/var/run/docker.sock + + postgres: + extends: + file: ./postgres.yml + service: postgres + restart: always + env_file: + - ${DATABASE_ENV_FILE:-./postgres/default.env} + volumes: + - ${DATABASE_VOLUME_MOUNT:-./.docker-data/artemis-postgres-data}:/var/lib/postgresql/data + + nginx: + extends: + file: ./nginx.yml + service: nginx + depends_on: + artemis-app: + condition: service_started + restart: always + volumes: + - type: bind + source: ${NGINX_PROXY_SSL_CERTIFICATE_PATH:-../src/test/cypress/certs/artemis-nginx+4.pem} + target: "/certs/fullchain.pem" + - type: bind + source: ${NGINX_PROXY_SSL_CERTIFICATE_KEY_PATH:-../src/test/cypress/certs/artemis-nginx+4-key.pem} + target: "/certs/priv_key.pem" + +networks: + artemis: + driver: "bridge" + name: artemis diff --git a/docs/dev/setup/kubernetes.rst b/docs/dev/setup/kubernetes.rst index 373b5f899847..08acd693e1ba 100644 --- a/docs/dev/setup/kubernetes.rst +++ b/docs/dev/setup/kubernetes.rst @@ -288,7 +288,7 @@ If you want to run with local user management and no programming exercises setup 1. Go to the ``src/main/resources/config/application-artemis.yml`` file, and set use-external in the user-management section to false. If you have created an additional ``application-local.yml`` file as it is described in the -`Setup documentation `__, make sure to edit this one. +`Setup documentation `__, make sure to edit this one. Another possibility is to add the variable directly in ``src/main/kubernetes/artemis/configmap/artemis-configmap.yml``. diff --git a/package-lock.json b/package-lock.json index 5f4daa212945..9682c900837e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2646,9 +2646,9 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz", - "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/generator": "^7.23.0", diff --git a/package.json b/package.json index a33e13b27ae0..b3aea9d3f91a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "6.6.1", + "version": "6.6.2", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", diff --git a/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java b/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java index a687b0642094..1e2dfde09891 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/ConversationWebSocketRecipientSummary.java @@ -4,9 +4,10 @@ * Stores the user of a conversation participant, who is supposed to receive a websocket message and stores whether * the corresponding conversation is hidden by the user. * - * @param user the user who is a member of the conversation + * @param userId the id of the user who is a member of the conversation + * @param userLogin the login of the user who is a member of the conversation * @param isConversationHidden true if the user has hidden the conversation * @param isAtLeastTutorInCourse true if the user is at least a tutor in the course */ -public record ConversationWebSocketRecipientSummary(User user, boolean isConversationHidden, boolean isAtLeastTutorInCourse) { +public record ConversationWebSocketRecipientSummary(Long userId, String userLogin, boolean isConversationHidden, boolean isAtLeastTutorInCourse) { } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/User.java b/src/main/java/de/tum/in/www1/artemis/domain/User.java index ac4c2b8af51e..a9f1617370d0 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/User.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/User.java @@ -185,6 +185,14 @@ public class User extends AbstractAuditingEntity implements Participant { @Column(name = "iris_accepted") private ZonedDateTime irisAccepted = null; + public User() { + } + + public User(Long id, String login) { + this.setId(id); + this.login = login; + } + public String getLogin() { return login; } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/TextAssessmentEventType.java b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/TextAssessmentEventType.java index 0ea89bf797e1..8434d51e1ebb 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/TextAssessmentEventType.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/TextAssessmentEventType.java @@ -5,7 +5,7 @@ * Enumerates the names of events that are tracked when users interact with the assessment * system in text exercises * More detailed info in the documentation: - * https://ls1intum.github.io/Artemis/dev/setup/#configure-text-assessment-analytics-service + * https://docs.artemis.cit.tum.de/dev/setup/#configure-text-assessment-analytics-service */ public enum TextAssessmentEventType { ADD_FEEDBACK_AUTOMATICALLY_SELECTED_BLOCK, ADD_FEEDBACK_MANUALLY_SELECTED_BLOCK, DELETE_FEEDBACK, EDIT_AUTOMATIC_FEEDBACK, SUBMIT_ASSESSMENT, ASSESS_NEXT_SUBMISSION diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java new file mode 100644 index 000000000000..70ee3de742a5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizGroup.java @@ -0,0 +1,32 @@ +package de.tum.in.www1.artemis.domain.quiz; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.DomainObject; + +@Entity +@Table(name = "quiz_group") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class QuizGroup extends DomainObject { + + @Column(name = "name") + private String name; + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @JsonIgnore + public boolean isValid() { + return getName() != null && !getName().isEmpty(); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java new file mode 100644 index 000000000000..e3e088eda513 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizPool.java @@ -0,0 +1,119 @@ +package de.tum.in.www1.artemis.domain.quiz; + +import java.util.ArrayList; +import java.util.List; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.JoinColumn; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonView; + +import de.tum.in.www1.artemis.domain.DomainObject; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.view.QuizView; + +@Entity +@Table(name = "quiz_pool") +@JsonInclude +public class QuizPool extends DomainObject implements QuizConfiguration { + + @OneToOne + @JoinColumn(name = "exam_id", referencedColumnName = "id") + private Exam exam; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "quiz_pool_id", referencedColumnName = "id") + private List quizQuestions; + + @Column(name = "max_points") + private int maxPoints; + + @Column(name = "randomize_question_order") + @JsonView(QuizView.Before.class) + private Boolean randomizeQuestionOrder = false; + + @Transient + private List quizGroups; + + public QuizPool() { + this.quizGroups = new ArrayList<>(); + this.quizQuestions = new ArrayList<>(); + } + + public void setExam(Exam exam) { + this.exam = exam; + } + + public Exam getExam() { + return exam; + } + + public int getMaxPoints() { + return maxPoints; + } + + public void setMaxPoints(int maxPoints) { + this.maxPoints = maxPoints; + } + + public Boolean getRandomizeQuestionOrder() { + return randomizeQuestionOrder; + } + + public void setRandomizeQuestionOrder(Boolean randomizeQuestionOrder) { + this.randomizeQuestionOrder = randomizeQuestionOrder; + } + + @Override + public void setQuestionParent(QuizQuestion quizQuestion) { + // Do nothing since the relationship between QuizPool and QuizQuestion is defined in QuizPool. + } + + @Override + public List getQuizQuestions() { + return this.quizQuestions; + } + + public void setQuizQuestions(List quizQuestions) { + this.quizQuestions = quizQuestions; + } + + @JsonProperty(value = "quizGroups", access = JsonProperty.Access.READ_ONLY) + public List getQuizGroups() { + return quizGroups; + } + + @JsonProperty(value = "quizGroups", access = JsonProperty.Access.WRITE_ONLY) + public void setQuizGroups(List quizGroups) { + this.quizGroups = quizGroups; + } + + /** + * Check if all quiz groups and questions are valid + * + * @return true if all quiz groups and questions are valid + */ + @JsonIgnore + public boolean isValid() { + for (QuizGroup quizGroup : getQuizGroups()) { + if (!quizGroup.isValid()) { + return false; + } + } + for (QuizQuestion quizQuestion : getQuizQuestions()) { + if (!quizQuestion.isValid()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java index 4760dc813d2a..2ba00ad2e9c1 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizQuestion.java @@ -65,6 +65,10 @@ public abstract class QuizQuestion extends DomainObject { @JsonView(QuizView.Before.class) private Boolean invalid = false; + @Column(name = "quiz_group_id") + @JsonView(QuizView.Before.class) + private Long quizGroupId; + @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) @JoinColumn(unique = true) private QuizQuestionStatistic quizQuestionStatistic; @@ -73,6 +77,9 @@ public abstract class QuizQuestion extends DomainObject { @JsonIgnore private QuizExercise exercise; + @Transient + private QuizGroup quizGroup; + public String getTitle() { return title; } @@ -168,6 +175,24 @@ public void setExercise(QuizExercise quizExercise) { this.exercise = quizExercise; } + public Long getQuizGroupId() { + return quizGroupId; + } + + public void setQuizGroupId(Long quizGroupId) { + this.quizGroupId = quizGroupId; + } + + @JsonProperty(value = "quizGroup", access = JsonProperty.Access.READ_ONLY) + public QuizGroup getQuizGroup() { + return quizGroup; + } + + @JsonProperty(value = "quizGroup", access = JsonProperty.Access.WRITE_ONLY) + public void setQuizGroup(QuizGroup quizGroup) { + this.quizGroup = quizGroup; + } + /** * Calculate the score for the given answer * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java new file mode 100644 index 000000000000..2730d938084e --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/QuizGroupRepository.java @@ -0,0 +1,14 @@ +package de.tum.in.www1.artemis.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; + +/** + * Spring Data JPA repository for the QuizGroup entity. + */ +@SuppressWarnings("unused") +@Repository +public interface QuizGroupRepository extends JpaRepository { +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java new file mode 100644 index 000000000000..4da6d6f3653d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/QuizPoolRepository.java @@ -0,0 +1,27 @@ +package de.tum.in.www1.artemis.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import de.tum.in.www1.artemis.domain.quiz.QuizPool; + +/** + * Spring Data JPA repository for the QuizPool entity. + */ +@SuppressWarnings("unused") +@Repository +public interface QuizPoolRepository extends JpaRepository { + + @Query(""" + SELECT qe + FROM QuizPool qe + JOIN qe.exam e + LEFT JOIN FETCH qe.quizQuestions qeq + LEFT JOIN FETCH qeq.quizQuestionStatistic + WHERE e.id = :examId + """) + Optional findWithEagerQuizQuestionsByExamId(Long examId); +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index 4cdf3ef5505d..84adb42192a9 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -123,22 +123,19 @@ OR lower(user.login) = lower(:#{#searchInput})) @Query(""" SELECT NEW de.tum.in.www1.artemis.domain.ConversationWebSocketRecipientSummary ( - user, + user.id, + user.login, CASE WHEN cp.isHidden = true THEN true ELSE false END, - CASE WHEN atLeastTutors.id IS NOT null THEN true ELSE false END + CASE WHEN ug.group = :teachingAssistantGroupName OR ug.group = :editorGroupName OR ug.group = :instructorGroupName THEN true ELSE false END ) FROM User user JOIN UserGroup ug ON ug.userId = user.id - LEFT JOIN Course students ON ug.group = students.studentGroupName - LEFT JOIN Course atLeastTutors ON (atLeastTutors.teachingAssistantGroupName = ug.group - OR atLeastTutors.editorGroupName = ug.group - OR atLeastTutors.instructorGroupName = ug.group - ) LEFT JOIN ConversationParticipant cp ON cp.user.id = user.id AND cp.conversation.id = :conversationId - WHERE user.isDeleted = false - AND (students.id = :courseId OR atLeastTutors.id = :courseId) + WHERE user.isDeleted = false AND (ug.group = :studentGroupName OR ug.group = :teachingAssistantGroupName OR ug.group = :editorGroupName OR ug.group = :instructorGroupName) """) - Set findAllWebSocketRecipientsInCourseForConversation(@Param("courseId") Long courseId, @Param("conversationId") Long conversationId); + Set findAllWebSocketRecipientsInCourseForConversation(@Param("conversationId") Long conversationId, + @Param("studentGroupName") String studentGroupName, @Param("teachingAssistantGroupName") String teachingAssistantGroupName, + @Param("editorGroupName") String editorGroupName, @Param("instructorGroupName") String instructorGroupName); /** * Searches for users in a group by their login or full name. diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java new file mode 100644 index 000000000000..3b952a08ab6f --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/QuizPoolService.java @@ -0,0 +1,139 @@ +package de.tum.in.www1.artemis.service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.domain.quiz.QuizQuestion; +import de.tum.in.www1.artemis.repository.DragAndDropMappingRepository; +import de.tum.in.www1.artemis.repository.ExamRepository; +import de.tum.in.www1.artemis.repository.QuizGroupRepository; +import de.tum.in.www1.artemis.repository.QuizPoolRepository; +import de.tum.in.www1.artemis.repository.ShortAnswerMappingRepository; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; + +/** + * This service contains the functions to manage QuizPool entity. + */ +@Service +public class QuizPoolService extends QuizService { + + private static final String ENTITY_NAME = "quizPool"; + + private final Logger log = LoggerFactory.getLogger(QuizPoolService.class); + + private final QuizPoolRepository quizPoolRepository; + + private final QuizGroupRepository quizGroupRepository; + + private final ExamRepository examRepository; + + public QuizPoolService(DragAndDropMappingRepository dragAndDropMappingRepository, ShortAnswerMappingRepository shortAnswerMappingRepository, + QuizPoolRepository quizPoolRepository, QuizGroupRepository quizGroupRepository, ExamRepository examRepository) { + super(dragAndDropMappingRepository, shortAnswerMappingRepository); + this.quizPoolRepository = quizPoolRepository; + this.quizGroupRepository = quizGroupRepository; + this.examRepository = examRepository; + } + + /** + * Check if the given exam id is valid, then update quiz pool that belongs to the given exam id + * + * @param examId the id of the exam to be checked + * @param quizPool the quiz pool to be updated + * @return updated quiz pool + */ + public QuizPool update(Long examId, QuizPool quizPool) { + Exam exam = examRepository.findByIdElseThrow(examId); + + quizPool.setExam(exam); + + if (quizPool.getQuizQuestions() == null || !quizPool.isValid()) { + throw new BadRequestAlertException("The quiz pool is invalid", ENTITY_NAME, "invalidQuiz"); + } + + List savedQuizGroups = quizGroupRepository.saveAllAndFlush(quizPool.getQuizGroups()); + quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).ifPresent(existingQuizPool -> { + List existingQuizGroupIds = existingQuizPool.getQuizQuestions().stream().map(QuizQuestion::getQuizGroupId).filter(Objects::nonNull).toList(); + removeUnusedQuizGroup(existingQuizGroupIds, savedQuizGroups); + }); + + Map quizGroupNameIdMap = savedQuizGroups.stream().collect(Collectors.toMap(QuizGroup::getName, QuizGroup::getId)); + for (QuizQuestion quizQuestion : quizPool.getQuizQuestions()) { + if (quizQuestion.getQuizGroup() != null) { + quizQuestion.setQuizGroupId(quizGroupNameIdMap.get(quizQuestion.getQuizGroup().getName())); + } + else { + quizQuestion.setQuizGroupId(null); + } + } + quizPool.reconnectJSONIgnoreAttributes(); + + log.debug("Save quiz pool to database: {}", quizPool); + super.save(quizPool); + + QuizPool savedQuizPool = quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).orElseThrow(() -> new EntityNotFoundException(ENTITY_NAME, "examId=" + examId)); + savedQuizPool.setQuizGroups(savedQuizGroups); + reassignQuizQuestion(savedQuizPool, savedQuizGroups); + + return savedQuizPool; + } + + /** + * Find a quiz pool (if exists) that belongs to the given exam id + * + * @param examId the id of the exam to be searched + * @return quiz pool that belongs to the given exam id + */ + public QuizPool findByExamId(Long examId) { + QuizPool quizPool = quizPoolRepository.findWithEagerQuizQuestionsByExamId(examId).orElseThrow(() -> new EntityNotFoundException(ENTITY_NAME, "examId=" + examId)); + List quizGroupIds = quizPool.getQuizQuestions().stream().map(QuizQuestion::getQuizGroupId).filter(Objects::nonNull).toList(); + List quizGroups = quizGroupRepository.findAllById(quizGroupIds); + quizPool.setQuizGroups(quizGroups); + reassignQuizQuestion(quizPool, quizGroups); + return quizPool; + } + + /** + * Reassign the connection between quiz question, quiz pool and quiz group + * + * @param quizPool the quiz pool to be reset + * @param quizGroups the list of quiz group to be reset + */ + private void reassignQuizQuestion(QuizPool quizPool, List quizGroups) { + Map idQuizGroupMap = quizGroups.stream().collect(Collectors.toMap(QuizGroup::getId, Function.identity())); + for (QuizQuestion quizQuestion : quizPool.getQuizQuestions()) { + if (quizQuestion.getQuizGroupId() != null) { + quizQuestion.setQuizGroup(idQuizGroupMap.get(quizQuestion.getQuizGroupId())); + } + } + } + + /** + * Remove existing groups that do not exist anymore in the updated quiz pool + * + * @param existingQuizGroupIds the list of existing quiz group id of the quiz pool + * @param usedQuizGroups the list of quiz group that are still exists in the updated quiz pool + */ + private void removeUnusedQuizGroup(List existingQuizGroupIds, List usedQuizGroups) { + Set usedQuizGroupIds = usedQuizGroups.stream().map(QuizGroup::getId).collect(Collectors.toSet()); + Set ids = existingQuizGroupIds.stream().filter(id -> !usedQuizGroupIds.contains(id)).collect(Collectors.toSet()); + quizGroupRepository.deleteAllById(ids); + } + + @Override + protected QuizPool saveAndFlush(QuizPool quizConfiguration) { + return quizPoolRepository.saveAndFlush(quizConfiguration); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerPostService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerPostService.java index 63e682a89d20..71892690feda 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerPostService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/AnswerPostService.java @@ -84,7 +84,7 @@ public AnswerPost createAnswerPost(Long courseId, AnswerPost answerPost) { AnswerPost savedAnswerPost = answerPostRepository.save(answerPost); postRepository.save(post); - this.preparePostAndBroadcast(savedAnswerPost, course); + preparePostAndBroadcast(savedAnswerPost, course); sendNotification(post, answerPost, course); return savedAnswerPost; diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java index c1210490f282..78b2428d8fff 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/ConversationMessagingService.java @@ -85,13 +85,15 @@ public Post createMessage(Long courseId, Post newMessage) { throw new BadRequestAlertException("A new message post must have a conversation", METIS_POST_ENTITY_NAME, "conversationnotset"); } - var author = this.userRepository.getUserWithGroupsAndAuthorities(); + var author = userRepository.getUserWithGroupsAndAuthorities(); newMessage.setAuthor(author); newMessage.setDisplayPriority(DisplayPriority.NONE); conversationService.isMemberElseThrow(newMessage.getConversation().getId(), author.getId()); + log.info(" createMessage:conversationService.isMemberElseThrow DONE"); var conversation = conversationRepository.findByIdElseThrow(newMessage.getConversation().getId()); + log.info(" createMessage:conversationRepository.findByIdElseThrow DONE"); // IMPORTANT we don't need it in the conversation any more, so we reduce the amount of data sent to clients conversation.setConversationParticipants(Set.of()); var course = preCheckUserAndCourseForMessaging(author, courseId); @@ -100,9 +102,9 @@ public Post createMessage(Long courseId, Post newMessage) { if (conversation instanceof Channel channel) { channelAuthorizationService.isAllowedToCreateNewPostInChannel(channel, author); } - + log.debug(" createMessage:additional authorization DONE"); Set mentionedUsers = parseUserMentions(course, newMessage.getContent()); - + log.debug(" createMessage:parseUserMentions DONE"); // update last message date of conversation conversation.setLastMessageDate(ZonedDateTime.now()); conversation.setCourse(course); @@ -119,21 +121,24 @@ public Post createMessage(Long courseId, Post newMessage) { if (createdMessage.getConversation() != null) { createdMessage.getConversation().hideDetails(); } - + log.debug(" conversationMessageRepository.save DONE"); // TODO: we should consider invoking the following method async to avoid that authors wait for the message creation if many notifications are sent notifyAboutMessageCreation(author, savedConversation, course, createdMessage, mentionedUsers); - + log.debug(" notifyAboutMessageCreation DONE"); return createdMessage; } private void notifyAboutMessageCreation(User author, Conversation conversation, Course course, Post createdMessage, Set mentionedUsers) { Set webSocketRecipients = getWebSocketRecipients(conversation).collect(Collectors.toSet()); - Set broadcastRecipients = webSocketRecipients.stream().map(ConversationWebSocketRecipientSummary::user).collect(Collectors.toSet()); + log.debug(" getWebSocketRecipients DONE"); + Set broadcastRecipients = webSocketRecipients.stream().map(summary -> new User(summary.userId(), summary.userLogin())).collect(Collectors.toSet()); // Add all mentioned users, including the author (if mentioned). Since working with sets, there are no duplicate user entries + mentionedUsers = mentionedUsers.stream().map(user -> new User(user.getId(), user.getLogin())).collect(Collectors.toSet()); broadcastRecipients.addAll(mentionedUsers); // Websocket notification 1: this notifies everyone including the author that there is a new message broadcastForPost(new PostDTO(createdMessage, MetisCrudAction.CREATE), course, broadcastRecipients); + log.debug(" broadcastForPost DONE"); if (conversation instanceof OneToOneChat) { var getNumberOfPosts = conversationMessageRepository.countByConversationId(conversation.getId()); @@ -143,17 +148,20 @@ private void notifyAboutMessageCreation(User author, Conversation conversation, } } conversationParticipantRepository.incrementUnreadMessagesCountOfParticipants(conversation.getId(), author.getId()); + log.debug(" incrementUnreadMessagesCountOfParticipants DONE"); // ToDo: Optimization Idea: Maybe we can save this websocket call and instead get the last message date from the conversation object in the post somehow? // send conversation with updated last message date to participants. This is necessary to show the unread messages badge in the client // TODO: why do we need notification 2 and 3? we should definitely re-work this! // Websocket notification 2 conversationService.notifyAllConversationMembersAboutNewMessage(course, conversation, broadcastRecipients); + log.debug(" conversationService.notifyAllConversationMembersAboutNewMessage DONE"); // creation of message posts should not trigger entity creation alert // Websocket notification 3 Set notificationRecipients = filterNotificationRecipients(author, conversation, webSocketRecipients, mentionedUsers); conversationNotificationService.notifyAboutNewMessage(createdMessage, notificationRecipients, course); + log.debug(" conversationNotificationService.notifyAboutNewMessage DONE"); } /** @@ -172,12 +180,13 @@ private void notifyAboutMessageCreation(User author, Conversation conversation, private Set filterNotificationRecipients(User author, Conversation conversation, Set webSocketRecipients, Set mentionedUsers) { // Initialize filter with check for author - Predicate filter = recipientSummary -> !Objects.equals(recipientSummary.user().getId(), author.getId()); + Predicate filter = recipientSummary -> !Objects.equals(recipientSummary.userId(), author.getId()); if (conversation instanceof Channel channel) { // If a channel is not an announcement channel, filter out users, that hid the conversation if (!channel.getIsAnnouncementChannel()) { - filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden() || mentionedUsers.contains(recipientSummary.user())); + filter = filter.and( + recipientSummary -> !recipientSummary.isConversationHidden() || mentionedUsers.contains(new User(recipientSummary.userId(), recipientSummary.userLogin()))); } // If a channel is not visible to students, filter out participants that are only students @@ -189,7 +198,7 @@ private Set filterNotificationRecipients(User author, Conversation convers filter = filter.and(recipientSummary -> !recipientSummary.isConversationHidden()); } - return webSocketRecipients.stream().filter(filter).map(ConversationWebSocketRecipientSummary::user).collect(Collectors.toSet()); + return webSocketRecipients.stream().filter(filter).map(summary -> new User(summary.userId(), summary.userLogin())).collect(Collectors.toSet()); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java index 14a5cb90fa8c..02fafd96f552 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/PostingService.java @@ -128,11 +128,14 @@ else if (postConversation != null) { */ protected Stream getWebSocketRecipients(Conversation conversation) { if (conversation instanceof Channel channel && channel.getIsCourseWide()) { - return userRepository.findAllWebSocketRecipientsInCourseForConversation(conversation.getCourse().getId(), conversation.getId()).stream(); + Course course = conversation.getCourse(); + return userRepository.findAllWebSocketRecipientsInCourseForConversation(conversation.getId(), course.getStudentGroupName(), course.getTeachingAssistantGroupName(), + course.getEditorGroupName(), course.getInstructorGroupName()).stream(); } return conversationParticipantRepository.findConversationParticipantWithUserGroupsByConversationId(conversation.getId()).stream() - .map(participant -> new ConversationWebSocketRecipientSummary(participant.getUser(), participant.getIsHidden() != null && participant.getIsHidden(), + .map(participant -> new ConversationWebSocketRecipientSummary(participant.getUser().getId(), participant.getUser().getLogin(), + participant.getIsHidden() != null && participant.getIsHidden(), authorizationCheckService.isAtLeastTeachingAssistantInCourse(conversation.getCourse(), participant.getUser()))); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java index 5db5b8df1c9a..02d834ae65f7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metis/conversation/ConversationService.java @@ -236,7 +236,7 @@ public void deregisterUsersFromAConversation(Course course, Set users, Con var remainingUsers = existingUsers.stream().filter(user -> !usersToBeDeregistered.contains(user)).collect(Collectors.toSet()); var participantsToRemove = conversationParticipantRepository.findConversationParticipantsByConversationIdAndUserIds(conversation.getId(), usersToBeDeregistered.stream().map(User::getId).collect(Collectors.toSet())); - if (participantsToRemove.size() > 0) { + if (!participantsToRemove.isEmpty()) { conversationParticipantRepository.deleteAll(participantsToRemove); broadcastOnConversationMembershipChannel(course, MetisCrudAction.DELETE, conversation, usersToBeDeregistered); broadcastOnConversationMembershipChannel(course, MetisCrudAction.UPDATE, conversation, remainingUsers); diff --git a/src/main/java/de/tum/in/www1/artemis/service/notifications/NotificationSettingsService.java b/src/main/java/de/tum/in/www1/artemis/service/notifications/NotificationSettingsService.java index 81981bd2b20d..0bf34ed302c4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/notifications/NotificationSettingsService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/notifications/NotificationSettingsService.java @@ -150,7 +150,7 @@ public class NotificationSettingsService { Map.entry(NOTIFICATION__USER_NOTIFICATION__NEW_REPLY_IN_CONVERSATION_MESSAGE, new NotificationType[] { CONVERSATION_NEW_REPLY_MESSAGE })); // This set has to equal the UI configuration in the client notification settings structure file! - // More information on supported notification types can be found here: https://ls1intum.github.io/Artemis/user/notifications/ + // More information on supported notification types can be found here: https://docs.artemis.cit.tum.de/user/notifications/ // Please adapt the above docs if you change the supported notification types private static final Set NOTIFICATION_TYPES_WITH_INSTANT_NOTIFICATION_SUPPORT = Set.of(EXERCISE_RELEASED, EXERCISE_PRACTICE, ATTACHMENT_CHANGE, NEW_ANNOUNCEMENT_POST, FILE_SUBMISSION_SUCCESSFUL, EXERCISE_SUBMISSION_ASSESSED, DUPLICATE_TEST_CASE, NEW_PLAGIARISM_CASE_STUDENT, PLAGIARISM_CASE_VERDICT_STUDENT, @@ -159,7 +159,7 @@ public class NotificationSettingsService { NEW_LECTURE_POST, NEW_REPLY_FOR_LECTURE_POST, NEW_COURSE_POST, NEW_REPLY_FOR_COURSE_POST, NEW_REPLY_FOR_EXERCISE_POST, QUIZ_EXERCISE_STARTED, DATA_EXPORT_CREATED, DATA_EXPORT_FAILED, CONVERSATION_NEW_MESSAGE, CONVERSATION_NEW_REPLY_MESSAGE); - // More information on supported notification types can be found here: https://ls1intum.github.io/Artemis/user/notifications/ + // More information on supported notification types can be found here: https://docs.artemis.cit.tum.de/user/notifications/ // Please adapt the above docs if you change the supported notification types private static final Set INSTANT_NOTIFICATION_TYPES_WITHOUT_EMAIL_SUPPORT = Set.of(QUIZ_EXERCISE_STARTED, NEW_EXERCISE_POST, NEW_LECTURE_POST, NEW_REPLY_FOR_LECTURE_POST, NEW_COURSE_POST, NEW_REPLY_FOR_COURSE_POST, NEW_REPLY_FOR_EXERCISE_POST, CONVERSATION_NEW_MESSAGE, CONVERSATION_NEW_REPLY_MESSAGE); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java new file mode 100644 index 000000000000..2d503e9197d5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizPoolResource.java @@ -0,0 +1,95 @@ +package de.tum.in.www1.artemis.web.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.security.Role; +import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; +import de.tum.in.www1.artemis.service.AuthorizationCheckService; +import de.tum.in.www1.artemis.service.QuizPoolService; +import de.tum.in.www1.artemis.service.exam.ExamAccessService; +import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; + +/** + * REST controller for managing QuizPool. + */ +@RestController +@RequestMapping("api/") +public class QuizPoolResource { + + private static final String ENTITY_NAME = "quizPool"; + + private final Logger log = LoggerFactory.getLogger(QuizPoolResource.class); + + @Value("${jhipster.clientApp.name}") + private String applicationName; + + private final QuizPoolService quizPoolService; + + private final CourseRepository courseRepository; + + private final AuthorizationCheckService authCheckService; + + private final ExamAccessService examAccessService; + + public QuizPoolResource(QuizPoolService quizPoolService, CourseRepository courseRepository, AuthorizationCheckService authCheckService, ExamAccessService examAccessService) { + this.quizPoolService = quizPoolService; + this.courseRepository = courseRepository; + this.authCheckService = authCheckService; + this.examAccessService = examAccessService; + } + + /** + * PUT /courses/{courseId}/exams/{examId}/quiz-pools : Update an existing QuizPool. + * + * @param courseId the id of the Course of which the QuizPool belongs to + * @param examId the id of the Exam of which the QuizPool belongs to + * @param quizPool the QuizPool to update + * @return the ResponseEntity with status 200 (OK) and with the body of the QuizPool, or with status 400 (Bad Request) if the QuizPool is invalid + */ + @PutMapping("courses/{courseId}/exams/{examId}/quiz-pools") + @EnforceAtLeastInstructor + public ResponseEntity updateQuizPool(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody QuizPool quizPool) { + log.info("REST request to update QuizPool : {}", quizPool); + + validateCourseRole(courseId); + QuizPool updatedQuizPool = quizPoolService.update(examId, quizPool); + + return ResponseEntity.ok().headers(HeaderUtil.createEntityUpdateAlert(applicationName, true, ENTITY_NAME, updatedQuizPool.getId().toString())).body(updatedQuizPool); + } + + /** + * GET /courses/{courseId}/exams/{examId}/quiz-pools : Get an existing QuizPool. + * + * @param courseId the id of the Course of which the QuizPool belongs to + * @param examId the id of the Exam of which the QuizPool belongs to + * @return the ResponseEntity with status 200 (OK) and with the body of the QuizPool, or with status 404 (Not Found) if the QuizPool is not found + */ + @GetMapping("courses/{courseId}/exams/{examId}/quiz-pools") + @EnforceAtLeastInstructor + public ResponseEntity getQuizPool(@PathVariable Long courseId, @PathVariable Long examId) { + log.info("REST request to get QuizPool given examId : {}", examId); + + validateCourseRole(courseId); + QuizPool quizPool = quizPoolService.findByExamId(examId); + + return ResponseEntity.ok().body(quizPool); + } + + private void validateCourseRole(Long courseId) { + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); + examAccessService.checkCourseAccessForInstructorElseThrow(courseId); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerMessageResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerMessageResource.java index 7d2ff4e1d739..87ff10ed392d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerMessageResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerMessageResource.java @@ -3,6 +3,8 @@ import java.net.URI; import java.net.URISyntaxException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -10,11 +12,14 @@ import de.tum.in.www1.artemis.domain.metis.AnswerPost; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.metis.AnswerMessageService; +import de.tum.in.www1.artemis.service.util.TimeLogUtil; @RestController @RequestMapping("/api") public class AnswerMessageResource { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final AnswerMessageService answerMessageService; public AnswerMessageResource(AnswerMessageService answerMessageService) { @@ -31,10 +36,12 @@ public AnswerMessageResource(AnswerMessageService answerMessageService) { */ @PostMapping("courses/{courseId}/answer-messages") @EnforceAtLeastStudent - public ResponseEntity createAnswerPost(@PathVariable Long courseId, @RequestBody AnswerPost answerMessage) throws URISyntaxException { + public ResponseEntity createAnswerMessage(@PathVariable Long courseId, @RequestBody AnswerPost answerMessage) throws URISyntaxException { + log.debug("POST createAnswerMessage invoked for course {} with message {}", courseId, answerMessage.getContent()); + long start = System.nanoTime(); AnswerPost createdAnswerMessage = answerMessageService.createAnswerMessage(courseId, answerMessage); - // creation of answerMessage should not trigger alert + log.info("createAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses" + courseId + "/answer-messages/" + createdAnswerMessage.getId())).body(createdAnswerMessage); } @@ -49,8 +56,11 @@ public ResponseEntity createAnswerPost(@PathVariable Long courseId, */ @PutMapping("courses/{courseId}/answer-messages/{answerMessageId}") @EnforceAtLeastStudent - public ResponseEntity updateAnswerPost(@PathVariable Long courseId, @PathVariable Long answerMessageId, @RequestBody AnswerPost answerMessage) { + public ResponseEntity updateAnswerMessage(@PathVariable Long courseId, @PathVariable Long answerMessageId, @RequestBody AnswerPost answerMessage) { + log.debug("PUT updateAnswerMessage invoked for course {} with message {}", courseId, answerMessage.getContent()); + long start = System.nanoTime(); AnswerPost updatedAnswerMessage = answerMessageService.updateAnswerMessage(courseId, answerMessageId, answerMessage); + log.info("updateAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedAnswerMessage, null, HttpStatus.OK); } @@ -64,9 +74,11 @@ public ResponseEntity updateAnswerPost(@PathVariable Long courseId, */ @DeleteMapping("courses/{courseId}/answer-messages/{answerMessageId}") @EnforceAtLeastStudent - public ResponseEntity deleteAnswerPost(@PathVariable Long courseId, @PathVariable Long answerMessageId) { + public ResponseEntity deleteAnswerMessage(@PathVariable Long courseId, @PathVariable Long answerMessageId) { + log.debug("PUT deleteAnswerMessage invoked for course {} on message {}", courseId, answerMessageId); + long start = System.nanoTime(); answerMessageService.deleteAnswerMessageById(courseId, answerMessageId); - + log.info("deleteAnswerMessage took {}", TimeLogUtil.formatDurationFrom(start)); // deletion of answerMessages should not trigger alert return ResponseEntity.ok().build(); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerPostResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerPostResource.java index 923c0711f999..8c2d6a4f527b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerPostResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/AnswerPostResource.java @@ -3,7 +3,8 @@ import java.net.URI; import java.net.URISyntaxException; -import org.springframework.beans.factory.annotation.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -11,6 +12,7 @@ import de.tum.in.www1.artemis.domain.metis.AnswerPost; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.metis.AnswerPostService; +import de.tum.in.www1.artemis.service.util.TimeLogUtil; /** * REST controller for managing AnswerPost. @@ -19,10 +21,9 @@ @RequestMapping("/api") public class AnswerPostResource { - private final AnswerPostService answerPostService; + private final Logger log = LoggerFactory.getLogger(getClass()); - @Value("${jhipster.clientApp.name}") - private String applicationName; + private final AnswerPostService answerPostService; public AnswerPostResource(AnswerPostService answerPostService) { this.answerPostService = answerPostService; @@ -39,7 +40,10 @@ public AnswerPostResource(AnswerPostService answerPostService) { @PostMapping("courses/{courseId}/answer-posts") @EnforceAtLeastStudent public ResponseEntity createAnswerPost(@PathVariable Long courseId, @RequestBody AnswerPost answerPost) throws URISyntaxException { + log.debug("POST createAnswerPost invoked for course {} with post {}", courseId, answerPost.getContent()); + long start = System.nanoTime(); AnswerPost createdAnswerPost = answerPostService.createAnswerPost(courseId, answerPost); + log.info("createAnswerPost took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses" + courseId + "/answer-posts/" + createdAnswerPost.getId())).body(createdAnswerPost); } @@ -55,7 +59,10 @@ public ResponseEntity createAnswerPost(@PathVariable Long courseId, @PutMapping("courses/{courseId}/answer-posts/{answerPostId}") @EnforceAtLeastStudent public ResponseEntity updateAnswerPost(@PathVariable Long courseId, @PathVariable Long answerPostId, @RequestBody AnswerPost answerPost) { + log.debug("PUT updateAnswerPost invoked for course {} with post {}", courseId, answerPost.getContent()); + long start = System.nanoTime(); AnswerPost updatedAnswerPost = answerPostService.updateAnswerPost(courseId, answerPostId, answerPost); + log.info("updatedAnswerPost took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedAnswerPost, null, HttpStatus.OK); } @@ -70,7 +77,10 @@ public ResponseEntity updateAnswerPost(@PathVariable Long courseId, @DeleteMapping("courses/{courseId}/answer-posts/{answerPostId}") @EnforceAtLeastStudent public ResponseEntity deleteAnswerPost(@PathVariable Long courseId, @PathVariable Long answerPostId) { + log.debug("PUT deleteAnswerPost invoked for course {} on post {}", courseId, answerPostId); + long start = System.nanoTime(); answerPostService.deleteAnswerPostById(courseId, answerPostId); + log.info("deleteAnswerPost took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/ConversationMessageResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/ConversationMessageResource.java index 792ba1eef2e6..52ee02c10665 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/ConversationMessageResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/ConversationMessageResource.java @@ -32,7 +32,7 @@ @RequestMapping("/api") public class ConversationMessageResource { - private final Logger log = LoggerFactory.getLogger(this.getClass()); + private final Logger log = LoggerFactory.getLogger(getClass()); private final ConversationMessagingService conversationMessagingService; @@ -51,7 +51,10 @@ public ConversationMessageResource(ConversationMessagingService conversationMess @PostMapping("courses/{courseId}/messages") @EnforceAtLeastStudent public ResponseEntity createMessage(@PathVariable Long courseId, @Valid @RequestBody Post post) throws URISyntaxException { + log.debug("POST createMessage invoked for course {} with post {}", courseId, post.getContent()); + long start = System.nanoTime(); Post createdMessage = conversationMessagingService.createMessage(courseId, post); + log.info("createMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/messages/" + createdMessage.getId())).body(createdMessage); } @@ -103,7 +106,10 @@ private void logDuration(List posts, Principal principal, long timeNanoSta @PutMapping("courses/{courseId}/messages/{messageId}") @EnforceAtLeastStudent public ResponseEntity updateMessage(@PathVariable Long courseId, @PathVariable Long messageId, @RequestBody Post messagePost) { + log.debug("PUT updateMessage invoked for course {} with post {}", courseId, messagePost.getContent()); + long start = System.nanoTime(); Post updatedMessagePost = conversationMessagingService.updateMessage(courseId, messageId, messagePost); + log.info("updateMessage took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedMessagePost, null, HttpStatus.OK); } @@ -118,8 +124,11 @@ public ResponseEntity updateMessage(@PathVariable Long courseId, @PathVari @DeleteMapping("courses/{courseId}/messages/{messageId}") @EnforceAtLeastStudent public ResponseEntity deleteMessage(@PathVariable Long courseId, @PathVariable Long messageId) { + log.debug("DELETE deleteMessage invoked for course {} on message {}", courseId, messageId); + long start = System.nanoTime(); conversationMessagingService.deleteMessageById(courseId, messageId); // deletion of message posts should not trigger entity deletion alert + log.info("deleteMessage took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/PostResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/PostResource.java index c36fe572d627..5d2edd87e7b5 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/metis/PostResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/metis/PostResource.java @@ -6,6 +6,8 @@ import javax.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,6 +22,7 @@ import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor; import de.tum.in.www1.artemis.service.metis.PostService; +import de.tum.in.www1.artemis.service.util.TimeLogUtil; import de.tum.in.www1.artemis.web.rest.dto.PostContextFilter; import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; import io.swagger.annotations.ApiParam; @@ -32,6 +35,8 @@ @RequestMapping("/api") public class PostResource { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final PostService postService; @Value("${jhipster.clientApp.name}") @@ -52,7 +57,10 @@ public PostResource(PostService postService) { @PostMapping("courses/{courseId}/posts") @EnforceAtLeastStudent public ResponseEntity createPost(@PathVariable Long courseId, @Valid @RequestBody Post post) throws URISyntaxException { + log.debug("POST createPost invoked for course {} with post {}", courseId, post.getContent()); + long start = System.nanoTime(); Post createdPost = postService.createPost(courseId, post); + log.info("createPost took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/posts/" + createdPost.getId())).body(createdPost); } @@ -68,7 +76,10 @@ public ResponseEntity createPost(@PathVariable Long courseId, @Valid @Requ @PutMapping("courses/{courseId}/posts/{postId}") @EnforceAtLeastStudent public ResponseEntity updatePost(@PathVariable Long courseId, @PathVariable Long postId, @RequestBody Post post) { + log.debug("PUT updatePost invoked for course {} with post {}", courseId, post.getContent()); + long start = System.nanoTime(); Post updatedPost = postService.updatePost(courseId, postId, post); + log.info("updatePost took {}", TimeLogUtil.formatDurationFrom(start)); return new ResponseEntity<>(updatedPost, null, HttpStatus.OK); } @@ -133,7 +144,10 @@ public ResponseEntity> getPostsInCourse(@ApiParam Pageable pageable, @DeleteMapping("courses/{courseId}/posts/{postId}") @EnforceAtLeastStudent public ResponseEntity deletePost(@PathVariable Long courseId, @PathVariable Long postId) { + log.debug("DELETE deletePost invoked for course {} on post {}", courseId, postId); + long start = System.nanoTime(); postService.deletePostById(courseId, postId); + log.info("deletePost took {}", TimeLogUtil.formatDurationFrom(start)); return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, postService.getEntityName(), postId.toString())).build(); } diff --git a/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml b/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml new file mode 100644 index 000000000000..f8db84eaa30c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20230524102945_changelog.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index a387a1af2d6f..107239fe2eb4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -64,6 +64,7 @@ + diff --git a/src/main/resources/templates/haskell/test/readme.md b/src/main/resources/templates/haskell/test/readme.md index a83977cee4fd..bfc95d38aa4f 100644 --- a/src/main/resources/templates/haskell/test/readme.md +++ b/src/main/resources/templates/haskell/test/readme.md @@ -10,7 +10,7 @@ The executables specified in `test.cabal` expect the solution repository checked Moreover, `test.cabal` provides an executable to test the template repository locally. For this, it expects the template repository in the `template` subdirectory. -You can find a script to conveniently setup this folder structure when checking out a new exercise in the [programming exercise setup documentation](https://ls1intum.github.io/Artemis/user/exercises/programming/#setup). +You can find a script to conveniently setup this folder structure when checking out a new exercise in the [programming exercise setup documentation](https://docs.artemis.cit.tum.de/user/exercises/programming/#setup). ## Running Tests diff --git a/src/main/webapp/app/core/about-us/about-us.component.ts b/src/main/webapp/app/core/about-us/about-us.component.ts index 1d6fb1b5fd58..11e6b08d22ad 100644 --- a/src/main/webapp/app/core/about-us/about-us.component.ts +++ b/src/main/webapp/app/core/about-us/about-us.component.ts @@ -24,28 +24,28 @@ export class AboutUsComponent implements OnInit { // Array of tuple containing translation keys and translation values readonly sections: [string, { [key: string]: string }][] = [ - ['exercises.programming', { programmingUrl: 'https://ls1intum.github.io/Artemis/user/exercises/programming/' }], - ['exercises.quiz', { quizUrl: 'https://ls1intum.github.io/Artemis/user/exercises/quiz/' }], - ['exercises.modeling', { modelingUrl: 'https://ls1intum.github.io/Artemis/user/exercises/modeling/', apollonUrl: 'https://apollon.ase.in.tum.de/' }], - ['exercises.text', { textUrl: 'https://ls1intum.github.io/Artemis/user/exercises/textual/', athenaUrl: 'https://github.com/ls1intum/Athena' }], - ['exercises.fileUpload', { fileUploadUrl: 'https://ls1intum.github.io/Artemis/user/exercises/file-upload/' }], - ['exam', { examModeUrl: 'https://ls1intum.github.io/Artemis/user/exam_mode/', studentFeatureUrl: '/features/students', instructorFeatureUrl: '/features/instructors' }], - ['grading', { gradingUrl: 'https://ls1intum.github.io/Artemis/user/grading/' }], - ['assessment', { assessmentUrl: 'https://ls1intum.github.io/Artemis/user/exercises/assessment/' }], - ['communication', { communicationUrl: 'https://ls1intum.github.io/Artemis/user/communication/' }], - ['notifications', { notificationsURL: 'https://ls1intum.github.io/Artemis/user/notifications' }], - ['teamExercises', { teamExercisesUrl: 'https://ls1intum.github.io/Artemis/user/exercises/team-exercises/' }], - ['lectures', { lecturesUrl: 'https://ls1intum.github.io/Artemis/user/lectures/' }], - ['integratedMarkdownEditor', { markdownEditorUrl: 'https://ls1intum.github.io/Artemis/user/markdown-support/' }], - ['plagiarismChecks', { jPlagUrl: 'https://github.com/jplag/JPlag/', plagiarismChecksUrl: 'https://ls1intum.github.io/Artemis/user/plagiarism-check/' }], - ['learningAnalytics', { learningAnalyticsUrl: 'https://ls1intum.github.io/Artemis/user/learning-analytics/' }], - ['adaptiveLearning', { adaptiveLearningUrl: 'https://ls1intum.github.io/Artemis/user/adaptive-learning/' }], - ['tutorialGroups', { tutorialGroupsUrl: 'https://ls1intum.github.io/Artemis/user/tutorialgroups/' }], + ['exercises.programming', { programmingUrl: 'https://docs.artemis.cit.tum.de/user/exercises/programming/' }], + ['exercises.quiz', { quizUrl: 'https://docs.artemis.cit.tum.de/user/exercises/quiz/' }], + ['exercises.modeling', { modelingUrl: 'https://docs.artemis.cit.tum.de/user/exercises/modeling/', apollonUrl: 'https://apollon.ase.in.tum.de/' }], + ['exercises.text', { textUrl: 'https://docs.artemis.cit.tum.de/user/exercises/textual/', athenaUrl: 'https://github.com/ls1intum/Athena' }], + ['exercises.fileUpload', { fileUploadUrl: 'https://docs.artemis.cit.tum.de/user/exercises/file-upload/' }], + ['exam', { examModeUrl: 'https://docs.artemis.cit.tum.de/user/exam_mode/', studentFeatureUrl: '/features/students', instructorFeatureUrl: '/features/instructors' }], + ['grading', { gradingUrl: 'https://docs.artemis.cit.tum.de/user/grading/' }], + ['assessment', { assessmentUrl: 'https://docs.artemis.cit.tum.de/user/exercises/assessment/' }], + ['communication', { communicationUrl: 'https://docs.artemis.cit.tum.de/user/communication/' }], + ['notifications', { notificationsURL: 'https://docs.artemis.cit.tum.de/user/notifications' }], + ['teamExercises', { teamExercisesUrl: 'https://docs.artemis.cit.tum.de/user/exercises/team-exercises/' }], + ['lectures', { lecturesUrl: 'https://docs.artemis.cit.tum.de/user/lectures/' }], + ['integratedMarkdownEditor', { markdownEditorUrl: 'https://docs.artemis.cit.tum.de/user/markdown-support/' }], + ['plagiarismChecks', { jPlagUrl: 'https://github.com/jplag/JPlag/', plagiarismChecksUrl: 'https://docs.artemis.cit.tum.de/user/plagiarism-check/' }], + ['learningAnalytics', { learningAnalyticsUrl: 'https://docs.artemis.cit.tum.de/user/learning-analytics/' }], + ['adaptiveLearning', { adaptiveLearningUrl: 'https://docs.artemis.cit.tum.de/user/adaptive-learning/' }], + ['tutorialGroups', { tutorialGroupsUrl: 'https://docs.artemis.cit.tum.de/user/tutorialgroups/' }], ['iris', { irisUrl: 'https://artemis.cit.tum.de/about-iris' }], - ['scalable', { scalingUrl: 'https://ls1intum.github.io/Artemis/user/scaling/' }], - ['highUserSatisfaction', { userExperienceUrl: 'https://ls1intum.github.io/Artemis/user/user-experience/' }], - ['customizable', { customizableUrl: 'https://ls1intum.github.io/Artemis/user/courses/customizable' }], - ['openSource', { openSourceUrl: 'https://ls1intum.github.io/Artemis/dev/open-source/' }], + ['scalable', { scalingUrl: 'https://docs.artemis.cit.tum.de/user/scaling/' }], + ['highUserSatisfaction', { userExperienceUrl: 'https://docs.artemis.cit.tum.de/user/user-experience/' }], + ['customizable', { customizableUrl: 'https://docs.artemis.cit.tum.de/user/courses/customizable' }], + ['openSource', { openSourceUrl: 'https://docs.artemis.cit.tum.de/dev/open-source/' }], ]; constructor( diff --git a/src/main/webapp/app/course/manage/course-exercise-card.component.html b/src/main/webapp/app/course/manage/course-exercise-card.component.html index 1074e874e5e9..f500ab539925 100644 --- a/src/main/webapp/app/course/manage/course-exercise-card.component.html +++ b/src/main/webapp/app/course/manage/course-exercise-card.component.html @@ -4,7 +4,7 @@
{{ exerciseCount }}
-
+
+
+ + + + diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss new file mode 100644 index 000000000000..ef7fcf6573e4 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.scss @@ -0,0 +1,32 @@ +.title-container { + float: left; +} + +.max-score-container { + float: right; +} + +.max-score { + background: var(--quiz-exercise-detail-max-score-background); + color: var(--quiz-exercise-detail-max-score-color); +} + +.edit-quiz-footer { + height: 100px; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + box-shadow: 0 0 3px var(--edit-quiz-footer-box-shadow); + background: var(--edit-quiz-footer-background); + padding-top: 10px; + + .container { + height: 100%; + + .edit-quiz-footer-content { + flex-wrap: wrap; + } + } +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts new file mode 100644 index 000000000000..49b9b6f77a53 --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.component.ts @@ -0,0 +1,260 @@ +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizQuestion, QuizQuestionType } from 'app/entities/quiz/quiz-question.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { DragAndDropQuestionUtil } from 'app/exercises/quiz/shared/drag-and-drop-question-util.service'; +import { ShortAnswerQuestionUtil } from 'app/exercises/quiz/shared/short-answer-question-util.service'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { ValidationReason } from 'app/entities/exercise.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; +import { onError } from 'app/shared/util/global.utils'; +import { computeQuizQuestionInvalidReason, isQuizQuestionValid } from 'app/exercises/quiz/shared/quiz-manage-util.service'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { Exam } from 'app/entities/exam.model'; +import dayjs from 'dayjs/esm'; + +@Component({ + selector: 'jhi-quiz-pool', + templateUrl: './quiz-pool.component.html', + providers: [DragAndDropQuestionUtil, ShortAnswerQuestionUtil], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./quiz-pool.component.scss', '../shared/quiz.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class QuizPoolComponent implements OnInit { + @ViewChild('quizPoolQuestionMapping') + quizPoolMappingComponent: QuizPoolMappingComponent; + @ViewChild('quizQuestionsEdit') + quizQuestionsEditComponent: QuizQuestionListEditComponent; + + faExclamationCircle = faExclamationCircle; + + quizPool: QuizPool; + savedQuizPool: string; + isSaving: boolean; + isValid: boolean; + hasPendingChanges: boolean; + invalidReasons: ValidationReason[] = []; + warningReasons: ValidationReason[] = []; + + courseId: number; + examId: number; + isExamStarted: boolean; + + constructor( + private route: ActivatedRoute, + private quizPoolService: QuizPoolService, + private examService: ExamManagementService, + private changeDetectorRef: ChangeDetectorRef, + private dragAndDropQuestionUtil: DragAndDropQuestionUtil, + private shortAnswerQuestionUtil: ShortAnswerQuestionUtil, + private alertService: AlertService, + ) {} + + ngOnInit(): void { + this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); + this.examId = Number(this.route.snapshot.paramMap.get('examId')); + this.checkIfExamStarted(); + this.initializeQuizPool(); + } + + /** + * Add question to the quiz pool mapping component + * + * @param quizQuestion the quiz question to be added + */ + handleQuestionAdded(quizQuestion: QuizQuestion) { + this.quizPoolMappingComponent.addQuestion(quizQuestion); + this.handleUpdate(); + } + + /** + * Delete question from the quiz pool mapping component + * + * @param quizQuestion the quiz question to be deleted + */ + handleQuestionDeleted(quizQuestion: QuizQuestion) { + this.quizPoolMappingComponent.deleteQuestion(quizQuestion); + this.handleUpdate(); + } + + /** + * Save the quiz pool if there is pending changes and the configuration is valid + */ + save() { + if (!this.hasPendingChanges || !this.isValid) { + return; + } + + this.isSaving = true; + this.quizQuestionsEditComponent.parseAllQuestions(); + const requestOptions = {} as any; + this.quizPool.maxPoints = this.quizPoolMappingComponent.getMaxPoints(); + this.quizPoolService.update(this.courseId, this.examId, this.quizPool, requestOptions).subscribe({ + next: (quizPoolResponse: HttpResponse) => { + if (quizPoolResponse.body) { + this.onSaveSuccess(quizPoolResponse.body); + } else { + this.onSaveError(); + } + }, + error: () => this.onSaveError(), + }); + } + + /** + * Set isExamStarted to true if exam has been started or false otherwise + */ + private checkIfExamStarted() { + this.examService.find(this.courseId, this.examId).subscribe({ + next: (response: HttpResponse) => { + const exam = response.body!; + this.isExamStarted = exam.startDate ? exam.startDate.isBefore(dayjs()) : false; + this.changeDetectorRef.detectChanges(); + }, + error: (error: HttpErrorResponse) => { + onError(this.alertService, error); + }, + }); + } + + /** + * Set quizPool if already exists or create a new object otherwise + */ + private initializeQuizPool() { + this.quizPoolService.find(this.courseId, this.examId).subscribe({ + next: (response: HttpResponse) => { + this.quizPool = response.body!; + this.savedQuizPool = JSON.stringify(this.quizPool); + this.isValid = true; + this.computeReasons(); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 404) { + this.quizPool = new QuizPool(); + this.quizPool.quizGroups = []; + this.quizPool.quizQuestions = []; + this.hasPendingChanges = false; + this.isValid = true; + this.changeDetectorRef.detectChanges(); + } else { + onError(this.alertService, error); + } + }, + }); + } + + /** + * Set pending changes to true if there is a change from the last saved quiz pool and set is valid to true if the configuration is valid + */ + handleUpdate() { + this.hasPendingChanges = JSON.stringify(this.quizPool) !== this.savedQuizPool; + this.isValid = this.isConfigurationValid(); + this.computeReasons(); + } + + /** + * Set invalidReasons and warningReasons + */ + private computeReasons() { + this.changeDetectorRef.detectChanges(); + this.invalidReasons = this.getInvalidReasons(); + this.warningReasons = this.getWarningReasons(); + this.changeDetectorRef.detectChanges(); + } + + /** + * Check if the quiz questions and groups are all valid. + * @return true if the configuration is valid or false otherwise + */ + private isConfigurationValid(): boolean { + const quizQuestionsValid = this.quizPool.quizQuestions.every((question) => isQuizQuestionValid(question, this.dragAndDropQuestionUtil, this.shortAnswerQuestionUtil)); + const totalPoints = this.quizPool.quizQuestions?.map((quizQuestion) => quizQuestion.points ?? 0).reduce((accumulator, points) => accumulator + points, 0); + return ( + (this.quizPool.quizQuestions.length === 0 || (quizQuestionsValid && totalPoints > 0)) && + !this.quizPoolMappingComponent.hasGroupsWithNoQuestion() && + !this.quizPoolMappingComponent.hasGroupsWithDifferentQuestionPoints() + ); + } + + /** + * Compute invalid reasons of the configurations + * @return an array of ValidationReason. + */ + private getInvalidReasons(): Array { + const invalidReasons = new Array(); + this.quizPool.quizQuestions!.forEach((question: QuizQuestion, index: number) => { + computeQuizQuestionInvalidReason(invalidReasons, question, index, this.dragAndDropQuestionUtil, this.shortAnswerQuestionUtil); + }); + + if (this.quizPoolMappingComponent.hasGroupsWithNoQuestion()) { + const names = this.quizPoolMappingComponent.getGroupNamesWithNoQuestion(); + for (const name of names) { + invalidReasons.push({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupNoQuestion', + translateValues: { + name, + }, + }); + } + } + + if (this.quizPoolMappingComponent.hasGroupsWithDifferentQuestionPoints()) { + const names = this.quizPoolMappingComponent.getGroupNamesWithDifferentQuestionPoints(); + for (const name of names) { + invalidReasons.push({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupHasDifferentQuestionPoints', + translateValues: { + name, + }, + }); + } + } + + return invalidReasons; + } + + /** + * Compute warning reasons of the configurations + * @return an array of ValidationReason. + */ + private getWarningReasons(): Array { + const warningReasons = new Array(); + this.quizPool.quizQuestions.forEach((quizQuestion: QuizQuestion, index: number) => { + if (quizQuestion.type === QuizQuestionType.MULTIPLE_CHOICE && (quizQuestion).answerOptions!.some((option) => !option.explanation)) { + warningReasons.push({ + translateKey: 'artemisApp.quizExercise.invalidReasons.explanationIsMissing', + translateValues: { index: index + 1 }, + }); + } + }); + return warningReasons; + } + + /** + * Callback if the save is successful. Set isSaving & hasPendingchanges to false and update quizPool and savedQuizPool. + * + * @param quizPool the saved quiz pool + */ + private onSaveSuccess(quizPool: QuizPool): void { + this.isSaving = false; + this.hasPendingChanges = false; + this.quizPool = quizPool; + this.savedQuizPool = JSON.stringify(quizPool); + this.changeDetectorRef.detectChanges(); + } + + /** + * Callback if the save is unsuccessful. Set isSaving to false and display alert. + */ + private onSaveError = (): void => { + this.alertService.error('artemisApp.quizExercise.saveError'); + this.isSaving = false; + this.changeDetectorRef.detectChanges(); + }; +} diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts new file mode 100644 index 000000000000..c1b8e7cf4fae --- /dev/null +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-pool.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; +import { createRequestOption } from 'app/shared/util/request.util'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class QuizPoolService { + constructor(private http: HttpClient) {} + + /** + * Update the given quiz pool that belongs to the given course id and exam id + * + * @param courseId the course id of which the exam belongs to + * @param examId the exam id of which the quiz pool belongs to + * @param quizPool the quiz pool to be updated + * @param req request options + * @return the updated quiz pool + */ + update(courseId: number, examId: number, quizPool: QuizPool, req?: any): Observable> { + const options = createRequestOption(req); + return this.http.put(`api/courses/${courseId}/exams/${examId}/quiz-pools`, quizPool, { params: options, observe: 'response' }); + } + + /** + * Find the quiz pool that belongs to the given course id and exam id + * + * @param courseId the course id of which the exam belongs to + * @param examId the exam id of which the quiz pool belongs to + * @return the quiz pool that belongs to the given course id and exam id + */ + find(courseId: number, examId: number): Observable> { + return this.http.get(`api/courses/${courseId}/exams/${examId}/quiz-pools`, { observe: 'response' }); + } +} diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html index 303b3d09904b..d1fe15b29630 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html +++ b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html @@ -2,9 +2,9 @@ {{ 'artemisApp.courseOverview.exerciseList.noExerciseMatchesSearchAndFilters' | artemisTranslate }} -
+
-
+
{{ title }} wirklich dauerhaft gelöscht werden? Alle zugehörigen Elemente werden gelöscht, inklusive der Klausuren der Studierenden. Diese Aktion kann NICHT rückgängig gemacht werden!", "typeNameToConfirm": "Bitte gib den Namen der Klausur zur Bestätigung ein." diff --git a/src/main/webapp/i18n/de/quizPool.json b/src/main/webapp/i18n/de/quizPool.json new file mode 100644 index 000000000000..9e70f7aed8fe --- /dev/null +++ b/src/main/webapp/i18n/de/quizPool.json @@ -0,0 +1,23 @@ +{ + "artemisApp": { + "quizPool": { + "editTitle": "Quiz-Pool bearbeiten", + "group": "Gruppe", + "groupName": "Gruppenname", + "addGroup": "Gruppe hinzufügen", + "delete": { + "question": "Möchten Sie die Quizgruppe {{ title }} wirklich löschen? Alle zugewiesenen Fragen werden nicht zugeordnet." + }, + "dragExplanation": "Ziehen Sie die Frage unten, um sie einer Gruppe zuzuordnen.", + "groupExplanation": "Fragen in der gleichen Gruppe werden für jeden Schüler nach dem Zufallsprinzip ausgewählt. Fragen, die nicht in einer Gruppe sind, werden allen Schülern zugewiesen.", + "updated": "Quiz Pool aktualisiert mit ID {{ param }}", + "invalidReasons": { + "groupNameEmpty": "Der Gruppenname kann nicht leer sein.", + "groupNameLength": "Der Gruppenname kann nicht länger als 100 Zeichen sein.", + "groupSameName": "Der Gruppenname muss einmalig sein.", + "groupNoQuestion": "Gruppe {{name}}: Die Gruppe hat keine Fragen.", + "groupHasDifferentQuestionPoints": "Gruppe {{name}}: Gruppe hat Fragen mit unterschiedlichen Schwerpunkten." + } + } + } +} diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 0d0ad9daac6d..c42fb45f9d59 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -563,6 +563,7 @@ }, "importSuccessful": "Exercise Groups successfully imported!" }, + "quizPool": "Quiz Pool", "delete": { "question": "Are you sure you want to permanently delete the Exam {{ title }}? All associated elements will be deleted including the Student Exams. This action can NOT be undone!", "typeNameToConfirm": "Please type in the name of the exam to confirm." diff --git a/src/main/webapp/i18n/en/quizPool.json b/src/main/webapp/i18n/en/quizPool.json new file mode 100644 index 000000000000..1104b0f0e19d --- /dev/null +++ b/src/main/webapp/i18n/en/quizPool.json @@ -0,0 +1,23 @@ +{ + "artemisApp": { + "quizPool": { + "editTitle": "Edit Quiz Pool", + "group": "Group", + "groupName": "Group Name", + "addGroup": "Add Group", + "delete": { + "question": "Are you sure you want to delete Quiz Group {{ title }}? All assigned Questions will be unmapped." + }, + "dragExplanation": "Drag the question below to assign it to a group.", + "groupExplanation": "Questions in the same group will be randomly chosen for each student. Questions that are not in a group will be assigned to all students.", + "updated": "Updated Quiz Pool with identifier {{ param }}", + "invalidReasons": { + "groupNameEmpty": "Group name cannot be empty.", + "groupNameLength": "Group name cannot be longer than 100 characters.", + "groupSameName": "Group name must be unique.", + "groupNoQuestion": "Group {{name}}: Group does not have any question.", + "groupHasDifferentQuestionPoints": "Group {{name}}: Group has questions with different points." + } + } + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java new file mode 100644 index 000000000000..9616695f0892 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/QuizPoolIntegrationTest.java @@ -0,0 +1,206 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.quiz.DragAndDropQuestion; +import de.tum.in.www1.artemis.domain.quiz.MultipleChoiceQuestion; +import de.tum.in.www1.artemis.domain.quiz.QuizGroup; +import de.tum.in.www1.artemis.domain.quiz.QuizPool; +import de.tum.in.www1.artemis.domain.quiz.QuizQuestion; +import de.tum.in.www1.artemis.domain.quiz.ShortAnswerQuestion; +import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseFactory; +import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseUtilService; +import de.tum.in.www1.artemis.service.QuizPoolService; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.RequestUtilService; + +class QuizPoolIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "quizpoolintegration"; + + @Autowired + private QuizPoolService quizPoolService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + private QuizExerciseUtilService quizExerciseUtilService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private RequestUtilService request; + + private Course course; + + private Exam exam; + + private QuizPool quizPool; + + private QuizGroup quizGroup0; + + private QuizGroup quizGroup1; + + private QuizGroup quizGroup2; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, 0, 0, 0, 1); + course = courseUtilService.addEmptyCourse(); + User instructor = userUtilService.getUserByLogin(TEST_PREFIX + "instructor1"); + instructor.setGroups(Set.of(course.getInstructorGroupName())); + exam = examUtilService.addExam(course); + quizPool = quizPoolService.update(exam.getId(), new QuizPool()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateQuizPoolSuccessful() throws Exception { + QuizPool responseQuizPool = createQuizPool(); + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(quizPool.getQuizGroups().size()).extracting("name").containsExactly(quizPool.getQuizGroups().get(0).getName(), + quizPool.getQuizGroups().get(1).getName(), quizPool.getQuizGroups().get(2).getName()); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(quizPool.getQuizQuestions().size()).extracting("title", "quizGroup.name").containsExactly( + tuple(quizPool.getQuizQuestions().get(0).getTitle(), quizGroup0.getName()), tuple(quizPool.getQuizQuestions().get(1).getTitle(), quizGroup0.getName()), + tuple(quizPool.getQuizQuestions().get(2).getTitle(), quizGroup1.getName()), tuple(quizPool.getQuizQuestions().get(3).getTitle(), quizGroup2.getName()), + tuple(quizPool.getQuizQuestions().get(4).getTitle(), null)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolSuccessful() throws Exception { + QuizPool quizPool = createQuizPool(); + + QuizGroup quizGroup3 = quizExerciseUtilService.createQuizGroup("Exception Handling"); + QuizQuestion saQuizQuestion1 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 1", quizGroup2); + QuizQuestion saQuizQuestion2 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 2", quizGroup3); + QuizQuestion saQuizQuestion3 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 3", null); + quizPool.setQuizGroups(List.of(quizPool.getQuizGroups().get(0), quizPool.getQuizGroups().get(2), quizGroup3)); + quizPool.setQuizQuestions(List.of(quizPool.getQuizQuestions().get(0), quizPool.getQuizQuestions().get(1), quizPool.getQuizQuestions().get(2), saQuizQuestion1, + saQuizQuestion2, saQuizQuestion3)); + quizPool.getQuizQuestions().get(2).setQuizGroup(quizGroup3); + + QuizPool responseQuizPool = request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, + HttpStatus.OK, null); + + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(quizPool.getQuizGroups().size()).extracting("name").containsExactly(quizPool.getQuizGroups().get(0).getName(), + quizPool.getQuizGroups().get(1).getName(), quizPool.getQuizGroups().get(2).getName()); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(quizPool.getQuizQuestions().size()).extracting("title", "quizGroup.name").containsExactly( + tuple(quizPool.getQuizQuestions().get(0).getTitle(), quizGroup0.getName()), tuple(quizPool.getQuizQuestions().get(1).getTitle(), quizGroup0.getName()), + tuple(quizPool.getQuizQuestions().get(2).getTitle(), quizGroup3.getName()), tuple(quizPool.getQuizQuestions().get(3).getTitle(), quizGroup2.getName()), + tuple(quizPool.getQuizQuestions().get(4).getTitle(), quizGroup3.getName()), tuple(quizPool.getQuizQuestions().get(5).getTitle(), null)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidMCQuestion() throws Exception { + MultipleChoiceQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizQuestion.setTitle(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidDnDQuestion() throws Exception { + DragAndDropQuestion quizQuestion = QuizExerciseFactory.createDragAndDropQuestion(); + quizQuestion.setCorrectMappings(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolBadRequestInvalidSAQuestion() throws Exception { + ShortAnswerQuestion quizQuestion = QuizExerciseFactory.createShortAnswerQuestion(); + quizQuestion.setCorrectMappings(null); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolNotFoundCourse() throws Exception { + QuizQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + int notFoundCourseId = 0; + request.putWithResponseBody("/api/courses/" + notFoundCourseId + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.NOT_FOUND, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateQuizPoolNotFoundExam() throws Exception { + QuizQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + quizPool.setQuizQuestions(List.of(quizQuestion)); + + int notFoundExamId = 0; + request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + notFoundExamId + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.NOT_FOUND, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetQuizPoolSuccessful() throws Exception { + QuizGroup quizGroup0 = quizExerciseUtilService.createQuizGroup("Encapsulation"); + QuizGroup quizGroup1 = quizExerciseUtilService.createQuizGroup("Inheritance"); + QuizQuestion mcQuizQuestion = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC", quizGroup0); + QuizQuestion dndQuizQuestion = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND", quizGroup1); + QuizQuestion saQuizQuestion = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA", null); + quizPool.setQuizGroups(List.of(quizGroup0, quizGroup1)); + quizPool.setQuizQuestions(List.of(mcQuizQuestion, dndQuizQuestion, saQuizQuestion)); + QuizPool savedQuizPool = quizPoolService.update(exam.getId(), quizPool); + + QuizPool responseQuizPool = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", HttpStatus.OK, QuizPool.class); + assertThat(responseQuizPool.getExam().getId()).isEqualTo(exam.getId()); + assertThat(responseQuizPool.getQuizGroups()).hasSize(savedQuizPool.getQuizGroups().size()).containsExactly(savedQuizPool.getQuizGroups().get(0), + savedQuizPool.getQuizGroups().get(1)); + assertThat(responseQuizPool.getQuizQuestions()).hasSize(savedQuizPool.getQuizQuestions().size()).containsExactly(savedQuizPool.getQuizQuestions().get(0), + savedQuizPool.getQuizQuestions().get(1), savedQuizPool.getQuizQuestions().get(2)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetQuizPoolNotFoundExam() throws Exception { + int notFoundExamId = 0; + request.get("/api/courses/" + course.getId() + "/exams/" + notFoundExamId + "/quiz-pools", HttpStatus.NOT_FOUND, QuizPool.class); + } + + private QuizPool createQuizPool() throws Exception { + quizGroup0 = quizExerciseUtilService.createQuizGroup("Encapsulation"); + quizGroup1 = quizExerciseUtilService.createQuizGroup("Inheritance"); + quizGroup2 = quizExerciseUtilService.createQuizGroup("Polymorphism"); + QuizQuestion mcQuizQuestion0 = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC 0", quizGroup0); + QuizQuestion mcQuizQuestion1 = quizExerciseUtilService.createMultipleChoiceQuestionWithTitleAndGroup("MC 1", quizGroup0); + QuizQuestion dndQuizQuestion0 = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND 0", quizGroup1); + QuizQuestion dndQuizQuestion1 = quizExerciseUtilService.createDragAndDropQuestionWithTitleAndGroup("DND 1", quizGroup2); + QuizQuestion saQuizQuestion0 = quizExerciseUtilService.createShortAnswerQuestionWithTitleAndGroup("SA 0", null); + quizPool.setQuizGroups(List.of(quizGroup0, quizGroup1, quizGroup2)); + quizPool.setQuizQuestions(List.of(mcQuizQuestion0, mcQuizQuestion1, dndQuizQuestion0, dndQuizQuestion1, saQuizQuestion0)); + + return request.putWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/quiz-pools", quizPool, QuizPool.class, HttpStatus.OK, null); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java index 909215386516..27594619aae1 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java @@ -362,4 +362,37 @@ public void joinQuizBatch(QuizExercise quizExercise, QuizBatch batch, String use user.setLogin(username); quizScheduleService.joinQuizBatch(quizExercise, batch, user); } + + @NotNull + public QuizGroup createQuizGroup(String name) { + QuizGroup quizGroup = new QuizGroup(); + quizGroup.setName(name); + return quizGroup; + } + + @NotNull + public MultipleChoiceQuestion createMultipleChoiceQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + MultipleChoiceQuestion quizQuestion = QuizExerciseFactory.createMultipleChoiceQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + @NotNull + public DragAndDropQuestion createDragAndDropQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + DragAndDropQuestion quizQuestion = QuizExerciseFactory.createDragAndDropQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + @NotNull + public ShortAnswerQuestion createShortAnswerQuestionWithTitleAndGroup(String title, QuizGroup quizGroup) { + ShortAnswerQuestion quizQuestion = QuizExerciseFactory.createShortAnswerQuestion(); + setQuizQuestionsTitleAndGroup(quizQuestion, title, quizGroup); + return quizQuestion; + } + + private void setQuizQuestionsTitleAndGroup(Q quizQuestion, String title, QuizGroup quizGroup) { + quizQuestion.setTitle(title); + quizQuestion.setQuizGroup(quizGroup); + } } diff --git a/src/test/javascript/spec/component/documentation-button/documentation-button.component.spec.ts b/src/test/javascript/spec/component/documentation-button/documentation-button.component.spec.ts index d66e439351c2..bb218b917349 100644 --- a/src/test/javascript/spec/component/documentation-button/documentation-button.component.spec.ts +++ b/src/test/javascript/spec/component/documentation-button/documentation-button.component.spec.ts @@ -46,7 +46,7 @@ describe('DocumentationButtonComponent', () => { window.open = mockedOpen; comp.openDocumentation(); - expect(mockedOpen).toHaveBeenCalledWith('https://ls1intum.github.io/Artemis/user/courses/customizable/', expect.anything()); + expect(mockedOpen).toHaveBeenCalledWith('https://docs.artemis.cit.tum.de/user/courses/customizable/', expect.anything()); window.open = originalOpen; }); diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts index b3645afd842f..5116fe8891f9 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts @@ -33,6 +33,8 @@ import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; import { ExamLiveAnnouncementCreateButtonComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-button.component'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; @Component({ template: '', @@ -43,6 +45,7 @@ describe('ExamDetailComponent', () => { let examDetailComponentFixture: ComponentFixture; let examDetailComponent: ExamDetailComponent; let service: ExamManagementService; + let quizPoolService: QuizPoolService; let router: Router; const exampleHTML = '

Sample Markdown

'; @@ -112,6 +115,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture = TestBed.createComponent(ExamDetailComponent); examDetailComponent = examDetailComponentFixture.componentInstance; service = TestBed.inject(ExamManagementService); + quizPoolService = TestBed.inject(QuizPoolService); }); router = TestBed.inject(Router); @@ -129,6 +133,7 @@ describe('ExamDetailComponent', () => { exam.examMaxPoints = 100; exam.exerciseGroups = []; examDetailComponent.exam = exam; + jest.spyOn(quizPoolService, 'find').mockReturnValue(of(new HttpResponse({ body: new QuizPool() }))); }); afterEach(() => { diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts new file mode 100644 index 000000000000..c5828a1ec76a --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping-question-list.component.spec.ts @@ -0,0 +1,65 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockDirective, MockPipe } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { QuizPoolMappingQuestionListComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping-question-list.component'; +import * as DragDrop from '@angular/cdk/drag-drop'; +import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; + +describe('QuizPoolMappingQuestionListComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolMappingQuestionListComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, DragDrop.DragDropModule, HttpClientTestingModule], + declarations: [QuizPoolMappingQuestionListComponent, MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe), MockDirective(TranslateDirective)], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolMappingQuestionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + }); + it('should move question in the same group', () => { + const container = { + data: {}, + }; + const event = { + previousContainer: container, + container: container, + previousIndex: 0, + currentIndex: 1, + } as DragDrop.CdkDragDrop; + const moveItemInArray = jest.spyOn(DragDrop, 'moveItemInArray').mockImplementation(() => {}); + + component.handleOnDropQuestion(event); + + expect(moveItemInArray).toHaveBeenCalledOnce(); + }); + + it('should move question within different groups', () => { + const container0 = { + data: {}, + }; + const container1 = { + data: {}, + }; + const event = { + previousContainer: container0, + container: container1, + previousIndex: 0, + currentIndex: 0, + } as DragDrop.CdkDragDrop; + const transferArrayItem = jest.spyOn(DragDrop, 'transferArrayItem').mockImplementation(() => {}); + + component.handleOnDropQuestion(event); + + expect(transferArrayItem).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts new file mode 100644 index 000000000000..595a8cb073a1 --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool-mapping.component.spec.ts @@ -0,0 +1,225 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ButtonComponent } from 'app/shared/components/button.component'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { NgModel } from '@angular/forms'; +import { QuizPoolMappingQuestionListComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping-question-list.component'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; + +describe('QuizPoolMappingComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolMappingComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule, MockDirective(NgbTooltip)], + declarations: [ + QuizPoolMappingComponent, + ButtonComponent, + MockPipe(ArtemisTranslatePipe), + MockPipe(ArtemisDatePipe), + MockDirective(TranslateDirective), + MockComponent(QuizPoolMappingQuestionListComponent), + MockDirective(NgModel), + MockDirective(DeleteButtonDirective), + ], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolMappingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + }); + + it('should add group', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Test Group'); + + expect(component.quizGroups).toBeArrayOfSize(1); + }); + + it('should not add group with empty name', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup(''); + + expect(component.quizGroups).toBeArrayOfSize(0); + }); + + it('should not add group with name consisting of more than 100 characters', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean ma'); + + expect(component.quizGroups).toBeArrayOfSize(0); + }); + + it('should not add group with the same name', () => { + expect(component.quizGroups).toBeArrayOfSize(0); + + component.addGroup('Test Group'); + component.addGroup('Test Group'); + + expect(component.quizGroups).toBeArrayOfSize(1); + }); + + it('should delete group', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Group 1'; + const question = new MultipleChoiceQuestion(); + question.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.quizQuestions = [question]; + const addQuestion = jest.spyOn(component, 'addQuestion').mockImplementation(); + + component.handleUpdate(); + component.deleteGroup(0); + + expect(component.quizGroups).toBeArrayOfSize(0); + expect(addQuestion).toHaveBeenCalledOnce(); + expect(addQuestion).toHaveBeenCalledWith(question); + }); + + it('should add question', () => { + const question = new MultipleChoiceQuestion(); + component.addQuestion(question); + expect(component.unmappedQuizQuestions).toBeArrayOfSize(1); + expect(component.unmappedQuizQuestions[0]).toEqual(question); + }); + + it('should delete unmapped question', () => { + const question = new MultipleChoiceQuestion(); + component.deleteQuestion(question); + expect(component.unmappedQuizQuestions).toBeEmpty(); + }); + + it('should delete mapped question', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question = new MultipleChoiceQuestion(); + question.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.handleUpdate(); + + component.deleteQuestion(question); + + const questions = component.quizGroupNameQuestionsMap.get(quizGroup.name); + expect(questions).toBeEmpty(); + }); + + it('should return group names that do not have questions', () => { + component.quizGroupNameQuestionsMap.set('Group 1', []); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + const groupNamesWithNoQuestion = component.getGroupNamesWithNoQuestion(); + expect(groupNamesWithNoQuestion).toBeArrayOfSize(1); + expect(groupNamesWithNoQuestion[0]).toBe('Group 1'); + }); + + it('should return true if some groups with no questions', () => { + component.quizGroupNameQuestionsMap.set('Group 1', []); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + expect(component.hasGroupsWithNoQuestion()).toBeTrue(); + }); + + it('should return false if all groups have at least 1 question', () => { + component.quizGroupNameQuestionsMap.set('Group 1', [new MultipleChoiceQuestion()]); + component.quizGroupNameQuestionsMap.set('Group 2', [new MultipleChoiceQuestion()]); + expect(component.hasGroupsWithNoQuestion()).toBeFalse(); + }); + + it('should return group names that have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 2; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + const groupNamesWithNoQuestion = component.getGroupNamesWithDifferentQuestionPoints(); + expect(groupNamesWithNoQuestion).toBeArrayOfSize(1); + expect(groupNamesWithNoQuestion[0]).toBe('Group 2'); + }); + + it('should return true if some groups have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 2; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + expect(component.hasGroupsWithDifferentQuestionPoints()).toBeTrue(); + }); + + it('should return false if some groups have questions with different points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = 1; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = 1; + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', [question2, question3]); + expect(component.hasGroupsWithDifferentQuestionPoints()).toBeFalse(); + }); + + it('should set unmappedQuizQuestions and quizGroupNameQuestionsMap when inputs are changed', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question0 = new MultipleChoiceQuestion(); + const question1 = new MultipleChoiceQuestion(); + question0.quizGroup = quizGroup; + component.quizGroups = [quizGroup]; + component.quizQuestions = [question0, question1]; + component.ngOnChanges(); + expect(component.unmappedQuizQuestions).toBeArrayOfSize(1); + expect(component.unmappedQuizQuestions[0]).toEqual(question1); + expect(component.quizGroupNameQuestionsMap.size).toBe(1); + const questions = component.quizGroupNameQuestionsMap.get('Test Group'); + expect(questions).toBeArrayOfSize(1); + expect(questions![0]).toEqual(question0); + }); + + it('should set quiz group to quiz question when question is dropped to the group', () => { + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const question = new MultipleChoiceQuestion(); + component.handleOnQuizQuestionDropped(question, quizGroup); + expect(question.quizGroup).toEqual(quizGroup); + }); + + it('should return max points', () => { + const question0 = new MultipleChoiceQuestion(); + question0.points = 1; + const question1 = new MultipleChoiceQuestion(); + question1.points = undefined; + const question2 = new MultipleChoiceQuestion(); + question2.points = 1; + const question3 = new MultipleChoiceQuestion(); + question3.points = undefined; + component.quizGroupNameQuestionsMap = new Map(); + component.quizGroupNameQuestionsMap.set('Group 1', [question0, question1]); + component.quizGroupNameQuestionsMap.set('Group 2', []); + component.unmappedQuizQuestions = [question2, question3]; + expect(component.getMaxPoints()).toBe(2); + }); +}); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts new file mode 100644 index 000000000000..8cb46261cc4e --- /dev/null +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-pool.component.spec.ts @@ -0,0 +1,212 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; +import { ArtemisTestModule } from '../../../../test.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { QuizPoolComponent } from 'app/exercises/quiz/manage/quiz-pool.component'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { of, throwError } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { MultipleChoiceQuestion } from 'app/entities/quiz/multiple-choice-question.model'; +import { QuizGroup } from 'app/entities/quiz/quiz-group.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { Exam } from 'app/entities/exam.model'; +import dayjs from 'dayjs/esm'; +import { QuizPoolMappingComponent } from 'app/exercises/quiz/manage/quiz-pool-mapping.component'; +import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; +import { AnswerOption } from 'app/entities/quiz/answer-option.model'; +import { ChangeDetectorRef } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AlertService } from 'app/core/util/alert.service'; + +describe('QuizPoolComponent', () => { + let fixture: ComponentFixture; + let component: QuizPoolComponent; + let quizPoolService: QuizPoolService; + let examService: ExamManagementService; + let alertService: AlertService; + let changeDetectorRef: ChangeDetectorRef; + + const courseId = 1; + const examId = 2; + const route = { snapshot: { paramMap: convertToParamMap({ courseId, examId }) }, queryParams: of({}) } as any as ActivatedRoute; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule, NgbModule, FormsModule], + declarations: [ + QuizPoolComponent, + MockComponent(QuizPoolMappingComponent), + MockComponent(QuizQuestionListEditComponent), + MockPipe(ArtemisTranslatePipe), + MockPipe(ArtemisDatePipe), + MockDirective(TranslateDirective), + ], + providers: [{ provide: ActivatedRoute, useValue: route }, MockProvider(ChangeDetectorRef), MockProvider(AlertService)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(QuizPoolComponent); + component = fixture.componentInstance; + quizPoolService = fixture.debugElement.injector.get(QuizPoolService); + examService = fixture.debugElement.injector.get(ExamManagementService); + alertService = fixture.debugElement.injector.get(AlertService); + changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef); + fixture.detectChanges(); + }); + }); + + it('should initialize quiz pool', () => { + const quizPool = new QuizPool(); + const quizGroup = new QuizGroup(); + quizGroup.name = 'Test Group'; + const quizQuestion = new MultipleChoiceQuestion(); + quizQuestion.quizGroup = quizGroup; + quizPool.id = 1; + quizPool.quizQuestions = [quizQuestion]; + quizPool.quizGroups = [quizGroup]; + jest.spyOn(quizPoolService, 'find').mockReturnValue(of(new HttpResponse({ body: quizPool }))); + component.ngOnInit(); + expect(component.quizPool).toEqual(quizPool); + }); + + it('should initialize quiz pool with new object if existing quiz pool is not found', () => { + jest.spyOn(quizPoolService, 'find').mockReturnValue(throwError(() => ({ status: 404 }))); + component.ngOnInit(); + expect(component.quizPool.quizGroups).toBeArrayOfSize(0); + expect(component.quizPool.quizQuestions).toBeArrayOfSize(0); + }); + + it('should set isExamStarted to true', () => { + const exam = new Exam(); + exam.startDate = dayjs().subtract(1, 'hour'); + jest.spyOn(examService, 'find').mockReturnValue(of(new HttpResponse({ body: exam }))); + component.ngOnInit(); + expect(component.isExamStarted).toBeTrue(); + }); + + it('should set isExamStarted to false', () => { + const exam = new Exam(); + exam.startDate = dayjs().add(1, 'hour'); + jest.spyOn(examService, 'find').mockReturnValue(of(new HttpResponse({ body: exam }))); + component.ngOnInit(); + expect(component.isExamStarted).toBeFalse(); + }); + + it('should call QuizGroupQuestionMappingComponent.addQuestion when there is a new question', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const addQuestionSpy = jest.spyOn(component.quizPoolMappingComponent, 'addQuestion'); + const quizQuestion = new MultipleChoiceQuestion(); + component.handleQuestionAdded(quizQuestion); + expect(addQuestionSpy).toHaveBeenCalledOnce(); + expect(addQuestionSpy).toHaveBeenCalledWith(quizQuestion); + }); + + it('should call QuizGroupQuestionMappingComponent.deleteQuestion when a question is deleted', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const deleteQuestionSpy = jest.spyOn(component.quizPoolMappingComponent, 'deleteQuestion'); + const quizQuestion = new MultipleChoiceQuestion(); + component.handleQuestionDeleted(quizQuestion); + expect(deleteQuestionSpy).toHaveBeenCalledOnce(); + expect(deleteQuestionSpy).toHaveBeenCalledWith(quizQuestion); + }); + + it('should call QuizPoolService.update when saving', () => { + const quizPool = new QuizPool(); + component.courseId = courseId; + component.examId = examId; + component.quizPool = quizPool; + component.hasPendingChanges = true; + component.isValid = true; + component.quizQuestionsEditComponent = new QuizQuestionListEditComponent(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + const parseAllQuestionsSpy = jest.spyOn(component.quizQuestionsEditComponent, 'parseAllQuestions').mockImplementation(); + const getMaxPointsSpy = jest.spyOn(component.quizPoolMappingComponent, 'getMaxPoints').mockImplementation(); + const updateQuizPoolSpy = jest.spyOn(quizPoolService, 'update').mockReturnValue(of(new HttpResponse({ body: quizPool }))); + component.save(); + expect(parseAllQuestionsSpy).toHaveBeenCalledOnce(); + expect(getMaxPointsSpy).toHaveBeenCalledOnce(); + expect(updateQuizPoolSpy).toHaveBeenCalledOnce(); + }); + + it('should not call QuizPoolService.update if there is no pending changes or is not valid', () => { + const quizPool = new QuizPool(); + component.courseId = courseId; + component.examId = examId; + component.quizPool = quizPool; + component.hasPendingChanges = false; + component.isValid = false; + const updateQuizPoolSpy = jest.spyOn(quizPoolService, 'update').mockImplementation(); + component.save(); + expect(updateQuizPoolSpy).toHaveBeenCalledTimes(0); + }); + + it('should set isValid to true if all questions and groups are valid', () => { + const answerOption0 = new AnswerOption(); + answerOption0.isCorrect = true; + const answerOption1 = new AnswerOption(); + answerOption1.isCorrect = false; + const question = new MultipleChoiceQuestion(); + question.points = 1; + question.answerOptions = [answerOption0, answerOption1]; + component.quizPool = new QuizPool(); + component.quizPool.quizQuestions = [question]; + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + component.isValid = false; + component.handleUpdate(); + component.isValid = true; + }); + + it('should set isValid to false if at least 1 question is invalid', () => { + const question = new MultipleChoiceQuestion(); + question.points = -1; + question.answerOptions = []; + component.quizPool = new QuizPool(); + component.quizPool.quizQuestions = [question]; + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + component.isValid = true; + component.handleUpdate(); + component.isValid = false; + }); + + it('should set invalid reasons when there is a group that does not have any question', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges').mockImplementation(); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithNoQuestion').mockReturnValue(true); + jest.spyOn(component.quizPoolMappingComponent, 'getGroupNamesWithNoQuestion').mockReturnValue(['Test Group']); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithDifferentQuestionPoints').mockReturnValue(false); + component.handleUpdate(); + expect(component.invalidReasons).toBeArrayOfSize(1); + expect(component.invalidReasons[0]).toEqual({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupNoQuestion', + translateValues: { + name: 'Test Group', + }, + }); + }); + + it('should set invalid reasons when there is a group whose questions do not have the same points', () => { + component.quizPool = new QuizPool(); + component.quizPoolMappingComponent = new QuizPoolMappingComponent(alertService); + jest.spyOn(changeDetectorRef.constructor.prototype, 'detectChanges').mockImplementation(); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithNoQuestion').mockReturnValue(false); + jest.spyOn(component.quizPoolMappingComponent, 'hasGroupsWithDifferentQuestionPoints').mockReturnValue(true); + jest.spyOn(component.quizPoolMappingComponent, 'getGroupNamesWithDifferentQuestionPoints').mockReturnValue(['Test Group']); + component.handleUpdate(); + expect(component.invalidReasons).toBeArrayOfSize(1); + expect(component.invalidReasons[0]).toEqual({ + translateKey: 'artemisApp.quizPool.invalidReasons.groupHasDifferentQuestionPoints', + translateValues: { + name: 'Test Group', + }, + }); + }); +}); diff --git a/src/test/javascript/spec/service/quiz-pool.service.spec.ts b/src/test/javascript/spec/service/quiz-pool.service.spec.ts new file mode 100644 index 000000000000..049a8be08df1 --- /dev/null +++ b/src/test/javascript/spec/service/quiz-pool.service.spec.ts @@ -0,0 +1,49 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { QuizPoolService } from 'app/exercises/quiz/manage/quiz-pool.service'; +import { ArtemisTestModule } from '../test.module'; +import { QuizPool } from 'app/entities/quiz/quiz-pool.model'; +import { firstValueFrom } from 'rxjs'; + +describe('QuizPoolService', () => { + let quizPoolService: QuizPoolService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule], + providers: [QuizPoolService], + }); + quizPoolService = TestBed.inject(QuizPoolService); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should return updated quiz pool', async () => { + const updatedQuizPool = new QuizPool(); + updatedQuizPool.id = 1; + const courseId = 2; + const examId = 3; + const quizPool = new QuizPool(); + const response = firstValueFrom(quizPoolService.update(courseId, examId, quizPool)); + const req = httpMock.expectOne({ + method: 'PUT', + url: `api/courses/${courseId}/exams/${examId}/quiz-pools`, + }); + req.flush(updatedQuizPool); + expect((await response)?.body).toEqual(updatedQuizPool); + }); + + it('should return quiz pool', async () => { + const quizPool = new QuizPool(); + quizPool.id = 1; + const courseId = 2; + const examId = 3; + const response = firstValueFrom(quizPoolService.find(courseId, examId)); + const req = httpMock.expectOne({ + method: 'GET', + url: `api/courses/${courseId}/exams/${examId}/quiz-pools`, + }); + req.flush(quizPool); + expect((await response)?.body).toEqual(quizPool); + }); +});