diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index b2201cc36358..34ca1e28321c 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -10,6 +10,8 @@ assessment: athena: - athena + - ai feedback + - request (ai )?feedback - caseSensitive: false atlas: @@ -48,8 +50,6 @@ core: - authority - data export - migration - - user - - group - caseSensitive: false exam: @@ -79,20 +79,19 @@ iris: - iris - llm - chatbot - - ai + - \b(? - sh -c ' - cd /app/artemis/src/test/playwright && - chmod 777 /root && - npm ci && - npm run playwright:setup && - npm run playwright:test - ' networks: artemis: diff --git a/docker/playwright-E2E-tests-mysql-localci.yml b/docker/playwright-E2E-tests-mysql-localci.yml index aa501ae75192..cf677b01fec6 100644 --- a/docker/playwright-E2E-tests-mysql-localci.yml +++ b/docker/playwright-E2E-tests-mysql-localci.yml @@ -47,14 +47,6 @@ services: condition: service_healthy environment: PLAYWRIGHT_DB_TYPE: 'MySQL' - command: > - sh -c ' - cd /app/artemis/src/test/playwright && - chmod 777 /root && - npm ci && - npm run playwright:setup && - npm run playwright:test - ' networks: artemis: diff --git a/docker/playwright-E2E-tests-mysql.yml b/docker/playwright-E2E-tests-mysql.yml index c269ce9b206e..4b9f212ba2e2 100644 --- a/docker/playwright-E2E-tests-mysql.yml +++ b/docker/playwright-E2E-tests-mysql.yml @@ -44,14 +44,6 @@ services: condition: service_healthy environment: PLAYWRIGHT_DB_TYPE: 'MySQL' - command: > - sh -c ' - chmod 777 /root && - cd /app/artemis/src/test/playwright && - npm ci && - npm run playwright:setup && - npm run playwright:test - ' networks: artemis: diff --git a/docker/playwright-E2E-tests-postgres.yml b/docker/playwright-E2E-tests-postgres.yml index 92bde9b2cfe0..d1ca43a7bdcf 100644 --- a/docker/playwright-E2E-tests-postgres.yml +++ b/docker/playwright-E2E-tests-postgres.yml @@ -45,14 +45,6 @@ services: condition: service_healthy environment: PLAYWRIGHT_DB_TYPE: 'Postgres' - command: > - sh -c ' - chmod 777 /root && - cd /app/artemis/src/test/playwright && - npm ci && - npm run playwright:setup && - npm run playwright:test - ' networks: artemis: diff --git a/docker/playwright.yml b/docker/playwright.yml index e6ac4bb3748e..a27ba2f9d589 100644 --- a/docker/playwright.yml +++ b/docker/playwright.yml @@ -22,7 +22,17 @@ services: TEST_TIMEOUT_SECONDS: '${bamboo_test_timeout_seconds}' TEST_RETRIES: '${bamboo_test_retries}' TEST_WORKER_PROCESSES: '${bamboo_test_worker_processes}' - command: sh -c "cd /app/artemis/src/test/playwright && chmod 777 /root && npm ci && npm run playwright:test" + SLOW_TEST_TIMEOUT_SECONDS: '${bamboo_slow_test_timeout_seconds}' + FAST_TEST_TIMEOUT_SECONDS: '${bamboo_fast_test_timeout_seconds}' + command: > + sh -c ' + cd /app/artemis/src/test/playwright && + chmod 777 /root && + npm ci && + npm run playwright:setup && + npm run playwright:test; + rm ./test-reports/results-fast.xml ./test-reports/results-slow.xml + ' volumes: - ..:/app/artemis networks: diff --git a/docs/dev/cypress.rst b/docs/dev/cypress.rst deleted file mode 100644 index c68afc351ec1..000000000000 --- a/docs/dev/cypress.rst +++ /dev/null @@ -1,325 +0,0 @@ -E2E Testing based on Cypress -============================ - -**Background** - -The Cypress test suite contains system tests verifying the most important features of Artemis. -System tests test the whole system and therefore require a complete deployment of Artemis first. -In order to prevent as many faults (bugs) as possible from being introduced into the develop branch, -we want to execute the Cypress test suite whenever new commits are pushed to a Git branch -(just like the unit and integration test suites). - -To accomplish this we need to be able to dynamically deploy multiple different instances of Artemis at the same time. -An ideal setup would be to deploy the whole Artemis system using Kubernetes. -However, this setup is too complex at the moment. -The main reason for the complexity is that it is very hard to automatically setup Docker containers for -the external services (e.g. Gitlab, Jenkins) and connect them directly with Artemis. - -Therefore, the current setup only dynamically deploys the Artemis server and configures it to connect to -the prelive system, which is already properly setup in the university data center. - - -Local Cypress Setup -------------------- -Sometimes developers need to set up Cypress locally, in order to debug failing E2E tests or write new tests. -Follow these steps to create your local cypress instance: - -1. Install dependencies - - First head into the cypress folder by using ``cd src/test/cypress``. Now run ``npm install``. - -2. Customize Cypress settings - - To connect cypress to our local Artemis instance, we need to adjust some configurations. - First we need to set the URL or IP of the Artemis instance in the ``cypress.config.ts`` file. - Adjust the ``baseUrl`` setting to fit your setup (e.g. ``baseUrl: 'http://localhost:9000',``) - -3. Adjust user settings - - We also need to adjust the user setting, which will determine the usernames and passwords, that cypress - will use. These settings are located within the ``cypress.env.json`` file. If you use the Atlassian setup, - the file should typically look like this: - - .. code-block:: json - - { - "username": "artemis_test_user_USERID", - "password": "artemis_test_user_USERID", - "adminUsername": "artemis_admin", - "adminPassword": "artemis_admin", - "allowGroupCustomization": true, - "studentGroupName": "students", - "tutorGroupName": "tutors", - "editorGroupName": "editors", - "instructorGroupName": "instructors", - "createUsers": false - } - - The ``USERID`` part will be automatically replaced by different user ids. These are set within the ``support/users.ts`` file. - By default the users 100-106 will be used by Cypress, if these users do not exist on your instance yet set ``createUsers`` to ``true``. - -4. Open Cypress browser - - If you want to use a different browser than chrome, you can set this within the ``package.json`` file - within the cypress subfolder like this ``"cypress:open": "cypress open --browser=edge",``. - To now run the test suites selectively instead of in full, we need to open the cypress - browser, which is by default chrome by running the following command ``npm run cypress:open``. - Now select ``E2E Testing``, followed by ``Start E2E testing in ...``. A new browser window - should open, which should look like this: - - .. figure:: cypress/cypress-open-screenshot.png - :align: center - :alt: Cypress cypress-open-screenshot - - You can now click on any test suite and it should run. - -.. warning:: - **IMPORTANT**: If you run the E2E tests for the first time, always run the ``ImportUsers.ts`` tests first, - since it will create the necessary users. - - -Debug using Sorry Cypress -------------------------- - -Since the E2E tests are sometimes hard to debug, we provide a dashboard, that allows to inspect the -CI run and even watch a video of the UI interaction with Artemis in that run. - -It's based on Sorry Cypress a open source and selfhostable alternative to the paid cypress cloud. - -The dashboard itself can be access here: https://sorry-cypress.ase.cit.tum.de/ - -To access it, you need these basic auth credentials (sorry cypress itself does not provide an auth -system, so we are forced to use nginx basic auth here). You can find these credentials on our confluence page: -https://confluence.ase.in.tum.de/display/ArTEMiS/Sorry+Cypress+Dashboard - -After that you will see the initial dashboard. - -You first have to select a project in the left sidebar (mysql or postgresql): - - .. figure:: cypress/sorry-cypress-dashboard.png - :align: center - :alt: Sorry Cypress dashboard - -Now you get a list of the last runs. In the top right you can enter your branch name to filter the runs. - - .. figure:: cypress/sorry-cypress-runs.png - :align: center - :alt: Sorry Cypress last runs - -The name of the run consists of the branch name followed by the run number. The last part is MySQL or -PostgreSQL depending on the run environment. If you are in the MySQL project, you will of course only see the MySQL runs. - -If you now click on the run, you can see detailed information about the test suites (corresponding -to components within Artemis). For each suite there is information about the run time, the successful/failed/flaky/skipped/ignored tests: - - .. figure:: cypress/sorry-cypress-run.png - :align: center - :alt: Sorry Cypress single run - -If you want to further debug one test suite, just click on it. - - .. figure:: cypress/sorry-cypress-test.png - :align: center - :alt: Sorry Cypress single test - -Here you can see the single tests on the left and a video on the right. This is a screen capture of -the actual run and can tremendously help debug failing E2E tests. - -Sometimes the video can be a little bit to fast to debug easily. Just download the video on your -computer and play it with a video player, that allows you to slow the video down. - -.. note:: - For maintenance reasons videos are deleted after 14 days. So if you have a failing test, debug - it before this period to get access to the video. - - -Best practice when writing new E2E tests ----------------------------------------- - -**Understanding the System and Requirements** - -Before writing tests, a deep understanding of the system and its requirements is crucial. -This understanding guides determining what needs testing and what defines a successful test. -The best way to understand is to consolidate the original system`s developer or a person actively working on this -component. - -**Identify Main Test Scenarios** - -Identify what are the main ways the component is supposed to be used. Try -the action with all involved user roles and test as many different inputs as -feasible. - -**Identify Edge Test Scenarios** - -Next to the main test scenarios, there are also edge case scenarios. These -tests include inputs/actions that are not supposed to be performed (e.g. enter -a too-long input into a field) and test the error-handling capabilities of the -platform. - -**Write Tests as Development Progresses** - -Rather than leaving testing until the end, write tests alongside each piece of -functionality. This approach ensures the code remains testable and makes -identifying and fixing issues as they arise easier. - -**Keep Tests Focused** - -Keep each test focused on one specific aspect of the code. If a test fails, it is -easier to identify the issue when it does not check multiple functionalities at -the same time. - -**Make Tests Independent** - -Tests should operate independently from each other and external factors like -the current date or time. Each test should be isolated. Use API calls for unrelated tasks, such as creating a -course, and UI interaction for the appropriate testing steps. This also involves -setting up a clean environment for every test suite. - -**Use Descriptive Test Names** - -Ensure each test name clearly describes what the test does. This strategy -makes the test suite easier to understand and quickly identifies which test -has failed. - -**Use Similar Test Setups** - -Avoid using different setups for each test suit. For example, always check -for the same HTTP response when deleting a course. - -**Do Not Ignore Failing Tests** - -If a test consistently fails, pay attention to it. Investigate as soon as possible -and fx the issue, or update the test if the requirements have changed. - -**Regularly Review and Refactor Your Tests** - -Tests, like code, can accumulate technical debt. Regular reviews for duplication, -unnecessary complexity, and other issues help maintain tests and enhance reliability. - -**Use HTML IDs instead of classes or other attributes** - -When searching for a single element within the DOM of an HTML page, try to use ID selectors as much as possible. -They are more reliable since there can only be one element with this ID on one single page according to the HTML - - -Artemis Deployment on Bamboo Build Agent ----------------------------------------- -Every execution of the Cypress test suite requires its own deployment of Artemis. -The easiest way to accomplish this is to deploy Artemis locally on the build agent, which executes the Cypress tests. -Using ``docker compose`` we can start a MySQL database and the Artemis server locally on the build agent and -connect it to the prelive system in the university data center. - -.. figure:: cypress/cypress_bamboo_deployment_diagram.svg - :align: center - :alt: Artemis Deployment on Bamboo Build Agent for Cypress - - Artemis Deployment on Bamboo Build Agent for Cypress - -In total there are three Docker containers started in the Bamboo build agent: - -1. MySQL - - This container starts a MySQL database and exposes it on port 3306. - The container automatically creates a new database 'Artemis' and configures it - with the recommended settings for Artemis. - The Cypress setup reuses the already existing - `MySQL docker image `__ - from the standard Artemis Docker setup. - -2. Artemis - - The Docker image for the Artemis container is created from the already existing - `Dockerfile `__. - When the Bamboo build of the Cypress test suite starts, it retrieves the Artemis executable (.war file) - from the `Artemis build plan `_. - Upon creation of the Artemis Docker image the executable is copied into the image together with configuration files - for the Artemis server. - - The main configuration of the Artemis server are contained in the - `Cypress environment configuration files `__. - However, those files do not contain any security relevant information. - Security relevant settings like the credentials to the Jira admin account in the prelive system are instead passed to - the Docker container via environment variables. - This information is accessible to the Bamboo build agent via - `Bamboo plan variables `__. - - The Artemis container is also configured to - `depend on `__ - the MySQL container and uses - `health checks `__ - to wait until the MySQL container is up and running. - -3. Cypress - - Cypress offers a `variety of docker images `__ - to execute Cypress tests. - We use an image which has the Cypress operating system dependencies and a Chrome browser installed. - However, Cypress itself is not installed in - `these images `__. - This is convenient for us because the image is smaller and the Artemis Cypress project requires - additional dependencies to fully function. - Therefore, the Artemis Cypress Docker container is configured to install all dependencies - (using :code:`npm ci`) upon start. This will also install Cypress itself. - Afterwards the Artemis Cypress test suite is executed. - - The necessary configuration for the Cypress test suite is also passed in via environment variables. - Furthermore, the Cypress container depends on the Artemis container and is only started - once Artemis has been fully booted. - -**Bamboo webhook** - -The Artemis instance deployed on the build agent is not publicly available to improve the security of this setup. -However, in order to get the build results for programming exercise submissions Artemis relies on a webhook from Bamboo -to send POST requests to Artemis. -To allow this, an extra rule has been added to the firewall allowing only the Bamboo instance in the prelive system -to connect to the Artemis instance in the build agent. - -**Timing** - -As mentioned above, we want the Cypress test suite to be executed whenever new commits are pushed to a Git branch. -This has been achieved by adding the -`Cypress Github build plan `__ -as a `child dependency `__ -to the `Artemis Build build plan `__. -The *Artemis Build* build plan is triggered whenever a new commit has been pushed to a branch. - -The Cypress build plan is only triggered after a successful build of the Artemis executable. -This does imply a delay (about 10 minutes on average) between the push of new commits and the execution -of the Cypress test suite, since the new Artemis executable first has to be built. - -**NOTE:** The Cypress test suite is only automatically executed for internal branches and pull requests -(requires access to this GitHub repository) **not** for external ones. -In case you need access rights, please contact the maintainer `Stephan Krusche `__. - -Artemis Deployment in Test Environment --------------------------------------- -There is another build plan on Bamboo which executes the Cypress test suite. -`This build plan `__ -deploys the latest Artemis executable of the develop branch on an already configured test environment (test server 3) -and executes the Cypress test suite against it. -This build plan is automatically executed every 8 hours and verifies that test server 3 is working properly. - -.. figure:: cypress/cypress_test_environment_deployment_diagram.svg - :align: center - :alt: Artemis Deployment on test environment for Cypress - - Artemis Deployment on test environment for Cypress - -The difference of this setup is that the Artemis server is deployed on a separate environment which already contains -the necessary configuration files for the Artemis server to connect to the prelive system. -The Docker image for the Cypress container should be exactly the same as the Cypress image used in -the *docker compose* file for the deployment on a Bamboo build agent. - -Maintenance ------------ -The Artemis Dockerfile as well as the MySQL image are already maintained because they are used in -other Artemis Docker setups. -Therefore, only Cypress and the Cypress Docker image require active maintenance. -Since the Cypress test suite simulates a real user, it makes sense to execute the test suite with -the latest Chrome browser. -The Cypress Docker image we use always has a specific Chrome version installed. -Therefore, the -`docker-compose file `__ -as well as the -`build plan configuration for the Cypress tests on test server 3 `__ -should be updated every month to make sure that the latest Cypress image for the Chrome browser is used. diff --git a/docs/dev/guidelines/client-design.rst b/docs/dev/guidelines/client-design.rst index 21f18733fb61..fd0aafc31b59 100644 --- a/docs/dev/guidelines/client-design.rst +++ b/docs/dev/guidelines/client-design.rst @@ -237,7 +237,7 @@ Example: .. code-block:: ts - this.themeService.applyThemeExplicitly(Theme.DARK); + this.themeService.applyThemePreference(Theme.DARK); diff --git a/docs/index.rst b/docs/index.rst index 53808018aaf8..0392e5286433 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -57,7 +57,6 @@ All these exercises are supposed to be run either live in the lecture with insta Guided Tour dev/testservers dev/docker - dev/cypress dev/playwright dev/open-source dev/local-moodle-setup-for-lti diff --git a/docs/requirements.txt b/docs/requirements.txt index fdf26e7ee025..0edd5bbb03b7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,8 +1,9 @@ -Sphinx==7.4.7 -sphinx-rtd-theme==2.0.0 -sphinx-autobuild==2024.9.19 -sphinxcontrib-bibtex==2.6.3 +alabaster==1.0.0 +docutils==0.21.2 requests==2.32.3 -zipp==3.20.2 -docutils==0.20.1 +Sphinx==8.1.3 +sphinx-rtd-theme==3.0.2 +sphinx-autobuild==2024.10.3 +sphinxcontrib-bibtex==2.6.3 urllib3==2.2.3 +zipp==3.21.0 diff --git a/gradle.properties b/gradle.properties index 8e95ead2b4ff..2f2ade9049ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,7 +25,7 @@ jplag_version=5.1.0 # NOTE: we do not need to use the latest version 9.x here as long as Stanford CoreNLP does not reference it lucene_version=8.11.4 slf4j_version=2.0.16 -sentry_version=7.16.0 +sentry_version=7.17.0 liquibase_version=4.30.0 docker_java_version=3.4.0 logback_version=1.5.12 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72b8b91..94113f200e61 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/jest.config.js b/jest.config.js index 6fc625124844..96eeb24f0890 100644 --- a/jest.config.js +++ b/jest.config.js @@ -105,8 +105,8 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 87.67, - branches: 73.81, + statements: 87.66, + branches: 73.79, functions: 82.17, lines: 87.72, }, diff --git a/package-lock.json b/package-lock.json index 30e371368350..4362969bff33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "artemis", - "version": "7.7.1", + "version": "7.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "7.7.1", + "version": "7.7.2", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "18.2.11", - "@angular/cdk": "18.2.12", - "@angular/common": "18.2.11", - "@angular/compiler": "18.2.11", - "@angular/core": "18.2.11", - "@angular/forms": "18.2.11", - "@angular/localize": "18.2.11", - "@angular/material": "18.2.12", - "@angular/platform-browser": "18.2.11", - "@angular/platform-browser-dynamic": "18.2.11", - "@angular/router": "18.2.11", - "@angular/service-worker": "18.2.11", + "@angular/animations": "18.2.12", + "@angular/cdk": "18.2.13", + "@angular/common": "18.2.12", + "@angular/compiler": "18.2.12", + "@angular/core": "18.2.12", + "@angular/forms": "18.2.12", + "@angular/localize": "18.2.12", + "@angular/material": "18.2.13", + "@angular/platform-browser": "18.2.12", + "@angular/platform-browser-dynamic": "18.2.12", + "@angular/router": "18.2.12", + "@angular/service-worker": "18.2.12", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -33,9 +33,9 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "16.0.3", "@ngx-translate/http-loader": "16.0.0", - "@sentry/angular": "8.37.1", + "@sentry/angular": "8.38.0", "@siemens/ngx-datatable": "22.4.1", - "@swimlane/ngx-charts": "20.5.0", + "@swimlane/ngx-charts": "21.0.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", "@vscode/markdown-it-katex": "1.1.0", @@ -45,7 +45,7 @@ "crypto-js": "4.2.0", "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", - "dompurify": "3.1.7", + "dompurify": "3.2.0", "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -66,7 +66,7 @@ "papaparse": "5.4.1", "pdf-lib": "1.17.1", "pdfjs-dist": "4.8.69", - "posthog-js": "1.181.0", + "posthog-js": "1.186.0", "rxjs": "7.8.1", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", @@ -75,23 +75,23 @@ "ts-cacheable": "1.0.10", "tslib": "2.8.1", "turndown": "7.2.0", - "uuid": "11.0.2", + "uuid": "11.0.3", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zone.js": "0.14.10" }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.11", + "@angular-devkit/build-angular": "18.2.12", "@angular-eslint/builder": "18.4.0", "@angular-eslint/eslint-plugin": "18.4.0", "@angular-eslint/eslint-plugin-template": "18.4.0", "@angular-eslint/schematics": "18.4.0", "@angular-eslint/template-parser": "18.4.0", - "@angular/cli": "18.2.11", - "@angular/compiler-cli": "18.2.11", - "@angular/language-service": "18.2.11", - "@sentry/types": "8.37.1", + "@angular/cli": "18.2.12", + "@angular/compiler-cli": "18.2.12", + "@angular/language-service": "18.2.12", + "@sentry/types": "8.38.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -105,8 +105,8 @@ "@types/sockjs-client": "1.5.4", "@types/turndown": "5.0.5", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.13.0", - "@typescript-eslint/parser": "8.13.0", + "@typescript-eslint/eslint-plugin": "8.14.0", + "@typescript-eslint/parser": "8.14.0", "eslint": "9.14.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -127,7 +127,7 @@ "ngxtension": "4.1.0", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.80.6", + "sass": "1.81.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -218,13 +218,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.11.tgz", - "integrity": "sha512-p+XIc/j51aI83ExNdeZwvkm1F4wkuKMGUUoj0MVUUi5E6NoiMlXYm6uU8+HbRvPBzGy5+3KOiGp3Fks0UmDSAA==", + "version": "0.1802.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.12.tgz", + "integrity": "sha512-bepVb2/GtJppYKaeW8yTGE6egmoWZ7zagFDsmBdbF+BYp+HmeoPsclARcdryBPVq68zedyTRdvhWSUTbw1AYuw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.11", + "@angular-devkit/core": "18.2.12", "rxjs": "7.8.1" }, "engines": { @@ -234,17 +234,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.11.tgz", - "integrity": "sha512-09Ln3NAdlMw/wMLgnwYU5VgWV5TPBEHolZUIvE9D8b6SFWBCowk3B3RWeAMgg7Peuf9SKwqQHBz2b1C7RTP/8g==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.12.tgz", + "integrity": "sha512-quVUi7eqTq9OHumQFNl9Y8t2opm8miu4rlYnuF6rbujmmBDvdUvR6trFChueRczl2p5HWqTOr6NPoDGQm8AyNw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.11", - "@angular-devkit/build-webpack": "0.1802.11", - "@angular-devkit/core": "18.2.11", - "@angular/build": "18.2.11", + "@angular-devkit/architect": "0.1802.12", + "@angular-devkit/build-webpack": "0.1802.12", + "@angular-devkit/core": "18.2.12", + "@angular/build": "18.2.12", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -255,7 +255,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.11", + "@ngtools/webpack": "18.2.12", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -362,6 +362,13 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular-devkit/build-angular/node_modules/sass": { "version": "1.77.6", "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", @@ -388,13 +395,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.11.tgz", - "integrity": "sha512-G76rNsyn1iQk7qjyr+K4rnDzfalmEswmwXQorypSDGaHYzIDY1SZXMoP4225WMq5fJNBOJrk82FA0PSfnPE+zQ==", + "version": "0.1802.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.12.tgz", + "integrity": "sha512-0Z3fdbZVRnjYWE2/VYyfy+uieY+6YZyEp4ylzklVkc+fmLNsnz4Zw6cK1LzzcBqAwKIyh1IdW20Cg7o8b0sONA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.11", + "@angular-devkit/architect": "0.1802.12", "rxjs": "7.8.1" }, "engines": { @@ -408,9 +415,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.11.tgz", - "integrity": "sha512-H9P1shRGigORWJHUY2BRa2YurT+DVminrhuaYHsbhXBRsPmgB2Dx/30YLTnC1s5XmR9QIRUCsg/d3kyT1wd5Zg==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.12.tgz", + "integrity": "sha512-NtB6ypsaDyPE6/fqWOdfTmACs+yK5RqfH5tStEzWFeeDsIEDYKsJ06ypuRep7qTjYus5Rmttk0Ds+cFgz8JdUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -436,13 +443,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.11.tgz", - "integrity": "sha512-efRK3FotTFp4KD5u42jWfXpHUALXB9kJNsWiB4wEImKFH6CN+vjBspJQuLqk2oeBFh/7D2qRMc5P+2tZHM5hdw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.12.tgz", + "integrity": "sha512-mMea9txHbnCX5lXLHlo0RAgfhFHDio45/jMsREM2PA8UtVf2S8ltXz7ZwUrUyMQRv8vaSfn4ijDstF4hDMnRgQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.11", + "@angular-devkit/core": "18.2.12", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -556,9 +563,9 @@ } }, "node_modules/@angular/animations": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.11.tgz", - "integrity": "sha512-ghgXa2VhtyJJnTMuH2NYxCMsveQbZno44AZGygPqrcW8UQMQe9GulFaTXCH5s6/so2CLy2ZviIwSZQRgK0ZlDw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.12.tgz", + "integrity": "sha512-XcWH/VFQ1Rddhdqi/iU8lW3Qg96yVx1NPfrO5lhcSSvVUzYWTZ5r+jh3GqYqUgPWyEp1Kpw3FLsOgVcGcBWQkQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -567,18 +574,18 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.11" + "@angular/core": "18.2.12" } }, "node_modules/@angular/build": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.11.tgz", - "integrity": "sha512-AgirvSCmqUKiDE3C0rl3JA68OkOqQWDKUvjqRHXCkhxldLVOVoeIl87+jBYK/v9gcmk+K+ju+5wbGEfu1FjhiQ==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.12.tgz", + "integrity": "sha512-4Ohz+OSILoL+cCAQ4UTiCT5v6pctu3fXNoNpTEUK46OmxELk9jDITO5rNyNS7TxBn9wY69kjX5VcDf7MenquFQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.11", + "@angular-devkit/architect": "0.1802.12", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -639,6 +646,13 @@ } } }, + "node_modules/@angular/build/node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular/build/node_modules/sass": { "version": "1.77.6", "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", @@ -658,9 +672,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.12", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.12.tgz", - "integrity": "sha512-FOklA6KatPtb0yO0doRhBI/UVY23A8ZhOSws5VuZTQl/6r/jXEXGV9n5JQj4rm8t/6IrReO55hdyw9XfHfZFjQ==", + "version": "18.2.13", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.13.tgz", + "integrity": "sha512-yBKoqcOwmwXnc5phFMEEMO130/Bz9beQLJrKzIS87f6TXaGCeBs4xrPHq2i7Xx/2TqvMiOD9ucjmlVbtGvNG3w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -675,18 +689,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.11.tgz", - "integrity": "sha512-0JI1xjOLRemBPjdT/yVlabxc3Zkjqa/lhvVxxVC1XhKoW7yGxIGwNrQ4pka4CcQtCuktO6KPMmTGIu8YgC3cpw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.12.tgz", + "integrity": "sha512-xhuZ/b7IhqNw1MgXf+arWf4x+GfUSt/IwbdWU4+CO8A7h0Y46zQywouP/KUK3cMQZfVdHdciTBvlpF3vFacA6Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.11", - "@angular-devkit/core": "18.2.11", - "@angular-devkit/schematics": "18.2.11", + "@angular-devkit/architect": "0.1802.12", + "@angular-devkit/core": "18.2.12", + "@angular-devkit/schematics": "18.2.12", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.11", + "@schematics/angular": "18.2.12", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -709,9 +723,9 @@ } }, "node_modules/@angular/common": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.11.tgz", - "integrity": "sha512-bamJeISl2zUlvjPYebQWazUjhjXU9nrot42cQJng94SkvNENT9LTWfPYgc+Bd972Kg+31jG4H41rgFNs7zySmw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.12.tgz", + "integrity": "sha512-gI5o8Bccsi8ow8Wk2vG4Tw/Rw9LoHEA9j8+qHKNR/55SCBsz68Syg310dSyxy+sApJO2WiqIadr5VP36dlSUFw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -720,14 +734,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.11", + "@angular/core": "18.2.12", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.11.tgz", - "integrity": "sha512-PSVL1YXUhTzkgJNYXiWk9eAZxNV6laQJRGdj9++C1q9m2S9/GlehZGzkt5GtC5rlUweJucCNvBC1+2D5FAt9vA==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.12.tgz", + "integrity": "sha512-D5d5dLrjQal5DbAXJJNSsCC3UxzjOI2wbc+Iv+LOpRM1gpNwuYfZMX5W7cj62Ce4G2++78CJSppdKBp8D4HErQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -736,7 +750,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.11" + "@angular/core": "18.2.12" }, "peerDependenciesMeta": { "@angular/core": { @@ -745,9 +759,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.11.tgz", - "integrity": "sha512-YJlAOiXZUYP6/RK9isu5AOucmNZhFB9lpY/beMzkkWgDku+va8szm4BZbLJFz176IUteyLWF3IP4aE7P9OBlXw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.12.tgz", + "integrity": "sha512-IWimTNq5Q+i2Wxev6HLqnN4iYbPvLz04W1BBycT1LfGUsHcjFYLuUqbeUzHbk2snmBAzXkixgVpo8SF6P4Y5Pg==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -768,7 +782,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.11", + "@angular/compiler": "18.2.12", "typescript": ">=5.4 <5.6" } }, @@ -801,9 +815,9 @@ } }, "node_modules/@angular/core": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.11.tgz", - "integrity": "sha512-/AGAFyZN8KR+kW5FUFCCBCj3qHyDDum7G0lJe5otrT9AqF6+g7PjF8yLha/6wPkJG7ri5xGLhini1sEivVeq/g==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.12.tgz", + "integrity": "sha512-wCf/OObwS6bpM60rk6bpMpCRGp0DlMLB1WNAMtfcaPNyqimVV5Bm98mWRhkOuRyvU3fU7iHhM/10ePVaoyu9+A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -817,9 +831,9 @@ } }, "node_modules/@angular/forms": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.11.tgz", - "integrity": "sha512-QjxayOxDTqsTJGBzfWd3nms1LZIXj2f1+wIPxxUNXyNS5ZaM7hBWkz2BTFYeewlD/HdNj0alNVCYK3M8ElLWYw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.12.tgz", + "integrity": "sha512-FsukBJEU6jfAmht7TrODTkct/o4iwCZvGozuThOp0tYUPD/E1rZZzuKjEyTnT5Azpfkf0Wqx1nmpz80cczELOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -828,16 +842,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.11", - "@angular/core": "18.2.11", - "@angular/platform-browser": "18.2.11", + "@angular/common": "18.2.12", + "@angular/core": "18.2.12", + "@angular/platform-browser": "18.2.12", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.11.tgz", - "integrity": "sha512-kI36Wfvw3E01Xox/H535/rrSTiDfzQeXATFR5i5vqc94XWUdQG67e4X6ybnqFUrezXoLPTULHp+5Di896YFPzw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.12.tgz", + "integrity": "sha512-oaiVAnGzmPZvrXdGh8XnosaqfEPbZxO2225MxbbrD49XTqUgpaS2zrz1Uf5j42e8qytA2kj8tckLq7PAMm0D1w==", "dev": true, "license": "MIT", "engines": { @@ -845,9 +859,9 @@ } }, "node_modules/@angular/localize": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.11.tgz", - "integrity": "sha512-ZGemNURZmhZcZhc0i4SzAjyckkvf6Xv24U7DDJ/TpgHQWP9/pu5QExFa2OuGoJJcZRqUrzEmPrbu+4a/xggaQw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.12.tgz", + "integrity": "sha512-qC3cYFh3miR9revmHGlfbGvugcsK6nQud4QKBNyTUp1XZRrEE0yzPvvsnmbv2lHUOazrvTxQpfVZZKpiifgoLw==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -864,21 +878,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.11", - "@angular/compiler-cli": "18.2.11" + "@angular/compiler": "18.2.12", + "@angular/compiler-cli": "18.2.12" } }, "node_modules/@angular/material": { - "version": "18.2.12", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.12.tgz", - "integrity": "sha512-5q8Os6i3D1e3qN+RqP95UgIR+Kx3goncSSYDeT6yPNrdrcqcWdyDPXGK6UsZqTTx/CJee/I7ZxgVVK1YDoVASQ==", + "version": "18.2.13", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.13.tgz", + "integrity": "sha512-Gxyyo6G+IXJwgf6zDTjPfFJ2PnjC2YXWKGkKKG2oR0jfiYiovDvNR4oXxhsztTwkaxLwck/gscoVTSQXMkU5fg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.12", + "@angular/cdk": "18.2.13", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -887,9 +901,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.11.tgz", - "integrity": "sha512-bzcP0QdPT/ncTxOx0t7901z5m0wDmkraTo/es4g8reV6VK9Ptv0QDuD8aDvrHh7sLCX5VgwDF9ohc6S2TpYUCA==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.12.tgz", + "integrity": "sha512-DRSMznuxuecrs+v5BRyd60/R4vjkQtuYUEPfzdo+rqxM83Dmr3PGtnqPRgd5oAFUbATxf02hQXijRD27K7rZRg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -898,9 +912,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.11", - "@angular/common": "18.2.11", - "@angular/core": "18.2.11" + "@angular/animations": "18.2.12", + "@angular/common": "18.2.12", + "@angular/core": "18.2.12" }, "peerDependenciesMeta": { "@angular/animations": { @@ -909,9 +923,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.11.tgz", - "integrity": "sha512-a30U4ZdTZSvL17xWwOq6xh9ToCDP2K7/j1HTJFREObbuAtZTa/6IVgBUM6oOMNQ43kHkT6Mr9Emkgf9iGtWwfw==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.12.tgz", + "integrity": "sha512-dv1QEjYpcFno6+oUeGEDRWpB5g2Ufb0XkUbLJQIgrOk1Qbyzb8tmpDpTjok8jcKdquigMRWolr6Y1EOicfRlLw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -920,16 +934,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.11", - "@angular/compiler": "18.2.11", - "@angular/core": "18.2.11", - "@angular/platform-browser": "18.2.11" + "@angular/common": "18.2.12", + "@angular/compiler": "18.2.12", + "@angular/core": "18.2.12", + "@angular/platform-browser": "18.2.12" } }, "node_modules/@angular/router": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.11.tgz", - "integrity": "sha512-xh4+t4pNBWxeH1a6GIoEGVSRZO4NDKK8q6b+AzB5GBgKsYgOz2lc74RXIPA//pK3aHrS9qD4sJLlodwgE/1+bA==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.12.tgz", + "integrity": "sha512-cz/1YWOZadAT35PPPYmpK3HSzKOE56nlUHue5bFkw73VSZr2iBn03ALLpd9YKzWgRmx3y7DqnlQtCkDu9JPGKQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -938,16 +952,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.11", - "@angular/core": "18.2.11", - "@angular/platform-browser": "18.2.11", + "@angular/common": "18.2.12", + "@angular/core": "18.2.12", + "@angular/platform-browser": "18.2.12", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.11.tgz", - "integrity": "sha512-FZ1yHCAmmbg+NYNFtvrZE8RzgsSnWgsL2ef+mvlfC/fxyu4pyoZT4+ZshwN7k55L++6M/RgdV7cZevPN4qGNrA==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.12.tgz", + "integrity": "sha512-rgztA+Eduo69y6cvSDtAXC5lMTWjgowSSreiyM4ssyjwd8vD6h2TZp/3slr8Tt6+Lh9J4bK+UdcqMIjIdDxwSw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -959,8 +973,8 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.11", - "@angular/core": "18.2.11" + "@angular/common": "18.2.12", + "@angular/core": "18.2.12" } }, "node_modules/@babel/code-frame": { @@ -1146,9 +1160,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -3567,9 +3581,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz", - "integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", + "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3820,9 +3834,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.7.tgz", - "integrity": "sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", "dev": true, "license": "MIT", "engines": { @@ -5024,9 +5038,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.11.tgz", - "integrity": "sha512-iTdUGJ5O7yMm1DyCzyoMDMxBJ68emUSSXPWbQzEEdcqmtifRebn+VAq4vHN8OmtGM1mtuKeLEsbiZP8ywrw7Ug==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.12.tgz", + "integrity": "sha512-FFJAwtWbtpncMOVNuULPBwFJB7GSjiUwO93eGTzRp8O4EPQ8lCQeFbezQm/NP34+T0+GBLGzPSuQT+muob8YKw==", "dev": true, "license": "MIT", "engines": { @@ -5353,9 +5367,9 @@ } }, "node_modules/@nx/devkit": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.0.12.tgz", - "integrity": "sha512-HsaDoAmzLPE2vHal2eNYvH7x6NCfHjUblm8WDD12Q/uCdTBvDTZqd7P+bukEH+2FhY89Dn/1fy59vKkA+rcB/g==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-20.1.1.tgz", + "integrity": "sha512-sqihJhJQERCTl0KmKmpRFxWxuTnH8yRqdo8T5uGGaHzTNiMdIp5smTF2dBs7/OMkZDxcJc4dKvcFWfreZr8XNw==", "dev": true, "license": "MIT", "dependencies": { @@ -5399,9 +5413,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.0.12.tgz", - "integrity": "sha512-iwEDUTKx0n2S6Nz9gc9ShrfBw0MG87U0YIu2x/09tKOSkcsw90QKy54qN/6WNoFIE41Kt3U+dYtWi+NdLRE9kw==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.1.1.tgz", + "integrity": "sha512-Ah0ShPQaMfvzVfhsyuI6hNB0bmwLHJqqrWldZeF97SFPhv6vfKdcdlZmSnask+V4N5z9TOCUmCMu2asMQa7+kw==", "cpu": [ "arm64" ], @@ -5416,9 +5430,9 @@ } }, "node_modules/@nx/nx-darwin-x64": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.0.12.tgz", - "integrity": "sha512-JYFNf0yPReejaooQAAIMsjWDGENT777wDXj45e7JQUMM4t6NOMpGBj4qUFyc6a/jXT+/bCGEj4N7VDZDZiogGA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-20.1.1.tgz", + "integrity": "sha512-TmdX6pbzclvPGsttTTaZhdF46HV1vfvYSHJaSMsYJX68l3gcQnAJ1ZRDksEgkYeAy+O9KrPimD84NM5W/JvqcQ==", "cpu": [ "x64" ], @@ -5433,9 +5447,9 @@ } }, "node_modules/@nx/nx-freebsd-x64": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.0.12.tgz", - "integrity": "sha512-892n8o7vxdmE7pol3ggV78YHlP25p6Y/Z2x69nnC3BBTpWmesyd6lbEmamANofD5KcKCmT1HquC3m6rCT7akHw==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.1.1.tgz", + "integrity": "sha512-7/7f3GbUbdvtTFOb/8wcaSQYkhVIxcC4UzFJM5yEyXPJmIrglk+RX3SLuOFRBFJnO+Z7D6jLUnLOBHKCGfqLVw==", "cpu": [ "x64" ], @@ -5450,9 +5464,9 @@ } }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.0.12.tgz", - "integrity": "sha512-ZPcdYIVAc5JMtmvroJOloI9CJgtwBOGr7E7mO1eT44zs5av0j/QMIj6GSDdvJ7fx+I7TmT4mDiu3s6rLO+/JjA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.1.1.tgz", + "integrity": "sha512-VxpMz5jCZ5gnk1gP2jDBCheYs7qOwQoJmzGbEB8hNy0CwRH/G8pL4RRo4Sz+4aiF6Z+9eax5RM2/Syh+bS0uJw==", "cpu": [ "arm" ], @@ -5467,9 +5481,9 @@ } }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.0.12.tgz", - "integrity": "sha512-TadGwwUKS5WQg2YOMb2WuuVG1k14miSdB9qJOcAX5MGdOiQ1fpV00ph+kMWZSsCCo6N7sKxmvXXXdsUUFSDGjg==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.1.1.tgz", + "integrity": "sha512-8T2+j4KvsWb6ljW1Y2s/uCSt4Drtlsr3GSrGdvcETW0IKaTfKZAJlxTLAWQHEF88hP6GAJRGxNrgmUHMr8HwUA==", "cpu": [ "arm64" ], @@ -5484,9 +5498,9 @@ } }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.0.12.tgz", - "integrity": "sha512-EE2HQjgY87/s9+PQ27vbYyDEXFZ4Qot+O8ThVDVuMI/2dosmWs6C4+YEm3VYG+CT31MVwe/vHKXbDlZgkROMuA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.1.1.tgz", + "integrity": "sha512-TI964w+HFUqG6elriKwQPRX7QRxVRMz5YKdNPgf4+ab4epQ379kwJQEHlyOHR72ir8Tl46z3BoPjvmaLylrT4Q==", "cpu": [ "arm64" ], @@ -5501,9 +5515,9 @@ } }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.0.12.tgz", - "integrity": "sha512-gITJ2g6dH2qvGrI2CHHRyd3soVrJyQQGkqtJnWq04ge+YDy/KniXR2ThQ93LI/QLAxKrKOe3qmIIaNdcdDYnjA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.1.1.tgz", + "integrity": "sha512-Sg2tQ0v3KP9cAqQST16YR+dT/NbirPts6by+A4vhOtaBrZFVqm9P89K9UdcJf4Aj1CaGbs84lotp2aM4E4bQPA==", "cpu": [ "x64" ], @@ -5518,9 +5532,9 @@ } }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.0.12.tgz", - "integrity": "sha512-vOoCrjL44nFZ5N8a4UAIYELnf/tq1dRaLEhSV+P0hKTEtwONj4k8crfU/2HifG1iU7p3AWJLEyaddMoINhB/2g==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.1.1.tgz", + "integrity": "sha512-ekKvuIMRJRhZnkWIWEr4TRVEAyKVDgEMwqk83ilB0Mqpj2RoOKbw7jZFvWcxJWI4kSeZjTea3xCWGNPa1GfCww==", "cpu": [ "x64" ], @@ -5535,9 +5549,9 @@ } }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.0.12.tgz", - "integrity": "sha512-gKdaul23bdRnh493iAd6pSLPSW54VBuEv2zPL86cgprLOcEZiGM5BLJWQguKHCib6dYKaIP4CUIs7i7vhEID+A==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.1.1.tgz", + "integrity": "sha512-JRycFkk6U8A1sXaDmSFA2HMKT2js3HK/+nI+auyITRqVbV79/r6ir/oFSgIjKth8j/vVbGDL8I4E3nEQ7leZYw==", "cpu": [ "arm64" ], @@ -5552,9 +5566,9 @@ } }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.0.12.tgz", - "integrity": "sha512-R1pz4kAG0Ok0EDxXhHwKM3ZZcK2nLycuR9SDrq2Ldp2knvbFf4quSjWyAQaiofJXo179+noa7o5tZDZbNjBYMw==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.1.1.tgz", + "integrity": "sha512-VwxmJU7o8KqTZ+KYk7atoWOUykKd8D4hdgKqqltdq/UBfsAWD/JCFt5OB/VFvrGDbK6I6iKpMvXWlHy4gkXQiw==", "cpu": [ "x64" ], @@ -5904,6 +5918,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", "dependencies": { "pako": "^1.0.6" } @@ -5912,6 +5927,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", "dependencies": { "pako": "^1.0.10" } @@ -6232,14 +6248,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.11.tgz", - "integrity": "sha512-jT54mc9+hPOwie9bji/g2krVuK1kkNh2PNFGwfgCg3Ofmt3hcyOBai1DKuot5uLTX4VCCbvfwiVR/hJniQl2SA==", + "version": "18.2.12", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.12.tgz", + "integrity": "sha512-sIoeipsisK5eTLW3XuNZYcal83AfslBbgI7LnV+3VrXwpasKPGHwo2ZdwhCd2IXAkuJ02Iyu7MyV0aQRM9i/3g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.11", - "@angular-devkit/schematics": "18.2.11", + "@angular-devkit/core": "18.2.12", + "@angular-devkit/schematics": "18.2.12", "jsonc-parser": "3.3.1" }, "engines": { @@ -6249,73 +6265,73 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.37.1.tgz", - "integrity": "sha512-OSR/V5GCsSCG7iapWtXCT/y22uo3HlawdEgfM1NIKk1mkP15UyGQtGEzZDdih2H+SNuX1mp9jQLTjr5FFp1A5w==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.38.0.tgz", + "integrity": "sha512-5QMVcssrAcmjKT0NdFYcX0b0wwZovGAZ9L2GajErXtHkBenjI2sgR2+5J7n+QZGuk2SC1qhGmT1O9i3p3UEwew==", "license": "MIT", "dependencies": { - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.37.1.tgz", - "integrity": "sha512-Se25NXbSapgS2S+JssR5YZ48b3OY4UGmAuBOafgnMW91LXMxRNWRbehZuNUmjjHwuywABMxjgu+Yp5uJDATX+g==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.38.0.tgz", + "integrity": "sha512-AW5HCCAlc3T1jcSuNhbFVNO1CHyJ5g5tsGKEP4VKgu+D1Gg2kZ5S2eFatLBUP/BD5JYb1A7p6XPuzYp1XfMq0A==", "license": "MIT", "dependencies": { - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.37.1.tgz", - "integrity": "sha512-E/Plhisk/pXJjOdOU12sg8m/APTXTA21iEniidP6jW3/+O0tD/H/UovEqa4odNTqxPMa798xHQSQNt5loYiaLA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.38.0.tgz", + "integrity": "sha512-mQPShKnIab7oKwkwrRxP/D8fZYHSkDY+cvqORzgi+wAwgnunytJQjz9g6Ww2lJu98rHEkr5SH4V4rs6PZYZmnQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.37.1", - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry-internal/browser-utils": "8.38.0", + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.37.1.tgz", - "integrity": "sha512-1JLAaPtn1VL5vblB0BMELFV0D+KUm/iMGsrl4/JpRm0Ws5ESzQl33DhXVv1IX/ZAbx9i14EjR7MG9+Hj70tieQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.38.0.tgz", + "integrity": "sha512-OxmlWzK9J8mRM+KxdSnQ5xuxq+p7TiBzTz70FT3HltxmeugvDkyp6803UcFqHOPHR35OYeVLOalym+FmvNn9kw==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.37.1", - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry-internal/replay": "8.38.0", + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.37.1.tgz", - "integrity": "sha512-N6IdxEUwVlB5qqd7UR0fiEvWoJrNA4rcdKot0W9uN3G9lqmff5EB3EUIvw9xFZJgZ695WNVZ1f+irvqXt+rYJA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.38.0.tgz", + "integrity": "sha512-FBeokQllQwFArdtQ8OMIHatIa1MOj3nJEQjHCuuUgK4ys0vpX/ithPuHU1lEpd1qkUGUnHYHyjjQW6QLY3whwg==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.37.1", - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1", + "@sentry/browser": "8.38.0", + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0", "tslib": "^2.4.1" }, "engines": { @@ -6329,52 +6345,52 @@ } }, "node_modules/@sentry/browser": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.37.1.tgz", - "integrity": "sha512-5ym+iGiIpjIKKpMWi9S3/tXh9xneS+jqxwRTJqed3cb8i4ydfMAAP8sM3U8xMCWWABpWyIUW+fpewC0tkhE1aQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.38.0.tgz", + "integrity": "sha512-AZR+b0EteNZEGv6JSdBD22S9VhQ7nrljKsSnzxobBULf3BpwmhmCzTbDrqWszKDAIDYmL+yQJIR2glxbknneWQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.37.1", - "@sentry-internal/feedback": "8.37.1", - "@sentry-internal/replay": "8.37.1", - "@sentry-internal/replay-canvas": "8.37.1", - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry-internal/browser-utils": "8.38.0", + "@sentry-internal/feedback": "8.38.0", + "@sentry-internal/replay": "8.38.0", + "@sentry-internal/replay-canvas": "8.38.0", + "@sentry/core": "8.38.0", + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.37.1.tgz", - "integrity": "sha512-82csXby589iDupM3VgCHJeWZagUyEEaDnbFcoZ/Z91QX2Sjq8FcF5OsforoXjw09i0XTFqlkFAnQVpDBmMXcpQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.38.0.tgz", + "integrity": "sha512-sGD+5TEHU9G7X7zpyaoJxpOtwjTjvOd1f/MKBrWW2vf9UbYK+GUJrOzLhMoSWp/pHSYgvObkJkDb/HwieQjvhQ==", "license": "MIT", "dependencies": { - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/types": "8.38.0", + "@sentry/utils": "8.38.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.37.1.tgz", - "integrity": "sha512-ryMOTROLSLINKFEbHWvi7GigNrsQhsaScw2NddybJGztJQ5UhxIGESnxGxWCufBmWFDwd7+5u0jDPCVUJybp7w==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.38.0.tgz", + "integrity": "sha512-fP5H9ZX01W4Z/EYctk3mkSHi7d06cLcX2/UWqwdWbyPWI+pL2QpUPICeO/C+8SnmYx//wFj3qWDhyPCh1PdFAA==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/utils": { - "version": "8.37.1", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.37.1.tgz", - "integrity": "sha512-Qtn2IfpII12K17txG/ZtTci35XYjYi4CxbQ3j7nXY7toGv/+MqPXwV5q2i9g94XaSXlE5Wy9/hoCZoZpZs/djA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-3X7MgIKIx+2q5Al7QkhaRB4wV6DvzYsaeIwdqKUzGLuRjXmNgJrLoU87TAwQRmZ6Wr3IoEpThZZMNrzYPXxArw==", "license": "MIT", "dependencies": { - "@sentry/types": "8.37.1" + "@sentry/types": "8.38.0" }, "engines": { "node": ">=14.18" @@ -6516,36 +6532,47 @@ } }, "node_modules/@swimlane/ngx-charts": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.5.0.tgz", - "integrity": "sha512-PNBIHdu/R3ceD7jnw1uCBVOj4k3T6IxfdW6xsDsglGkZyoWMEEq4tLoEurjLEKzmDtRv9c35kVNOXy0lkOuXeA==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-21.0.0.tgz", + "integrity": "sha512-4YQNWevbVPekiuLz6w3wLdJY9rD2Pk21xskTUtfpUirUFXdkKZdUByJkSUlup+F8UPvkeZIEC5bhBtOr0yTktA==", "license": "MIT", "dependencies": { - "d3-array": "^3.1.1", + "d3-array": "^3.2.0", "d3-brush": "^3.0.0", "d3-color": "^3.1.0", "d3-ease": "^3.0.1", "d3-format": "^3.1.0", - "d3-hierarchy": "^3.1.0", + "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", + "d3-time-format": "^4.1.0", "d3-transition": "^3.0.1", - "rfdc": "^1.3.0", - "tslib": "^2.0.0" + "tslib": "^2.3.1" }, "peerDependencies": { - "@angular/animations": ">=12.0.0", - "@angular/cdk": ">=12.0.0", - "@angular/common": ">=12.0.0", - "@angular/core": ">=12.0.0", - "@angular/forms": ">=12.0.0", - "@angular/platform-browser": ">=12.0.0", - "@angular/platform-browser-dynamic": ">=12.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "@angular/animations": "17.x || 18.x", + "@angular/cdk": "17.x || 18.x", + "@angular/common": "17.x || 18.x", + "@angular/core": "17.x || 18.x", + "@angular/forms": "17.x || 18.x", + "@angular/platform-browser": "17.x || 18.x", + "@angular/platform-browser-dynamic": "17.x || 18.x", + "rxjs": "7.x" + } + }, + "node_modules/@swimlane/ngx-charts/node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, "node_modules/@swimlane/ngx-graph": { @@ -6584,12 +6611,6 @@ "internmap": "^1.0.0" } }, - "node_modules/@swimlane/ngx-graph/node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", - "license": "BSD-3-Clause" - }, "node_modules/@swimlane/ngx-graph/node_modules/d3-ease": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", @@ -6639,12 +6660,6 @@ "d3-array": "2" } }, - "node_modules/@swimlane/ngx-graph/node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" - }, "node_modules/@swimlane/ngx-graph/node_modules/internmap": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", @@ -7272,17 +7287,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz", - "integrity": "sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.14.0.tgz", + "integrity": "sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/type-utils": "8.13.0", - "@typescript-eslint/utils": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/type-utils": "8.14.0", + "@typescript-eslint/utils": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7306,16 +7321,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.13.0.tgz", - "integrity": "sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.14.0.tgz", + "integrity": "sha512-2p82Yn9juUJq0XynBXtFCyrBDb6/dJombnz6vbo6mgQEtWHfvHbQuEa9kAOVIt1c9YFwi7H6WxtPj1kg+80+RA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/typescript-estree": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/typescript-estree": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", "debug": "^4.3.4" }, "engines": { @@ -7335,14 +7350,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.13.0.tgz", - "integrity": "sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz", + "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0" + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7353,14 +7368,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.13.0.tgz", - "integrity": "sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.14.0.tgz", + "integrity": "sha512-Xcz9qOtZuGusVOH5Uk07NGs39wrKkf3AxlkK79RBK6aJC1l03CobXjJbwBPSidetAOV+5rEVuiT1VSBUOAsanQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.13.0", - "@typescript-eslint/utils": "8.13.0", + "@typescript-eslint/typescript-estree": "8.14.0", + "@typescript-eslint/utils": "8.14.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7378,9 +7393,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.13.0.tgz", - "integrity": "sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz", + "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==", "dev": true, "license": "MIT", "engines": { @@ -7392,14 +7407,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz", - "integrity": "sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz", + "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7421,16 +7436,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.13.0.tgz", - "integrity": "sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz", + "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.13.0", - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/typescript-estree": "8.13.0" + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/typescript-estree": "8.14.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7444,13 +7459,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz", - "integrity": "sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz", + "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", + "@typescript-eslint/types": "8.14.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -8284,14 +8299,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", - "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.2", + "@babel/helper-define-polyfill-provider": "^0.6.3", "semver": "^6.3.1" }, "peerDependencies": { @@ -8313,13 +8328,13 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" + "@babel/helper-define-polyfill-provider": "^0.6.3" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -8815,9 +8830,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001679", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001679.tgz", - "integrity": "sha512-j2YqID/YwpLnKzCmBOS4tlZdWprXm3ZmQLBH9ZBXFOhoxLA46fwyBvx6toCBWBmnuwUY/qB3kEU6gFx8qgCroA==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "funding": [ { "type": "opencollective", @@ -9719,13 +9734,10 @@ } }, "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" }, "node_modules/d3-drag": { "version": "3.0.0", @@ -9761,18 +9773,6 @@ "d3-timer": "1" } }, - "node_modules/d3-force/node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-force/node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" - }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -9941,13 +9941,10 @@ "license": "ISC" }, "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" }, "node_modules/d3-transition": { "version": "3.0.1", @@ -10325,9 +10322,9 @@ } }, "node_modules/dompurify": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", - "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-AMdOzK44oFWqHEi0wpOqix/fUNY707OmoeFDnbi3Q5I8uOpy21ufUA5cDJPr0bosxrflOVD/H2DMSvuGKJGfmQ==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/domutils": { @@ -10359,13 +10356,13 @@ } }, "node_modules/dotenv-expand": { - "version": "11.0.6", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", - "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "dotenv": "^16.4.4" + "dotenv": "^16.4.5" }, "engines": { "node": ">=12" @@ -10405,9 +10402,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.55", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.55.tgz", - "integrity": "sha512-6maZ2ASDOTBtjt9FhqYPRnbvKU5tjG0IN9SztUOWYw2AzNDNpKJYLJmlK0/En4Hs/aiWnB+JZ+gW19PIGszgKg==", + "version": "1.5.60", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.60.tgz", + "integrity": "sha512-HcraRUkTKJ+8yA3b10i9qvhUlPBRDlKjn1XGek1zDGVfAKcvi8TsUnImGqLiEm9j6ZulxXIWWIo9BmbkbCTGgA==", "license": "ISC" }, "node_modules/emittery": { @@ -12786,9 +12783,9 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz", + "integrity": "sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==", "dev": true, "license": "MIT" }, @@ -16052,9 +16049,9 @@ } }, "node_modules/node-gyp-build": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", - "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", + "integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==", "dev": true, "license": "MIT", "bin": { @@ -16347,9 +16344,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "20.0.12", - "resolved": "https://registry.npmjs.org/nx/-/nx-20.0.12.tgz", - "integrity": "sha512-pQ7Rwb2Qlhr+fEamd0qc4VsL/aKjVJ0MXPsosuhdZobLJQOKHefe+nXSSZ1Jy19VM3RRpxUKFneD/V2jvs3qDA==", + "version": "20.1.1", + "resolved": "https://registry.npmjs.org/nx/-/nx-20.1.1.tgz", + "integrity": "sha512-bLDEDBUuAvFC5b74QUnmJxUHTRa0mkc2wRPmb2rN3d1VlTFjzKTT9ClJTR1emp/DDO620zyAmVCDVKmnSZNFoQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -16392,16 +16389,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "20.0.12", - "@nx/nx-darwin-x64": "20.0.12", - "@nx/nx-freebsd-x64": "20.0.12", - "@nx/nx-linux-arm-gnueabihf": "20.0.12", - "@nx/nx-linux-arm64-gnu": "20.0.12", - "@nx/nx-linux-arm64-musl": "20.0.12", - "@nx/nx-linux-x64-gnu": "20.0.12", - "@nx/nx-linux-x64-musl": "20.0.12", - "@nx/nx-win32-arm64-msvc": "20.0.12", - "@nx/nx-win32-x64-msvc": "20.0.12" + "@nx/nx-darwin-arm64": "20.1.1", + "@nx/nx-darwin-x64": "20.1.1", + "@nx/nx-freebsd-x64": "20.1.1", + "@nx/nx-linux-arm-gnueabihf": "20.1.1", + "@nx/nx-linux-arm64-gnu": "20.1.1", + "@nx/nx-linux-arm64-musl": "20.1.1", + "@nx/nx-linux-x64-gnu": "20.1.1", + "@nx/nx-linux-x64-musl": "20.1.1", + "@nx/nx-win32-arm64-msvc": "20.1.1", + "@nx/nx-win32-x64-msvc": "20.1.1" }, "peerDependencies": { "@swc-node/register": "^1.8.0", @@ -16834,9 +16831,9 @@ } }, "node_modules/p-retry": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz", - "integrity": "sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17134,6 +17131,7 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", @@ -17144,7 +17142,8 @@ "node_modules/pdf-lib/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "node_modules/pdfjs-dist": { "version": "4.8.69", @@ -17437,14 +17436,14 @@ } }, "node_modules/postcss-modules-local-by-default": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", - "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.1.0.tgz", + "integrity": "sha512-rm0bdSv4jC3BDma3s9H19ZddW0aHX6EoqwDYU2IfZhRN+53QrufTRo2IdkAbRqLx4R2IYbZnbjKKxg4VN5oU9Q==", "dev": true, "license": "MIT", "dependencies": { "icss-utils": "^5.0.0", - "postcss-selector-parser": "^6.0.2", + "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "engines": { @@ -17455,13 +17454,13 @@ } }, "node_modules/postcss-modules-scope": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", - "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", "dev": true, "license": "ISC", "dependencies": { - "postcss-selector-parser": "^6.0.4" + "postcss-selector-parser": "^7.0.0" }, "engines": { "node": "^10 || ^12 || >= 14" @@ -17487,9 +17486,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz", + "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17507,9 +17506,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.181.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.181.0.tgz", - "integrity": "sha512-bI+J+f4E8x4JwbGtG6LReQv1Xvss01F6cs7UDlvffHySpVhNq4ptkNjV88B92IVEsrCtNYhy/TjFnGxk6RN0Qw==", + "version": "1.186.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.186.0.tgz", + "integrity": "sha512-WagGNrDtvyOhmX1Gtf1hJQMBy1mB1vx9gtC6BKEfJi2pvEFtQuAzQ9c/tMUTmY0o2ZF5ZBFiZ2IRs4kbFLMvPQ==", "license": "MIT", "dependencies": { "core-js": "^3.38.1", @@ -18431,6 +18430,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, "license": "MIT" }, "node_modules/rimraf": { @@ -18664,14 +18664,14 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.80.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.6.tgz", - "integrity": "sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg==", + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.81.0.tgz", + "integrity": "sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -20183,22 +20183,22 @@ "license": "MIT" }, "node_modules/tldts": { - "version": "6.1.59", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.59.tgz", - "integrity": "sha512-472ilPxsRuqBBpn+KuRBHJvZhk6tTo4yTVsmODrLBNLwRYJPkDfMEHivgNwp5iEl+cbrZzzRtLKRxZs7+QKkRg==", + "version": "6.1.61", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.61.tgz", + "integrity": "sha512-rv8LUyez4Ygkopqn+M6OLItAOT9FF3REpPQDkdMx5ix8w4qkuE7Vo2o/vw1nxKQYmJDV8JpAMJQr1b+lTKf0FA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.59" + "tldts-core": "^6.1.61" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.59", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.59.tgz", - "integrity": "sha512-EiYgNf275AQyVORl8HQYYe7rTVnmLb4hkWK7wAk/12Ksy5EiHpmUmTICa4GojookBPC8qkLMBKKwCmzNA47ZPQ==", + "version": "6.1.61", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.61.tgz", + "integrity": "sha512-In7VffkDWUPgwa+c9picLUxvb0RltVwTkSgMNFgvlGSWveCzGBemBqTsgJCL4EDFWZ6WH0fKTsot6yNhzy3ZzQ==", "dev": true, "license": "MIT" }, @@ -20834,9 +20834,9 @@ } }, "node_modules/uuid": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", - "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -21498,12 +21498,6 @@ "d3-timer": "^1.0.5" } }, - "node_modules/webcola/node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", - "license": "BSD-3-Clause" - }, "node_modules/webcola/node_modules/d3-drag": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", @@ -21529,12 +21523,6 @@ "d3-path": "1" } }, - "node_modules/webcola/node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 6956b8ac3223..2ca02dacf336 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "7.7.1", + "version": "7.7.2", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "18.2.11", - "@angular/cdk": "18.2.12", - "@angular/common": "18.2.11", - "@angular/compiler": "18.2.11", - "@angular/core": "18.2.11", - "@angular/forms": "18.2.11", - "@angular/localize": "18.2.11", - "@angular/material": "18.2.12", - "@angular/platform-browser": "18.2.11", - "@angular/platform-browser-dynamic": "18.2.11", - "@angular/router": "18.2.11", - "@angular/service-worker": "18.2.11", + "@angular/animations": "18.2.12", + "@angular/cdk": "18.2.13", + "@angular/common": "18.2.12", + "@angular/compiler": "18.2.12", + "@angular/core": "18.2.12", + "@angular/forms": "18.2.12", + "@angular/localize": "18.2.12", + "@angular/material": "18.2.13", + "@angular/platform-browser": "18.2.12", + "@angular/platform-browser-dynamic": "18.2.12", + "@angular/router": "18.2.12", + "@angular/service-worker": "18.2.12", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -36,9 +36,9 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "16.0.3", "@ngx-translate/http-loader": "16.0.0", - "@sentry/angular": "8.37.1", + "@sentry/angular": "8.38.0", "@siemens/ngx-datatable": "22.4.1", - "@swimlane/ngx-charts": "20.5.0", + "@swimlane/ngx-charts": "21.0.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", "@vscode/markdown-it-katex": "1.1.0", @@ -48,7 +48,7 @@ "crypto-js": "4.2.0", "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", - "dompurify": "3.1.7", + "dompurify": "3.2.0", "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -69,7 +69,7 @@ "papaparse": "5.4.1", "pdf-lib": "1.17.1", "pdfjs-dist": "4.8.69", - "posthog-js": "1.181.0", + "posthog-js": "1.186.0", "rxjs": "7.8.1", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", @@ -78,7 +78,7 @@ "ts-cacheable": "1.0.10", "tslib": "2.8.1", "turndown": "7.2.0", - "uuid": "11.0.2", + "uuid": "11.0.3", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zone.js": "0.14.10" @@ -116,16 +116,16 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.11", + "@angular-devkit/build-angular": "18.2.12", "@angular-eslint/builder": "18.4.0", "@angular-eslint/eslint-plugin": "18.4.0", "@angular-eslint/eslint-plugin-template": "18.4.0", "@angular-eslint/schematics": "18.4.0", "@angular-eslint/template-parser": "18.4.0", - "@angular/cli": "18.2.11", - "@angular/compiler-cli": "18.2.11", - "@angular/language-service": "18.2.11", - "@sentry/types": "8.37.1", + "@angular/cli": "18.2.12", + "@angular/compiler-cli": "18.2.12", + "@angular/language-service": "18.2.12", + "@sentry/types": "8.38.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -139,8 +139,8 @@ "@types/sockjs-client": "1.5.4", "@types/turndown": "5.0.5", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.13.0", - "@typescript-eslint/parser": "8.13.0", + "@typescript-eslint/eslint-plugin": "8.14.0", + "@typescript-eslint/parser": "8.14.0", "eslint": "9.14.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -161,7 +161,7 @@ "ng-mocks": "14.13.1", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.80.6", + "sass": "1.81.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java new file mode 100644 index 000000000000..71c6b73a208f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java @@ -0,0 +1,4 @@ +package de.tum.cit.aet.artemis.assessment.dto; + +public record FeedbackAffectedStudentDTO(long courseId, long participationId, String firstName, String lastName, String login, String repositoryURI) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java index 6d31e250f5d5..d22a036e7489 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java @@ -1,7 +1,16 @@ package de.tum.cit.aet.artemis.assessment.dto; +import java.util.Arrays; +import java.util.List; + import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) { +public record FeedbackDetailDTO(List concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName, + String errorCategory) { + + public FeedbackDetailDTO(String concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) { + this(Arrays.stream(concatenatedFeedbackIds.split(",")).map(Long::valueOf).toList(), count, relativeCount, detailText, testCaseName, taskName, errorCategory); + } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ParticipantScoreRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ParticipantScoreRepository.java index 14598bad30b1..24636efc2330 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ParticipantScoreRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ParticipantScoreRepository.java @@ -44,10 +44,6 @@ public interface ParticipantScoreRepository extends ArtemisJpaRepository findAllOutdated(); - @Override - @EntityGraph(type = LOAD, attributePaths = { "exercise", "lastResult", "lastRatedResult" }) - List findAll(); - @EntityGraph(type = LOAD, attributePaths = { "exercise", "lastResult", "lastRatedResult" }) List findAllByExercise(Exercise exercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index d0c25898b6a3..50576953916b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java @@ -4,6 +4,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.HashMap; @@ -24,6 +25,7 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; @@ -31,6 +33,7 @@ import de.tum.cit.aet.artemis.assessment.domain.FeedbackType; import de.tum.cit.aet.artemis.assessment.domain.LongFeedbackText; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackAnalysisResponseDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackPageableDTO; @@ -46,6 +49,7 @@ import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; +import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; @@ -618,9 +622,8 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee maxOccurrence, filterErrorCategories, pageable); // 10. Process and map feedback details, calculating relative count and assigning task names - List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> new FeedbackDetailDTO(detail.count(), + List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> new FeedbackDetailDTO(detail.concatenatedFeedbackIds(), detail.count(), (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), detail.taskName(), detail.errorCategory())).toList(); - // 11. Predefined error categories available for filtering on the client side final List ERROR_CATEGORIES = List.of("Student Error", "Ares Error", "AST Error"); @@ -642,6 +645,25 @@ public long getMaxCountForExercise(long exerciseId) { return studentParticipationRepository.findMaxCountForExercise(exerciseId); } + /** + * Retrieves a paginated list of students affected by specific feedback entries for a given exercise. + *
+ * This method filters students based on feedback IDs and returns participation details for each affected student. It uses + * pagination and sorting (order based on the {@link PageUtil.ColumnMapping#AFFECTED_STUDENTS}) to allow efficient retrieval and sorting of the results, thus supporting large + * datasets. + *
+ * + * @param exerciseId for which the affected student participation data is requested. + * @param feedbackIds used to filter the participation to only those affected by specific feedback entries. + * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters. + * @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback. + */ + public Page getAffectedStudentsWithFeedbackId(long exerciseId, String feedbackIds, PageableSearchDTO data) { + List feedbackIdLongs = Arrays.stream(feedbackIds.split(",")).map(Long::valueOf).toList(); + PageRequest pageRequest = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.AFFECTED_STUDENTS); + return studentParticipationRepository.findAffectedStudentsByFeedbackId(exerciseId, feedbackIdLongs, pageRequest); + } + /** * Deletes long feedback texts for the provided list of feedback items to prevent duplicate entries in the {@link LongFeedbackTextRepository}. *
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index 2a235145a04b..431fb66373e8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -22,12 +23,14 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.assessment.domain.Feedback; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackAnalysisResponseDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackPageableDTO; import de.tum.cit.aet.artemis.assessment.dto.ResultWithPointsPerGradingCriterionDTO; @@ -36,13 +39,14 @@ import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; +import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; -import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; import de.tum.cit.aet.artemis.exam.domain.Exam; @@ -328,7 +332,7 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo * */ @GetMapping("exercises/{exerciseId}/feedback-details") - @EnforceAtLeastInstructorInExercise + @EnforceAtLeastEditorInExercise public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @ModelAttribute FeedbackPageableDTO data) { FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data); return ResponseEntity.ok(response); @@ -343,9 +347,30 @@ public ResponseEntity getFeedbackDetailsPaged(@Path * @return A {@link ResponseEntity} containing the maximum count of feedback occurrences (long). */ @GetMapping("exercises/{exerciseId}/feedback-details-max-count") - @EnforceAtLeastInstructorInExercise + @EnforceAtLeastEditorInExercise public ResponseEntity getMaxCount(@PathVariable long exerciseId) { long maxCount = resultService.getMaxCountForExercise(exerciseId); return ResponseEntity.ok(maxCount); } + + /** + * GET /exercises/{exerciseId}/feedback-details-participation : Retrieves paginated details of students affected by specific feedback entries for a specified exercise. + * This endpoint returns details of students whose submissions were impacted by specified feedback entries, including student information + * and participation details. + *
+ * + * @param exerciseId for which the participation data is requested. + * @param feedbackIdsHeader to filter affected students by specific feedback entries. + * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters. + * @return A {@link ResponseEntity} containing a {@link Page} of {@link FeedbackAffectedStudentDTO}, each representing a student affected by the feedback entries. + */ + @GetMapping("exercises/{exerciseId}/feedback-details-participation") + @EnforceAtLeastEditorInExercise + public ResponseEntity> getAffectedStudentsWithFeedback(@PathVariable long exerciseId, @RequestHeader("feedbackIds") String feedbackIdsHeader, + @ModelAttribute PageableSearchDTO data) { + + Page participation = resultService.getAffectedStudentsWithFeedbackId(exerciseId, feedbackIdsHeader, data); + + return ResponseEntity.ok(participation); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyExerciseLinkRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyExerciseLinkRepository.java index d19675ca6412..8622daff5490 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyExerciseLinkRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyExerciseLinkRepository.java @@ -2,7 +2,11 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import java.util.List; + import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; @@ -12,4 +16,10 @@ @Repository public interface CompetencyExerciseLinkRepository extends ArtemisJpaRepository { + @Query(""" + SELECT cel FROM CompetencyExerciseLink cel + LEFT JOIN FETCH cel.competency + WHERE cel.exercise.id = :exerciseId + """) + List findByExerciseIdWithCompetency(@Param("exerciseId") long exerciseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index fee3ca1e82f2..bba103f436d9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -126,7 +126,7 @@ CASE WHEN TYPE(e) = ProgrammingExercise THEN TRUE ELSE FALSE END, LEFT JOIN TeamScore tS ON tS.exercise = e AND :user MEMBER OF tS.team.students WHERE c.id = :competencyId AND e IS NOT NULL - GROUP BY e.id, e.maxPoints, e.difficulty, TYPE(e), sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate + GROUP BY e.id, e.maxPoints, e.difficulty, TYPE(e), el.weight, sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate """) Set findAllExerciseInfoByCompetencyIdAndUser(@Param("competencyId") long competencyId, @Param("user") User user); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java index 9c11f0f33fdc..705c162c6341 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java @@ -2,19 +2,20 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; @@ -27,6 +28,8 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; +import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; +import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @@ -39,15 +42,19 @@ public class CompetencyService extends CourseCompetencyService { private final CompetencyRepository competencyRepository; + private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + public CompetencyService(CompetencyRepository competencyRepository, AuthorizationCheckService authCheckService, CompetencyRelationRepository competencyRelationRepository, LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { + LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository, + CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, competencyLectureUnitLinkRepository, courseRepository); this.competencyRepository = competencyRepository; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; } /** @@ -59,17 +66,7 @@ public CompetencyService(CompetencyRepository competencyRepository, Authorizatio * @return The set of imported competencies, each also containing the relations it is the tail competency for. */ public Set importCompetencies(Course course, Collection competencies, CompetencyImportOptionsDTO importOptions) { - var idToImportedCompetency = new HashMap(); - - for (var competency : competencies) { - Competency importedCompetency = new Competency(competency); - importedCompetency.setCourse(course); - - importedCompetency = competencyRepository.save(importedCompetency); - idToImportedCompetency.put(competency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); - } - - return importCourseCompetencies(course, competencies, idToImportedCompetency, importOptions); + return importCourseCompetencies(course, competencies, importOptions, Competency::new); } /** @@ -121,4 +118,26 @@ public List findCompetenciesWithProgressForUserByCourseId(Long cours List competencies = competencyRepository.findByCourseIdOrderById(courseId); return findProgressForCompetenciesAndUser(competencies, userId); } + + /** + * Creates competency links for exercise units of the lecture. + *

+ * As exercise units can not be linked to competencies but only via the exercise itself, we add temporary links to the exercise units. + * Although they can not be persisted, this makes it easier to display the linked competencies in the client consistently across all lecture unit type. + * + * @param lecture the lecture to augment the exercise unit links for + */ + public void addCompetencyLinksToExerciseUnits(Lecture lecture) { + var exerciseUnits = lecture.getLectureUnits().stream().filter(unit -> unit instanceof ExerciseUnit); + exerciseUnits.forEach(unit -> { + var exerciseUnit = (ExerciseUnit) unit; + var exercise = exerciseUnit.getExercise(); + if (exercise != null) { + var competencyExerciseLinks = competencyExerciseLinkRepository.findByExerciseIdWithCompetency(exercise.getId()); + var competencyLectureUnitLinks = competencyExerciseLinks.stream().map(link -> new CompetencyLectureUnitLink(link.getCompetency(), exerciseUnit, link.getWeight())) + .collect(Collectors.toSet()); + exerciseUnit.setCompetencyLinks(competencyLectureUnitLinks); + } + }); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java index 8e47f7443297..cbe33e70b710 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java @@ -8,7 +8,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -213,34 +212,39 @@ public void filterOutLearningObjectsThatUserShouldNotSee(CourseCompetency compet * @return The set of imported course competencies, each also containing the relations it is the tail competency for. */ public Set importCourseCompetencies(Course course, Collection courseCompetencies, CompetencyImportOptionsDTO importOptions) { - var idToImportedCompetency = new HashMap(); - - for (var courseCompetency : courseCompetencies) { - CourseCompetency importedCompetency = switch (courseCompetency) { - case Competency competency -> new Competency(competency); - case Prerequisite prerequisite -> new Prerequisite(prerequisite); - default -> throw new IllegalStateException("Unexpected value: " + courseCompetency); - }; - importedCompetency.setCourse(course); + Function createNewCourseCompetency = courseCompetency -> switch (courseCompetency) { + case Competency competency -> new Competency(competency); + case Prerequisite prerequisite -> new Prerequisite(prerequisite); + default -> throw new IllegalStateException("Unexpected value: " + courseCompetency); + }; - importedCompetency = courseCompetencyRepository.save(importedCompetency); - idToImportedCompetency.put(courseCompetency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); - } - - return importCourseCompetencies(course, courseCompetencies, idToImportedCompetency, importOptions); + return importCourseCompetencies(course, courseCompetencies, importOptions, createNewCourseCompetency); } /** * Imports the given competencies and relations into a course * - * @param course the course to import into - * @param competenciesToImport the source competencies that were imported - * @param idToImportedCompetency map of original competency id to imported competency - * @param importOptions the import options + * @param course the course to import into + * @param competenciesToImport the source competencies that were imported + * @param importOptions the import options + * @param createNewCourseCompetency the function that creates new course competencies * @return The set of imported competencies, each also containing the relations it is the tail competency for. */ public Set importCourseCompetencies(Course course, Collection competenciesToImport, - Map idToImportedCompetency, CompetencyImportOptionsDTO importOptions) { + CompetencyImportOptionsDTO importOptions, Function createNewCourseCompetency) { + var idToImportedCompetency = new HashMap(); + + Set competenciesInCourse = courseCompetencyRepository.findAllForCourse(course.getId()); + + for (var courseCompetency : competenciesToImport) { + Optional existingCompetency = competenciesInCourse.stream().filter(competency -> competency.getTitle().equals(courseCompetency.getTitle())) + .filter(competency -> competency.getType().equals(courseCompetency.getType())).findFirst(); + CourseCompetency importedCompetency = existingCompetency.orElse(createNewCourseCompetency.apply(courseCompetency)); + importedCompetency.setCourse(course); + idToImportedCompetency.put(courseCompetency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); + } + courseCompetencyRepository.saveAll(idToImportedCompetency.values().stream().map(CompetencyWithTailRelationDTO::competency).toList()); + if (course.getLearningPathsEnabled()) { var importedCompetencies = idToImportedCompetency.values().stream().map(CompetencyWithTailRelationDTO::competency).toList(); learningPathService.linkCompetenciesToLearningPathsOfCourse(importedCompetencies, course.getId()); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java index 996ebd7d4385..eb66a98d641f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java @@ -2,9 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Set; @@ -59,17 +57,7 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author * @return The set of imported prerequisites, each also containing the relations for which it is the tail prerequisite for. */ public Set importPrerequisites(Course course, Collection prerequisites, CompetencyImportOptionsDTO importOptions) { - var idToImportedPrerequisite = new HashMap(); - - for (var prerequisite : prerequisites) { - Prerequisite importedPrerequisite = new Prerequisite(prerequisite); - importedPrerequisite.setCourse(course); - - importedPrerequisite = prerequisiteRepository.save(importedPrerequisite); - idToImportedPrerequisite.put(prerequisite.getId(), new CompetencyWithTailRelationDTO(importedPrerequisite, new ArrayList<>())); - } - - return importCourseCompetencies(course, prerequisites, idToImportedPrerequisite, importOptions); + return importCourseCompetencies(course, prerequisites, importOptions, Prerequisite::new); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/StandardizedCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/StandardizedCompetencyService.java index 47a4bd801e8e..55999666aea5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/StandardizedCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/StandardizedCompetencyService.java @@ -191,6 +191,7 @@ public void importStandardizedCompetencyCatalog(StandardizedCompetencyCatalogDTO */ public String exportStandardizedCompetencyCatalog() { List knowledgeAreas = getAllForTreeView(); + // TODO: we should avoid using findAll() here, as it might return a huge amount of data List sources = sourceRepository.findAll(); var catalog = StandardizedCompetencyCatalogDTO.of(knowledgeAreas, sources); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java index baacbfd73966..fbd88e0b323b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java @@ -207,6 +207,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, SecurityProblemSupport // Websocket and other specific endpoints allowed without authentication. .requestMatchers("/websocket/**").permitAll() .requestMatchers("/.well-known/jwks.json").permitAll() + .requestMatchers("/.well-known/assetlinks.json").permitAll() // Prometheus endpoint protected by IP address. .requestMatchers("/management/prometheus/**").access((authentication, context) -> new AuthorizationDecision(monitoringIpAddresses.contains(context.getRequest().getRemoteAddr()))); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseGroupsDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseGroupsDTO.java new file mode 100644 index 000000000000..3aa5bce0b285 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseGroupsDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.core.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseGroupsDTO(String instructorGroupName, String editorGroupName, String teachingAssistantGroupName, String studentGroupName) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java index b7b34537848d..8d9aa0c09df5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java @@ -26,6 +26,7 @@ import de.tum.cit.aet.artemis.core.domain.Organization; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO; +import de.tum.cit.aet.artemis.core.dto.CourseGroupsDTO; import de.tum.cit.aet.artemis.core.dto.StatisticsEntry; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -323,14 +324,6 @@ GROUP BY SUBSTRING(CAST(s.submissionDate AS string), 1, 10), p.student.login """) List findAllNotEndedCoursesByManagementGroupNames(@Param("now") ZonedDateTime now, @Param("userGroups") List userGroups); - @Query(""" - SELECT COUNT(DISTINCT ug.userId) - FROM Course c - JOIN UserGroup ug ON c.studentGroupName = ug.group - WHERE c.id = :courseId - """) - int countCourseStudents(@Param("courseId") long courseId); - /** * Counts the number of members of a course, i.e. users that are a member of the course's student, tutor, editor or instructor group. * Users that are part of multiple groups are NOT counted multiple times. @@ -569,4 +562,23 @@ SELECT COUNT(c) > 0 Set findInactiveCoursesForUserRolesWithNonNullSemester(@Param("isAdmin") boolean isAdmin, @Param("groups") Set groups, @Param("now") ZonedDateTime now); + @Query(""" + SELECT new de.tum.cit.aet.artemis.core.dto.CourseGroupsDTO( + c.instructorGroupName, + c.editorGroupName, + c.teachingAssistantGroupName, + c.studentGroupName + ) FROM Course c + """) + Set findAllCourseGroups(); + + @Query(""" + SELECT c + FROM Course c + WHERE c.teachingAssistantGroupName IN :userGroups + OR c.editorGroupName IN :userGroups + OR c.instructorGroupName IN :userGroups + OR :isAdmin = TRUE + """) + List findCoursesForAtLeastTutorWithGroups(@Param("userGroups") Set userGroups, @Param("isAdmin") boolean isAdmin); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/StatisticsRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/StatisticsRepository.java index a39e6207f4bd..127dc533aef9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/StatisticsRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/StatisticsRepository.java @@ -121,7 +121,7 @@ OR EXISTS (SELECT c FROM Course c WHERE submission.participation.exercise.course * * @param startDate the minimum submission date * @param endDate the maximum submission date - * @return a list of active users + * @return a count of active users */ @Query(""" SELECT COUNT(DISTINCT p.student.id) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index 81e41bd58c63..1b8958d78cef 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -687,6 +687,7 @@ public List getAllCoursesForManagementOverview(boolean onlyActive) { var user = userRepository.getUserWithGroupsAndAuthorities(); boolean isAdmin = authCheckService.isAdmin(user); if (isAdmin && !onlyActive) { + // TODO: we should avoid using findAll() here, as it might return a huge amount of data return courseRepository.findAll(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java index cc135808aee4..30788b082480 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java @@ -838,7 +838,7 @@ public List importUsers(List userDtos) { * @return the users participation vcs access token, or throws an exception if it does not exist */ public ParticipationVCSAccessToken getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(User user, Long participationId) { - return participationVCSAccessTokenService.findByUserIdAndParticipationIdOrElseThrow(user.getId(), participationId); + return participationVCSAccessTokenService.findByUserAndParticipationIdOrElseThrow(user, participationId); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java index 7417fe060e93..88f7bb7302e6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java @@ -82,6 +82,9 @@ SELECT MAX(t.taskName) JOIN t.testCases tct WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName ), '')""" + )), + AFFECTED_STUDENTS(Map.of( + "participationId", "p.id" )); // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java index 2a441e4cd035..997574c76da7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java @@ -17,7 +17,6 @@ import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -58,9 +57,6 @@ public class AccountResource { public static final String ENTITY_NAME = "user"; - @Value("${jhipster.clientApp.name}") - private String applicationName; - private static final Logger log = LoggerFactory.getLogger(AccountResource.class); private final UserRepository userRepository; @@ -233,7 +229,7 @@ public ResponseEntity getVcsAccessToken(@RequestParam("participationId") } /** - * PUT account/participation-vcs-access-token : get the vcsToken for of a user for a participation + * PUT account/participation-vcs-access-token : add a vcsToken for of a user for a participation * * @param participationId the participation for which the access token should be fetched * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index 79e53eb74f4b..4d55e007a538 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -21,7 +21,6 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import jakarta.validation.constraints.NotNull; @@ -100,6 +99,7 @@ import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastTutorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.CourseService; import de.tum.cit.aet.artemis.core.service.FilePathService; @@ -118,7 +118,6 @@ import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.exercise.service.SubmissionService; -import de.tum.cit.aet.artemis.lti.domain.OnlineCourseConfiguration; import de.tum.cit.aet.artemis.lti.service.OnlineCourseConfigurationService; import de.tum.cit.aet.artemis.programming.service.ci.CIUserManagementService; import de.tum.cit.aet.artemis.programming.service.vcs.VcsUserManagementService; @@ -259,16 +258,10 @@ public ResponseEntity updateCourse(@PathVariable Long courseId, @Request // this is important, otherwise someone could put himself into the instructor group of the updated course authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, existingCourse, user); - Set existingGroupNames = new HashSet<>(List.of(existingCourse.getStudentGroupName(), existingCourse.getTeachingAssistantGroupName(), - existingCourse.getEditorGroupName(), existingCourse.getInstructorGroupName())); - Set newGroupNames = new HashSet<>(List.of(courseUpdate.getStudentGroupName(), courseUpdate.getTeachingAssistantGroupName(), courseUpdate.getEditorGroupName(), - courseUpdate.getInstructorGroupName())); - Set changedGroupNames = new HashSet<>(newGroupNames); - changedGroupNames.removeAll(existingGroupNames); - if (!authCheckService.isAdmin(user)) { // this means the user must be an instructor, who has NO Admin rights. // instructors are not allowed to change group names, because this would lead to security problems + final var changedGroupNames = getChangedGroupNames(courseUpdate, existingCourse); if (!changedGroupNames.isEmpty()) { throw new BadRequestAlertException("You are not allowed to change the group names of a course", Course.ENTITY_NAME, "groupNamesCannotChange", true); } @@ -373,48 +366,14 @@ else if (courseUpdate.getCourseIcon() == null && existingCourse.getCourseIcon() return ResponseEntity.ok(result); } - /** - * PUT courses/:courseId/online-course-configuration : Updates the onlineCourseConfiguration for the given course. - * - * @param courseId the id of the course to update - * @param onlineCourseConfiguration the online course configuration to update - * @return the ResponseEntity with status 200 (OK) and with body the updated online course configuration - */ - // TODO: move into LTIResource - @PutMapping("courses/{courseId}/online-course-configuration") - @EnforceAtLeastInstructor - @Profile(PROFILE_LTI) - public ResponseEntity updateOnlineCourseConfiguration(@PathVariable Long courseId, - @RequestBody OnlineCourseConfiguration onlineCourseConfiguration) { - log.debug("REST request to update the online course configuration for Course : {}", courseId); - - Course course = courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); - - if (!course.isOnlineCourse()) { - throw new BadRequestAlertException("Course must be online course", Course.ENTITY_NAME, "courseMustBeOnline"); - } - - if (!course.getOnlineCourseConfiguration().getId().equals(onlineCourseConfiguration.getId())) { - throw new BadRequestAlertException("The onlineCourseConfigurationId does not match the id of the course's onlineCourseConfiguration", - OnlineCourseConfiguration.ENTITY_NAME, "idMismatch"); - } - - if (onlineCourseConfigurationService.isPresent()) { - onlineCourseConfigurationService.get().validateOnlineCourseConfiguration(onlineCourseConfiguration); - course.setOnlineCourseConfiguration(onlineCourseConfiguration); - try { - onlineCourseConfigurationService.get().addOnlineCourseConfigurationToLtiConfigurations(onlineCourseConfiguration); - } - catch (Exception ex) { - log.error("Failed to add online course configuration to LTI configurations", ex); - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error when adding online course configuration to LTI configurations", ex); - } - } - - courseRepository.save(course); - - return ResponseEntity.ok(onlineCourseConfiguration); + private static Set getChangedGroupNames(Course courseUpdate, Course existingCourse) { + Set existingGroupNames = new HashSet<>(List.of(existingCourse.getStudentGroupName(), existingCourse.getTeachingAssistantGroupName(), + existingCourse.getEditorGroupName(), existingCourse.getInstructorGroupName())); + Set newGroupNames = new HashSet<>(List.of(courseUpdate.getStudentGroupName(), courseUpdate.getTeachingAssistantGroupName(), courseUpdate.getEditorGroupName(), + courseUpdate.getInstructorGroupName())); + Set changedGroupNames = new HashSet<>(newGroupNames); + changedGroupNames.removeAll(existingGroupNames); + return changedGroupNames; } /** @@ -485,17 +444,20 @@ public ResponseEntity> unenrollFromCourse(@PathVariable Long courseI @GetMapping("courses") @EnforceAtLeastTutor public ResponseEntity> getCourses(@RequestParam(defaultValue = "false") boolean onlyActive) { - log.debug("REST request to get all Courses the user has access to"); + log.debug("REST request to get all courses the user has access to"); User user = userRepository.getUserWithGroupsAndAuthorities(); - // TODO: we should avoid findAll() and instead try to filter this directly in the database, in case of admins, we should load batches of courses, e.g. per semester - List courses = courseRepository.findAll(); - Stream userCourses = courses.stream().filter(course -> user.getGroups().contains(course.getTeachingAssistantGroupName()) - || user.getGroups().contains(course.getInstructorGroupName()) || authCheckService.isAdmin(user)); + List courses = getCoursesForTutors(user, onlyActive); + return ResponseEntity.ok(courses); + } + + private List getCoursesForTutors(User user, boolean onlyActive) { + List userCourses = courseRepository.findCoursesForAtLeastTutorWithGroups(user.getGroups(), authCheckService.isAdmin(user)); if (onlyActive) { // only include courses that have NOT been finished - userCourses = userCourses.filter(course -> course.getEndDate() == null || course.getEndDate().isAfter(ZonedDateTime.now())); + final var now = ZonedDateTime.now(); + userCourses = userCourses.stream().filter(course -> course.getEndDate() == null || course.getEndDate().isAfter(now)).toList(); } - return ResponseEntity.ok(userCourses.toList()); + return userCourses; } /** @@ -542,8 +504,9 @@ public ResponseEntity> getCoursesWithQuizExercises() { @EnforceAtLeastTutor public ResponseEntity> getCoursesWithUserStats(@RequestParam(defaultValue = "false") boolean onlyActive) { log.debug("get courses with user stats, only active: {}", onlyActive); - // TODO: we should avoid using an endpoint in such cases and instead call a service method - List courses = getCourses(onlyActive).getBody(); + + User user = userRepository.getUserWithGroupsAndAuthorities(); + List courses = getCoursesForTutors(user, onlyActive); for (Course course : courses) { course.setNumberOfInstructors(userRepository.countUserInGroup(course.getInstructorGroupName())); course.setNumberOfTeachingAssistants(userRepository.countUserInGroup(course.getTeachingAssistantGroupName())); @@ -651,11 +614,11 @@ public ResponseEntity getCourseForDashboard(@PathVariable } courseService.fetchParticipationsWithSubmissionsAndResultsForCourses(List.of(course), user, true); - log.debug("courseService.fetchParticipationsWithSubmissionsAndResultsForCourses done"); + log.debug("courseService.fetchParticipationsWithSubmissionsAndResultsForCourses done in getCourseForDashboard"); courseService.fetchPlagiarismCasesForCourseExercises(course.getExercises(), user.getId()); - log.debug("courseService.fetchPlagiarismCasesForCourseExercises done"); + log.debug("courseService.fetchPlagiarismCasesForCourseExercises done in getCourseForDashboard"); GradingScale gradingScale = gradingScaleRepository.findByCourseId(course.getId()).orElse(null); - log.debug("gradingScaleRepository.findByCourseId done"); + log.debug("gradingScaleRepository.findByCourseId done in getCourseForDashboard"); CourseForDashboardDTO courseForDashboardDTO = courseScoreCalculationService.getScoresAndParticipationResults(course, gradingScale, user.getId()); logDuration(List.of(course), user, timeNanoStart, "courses/" + courseId + "/for-dashboard (single course)"); return ResponseEntity.ok(courseForDashboardDTO); @@ -754,16 +717,15 @@ public ResponseEntity> getCoursesForNotifications() { * @return data about a course including all exercises, plus some data for the tutor as tutor status for assessment */ @GetMapping("courses/{courseId}/for-assessment-dashboard") - @EnforceAtLeastTutor + @EnforceAtLeastTutorInCourse public ResponseEntity getCourseForAssessmentDashboard(@PathVariable long courseId) { log.debug("REST request /courses/{courseId}/for-assessment-dashboard"); - // TODO: use ...ElseThrow below in case the course cannot be found - Course course = courseRepository.findWithEagerExercisesById(courseId); - User user = userRepository.getUserWithGroupsAndAuthorities(); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.TEACHING_ASSISTANT, course, user); + Course course = courseRepository.findByIdWithEagerExercisesElseThrow(courseId); Set interestingExercises = courseRepository.filterInterestingExercisesForAssessmentDashboards(course.getExercises()); course.setExercises(interestingExercises); + + User user = userRepository.getUser(); List tutorParticipations = tutorParticipationRepository.findAllByAssessedExercise_Course_IdAndTutor_Id(course.getId(), user.getId()); assessmentDashboardService.generateStatisticsForExercisesForAssessmentDashboard(course.getExercises(), tutorParticipations, false); return ResponseEntity.ok(course); @@ -796,7 +758,7 @@ public ResponseEntity getStatsForAssessmentDashboard(@Path @GetMapping("courses/{courseId}") @EnforceAtLeastStudent public ResponseEntity getCourse(@PathVariable Long courseId) { - log.debug("REST request to get Course : {}", courseId); + log.debug("REST request to get course {} for students", courseId); Course course = courseRepository.findByIdElseThrow(courseId); User user = userRepository.getUserWithGroupsAndAuthorities(); @@ -828,7 +790,7 @@ else if (authCheckService.isAtLeastTeachingAssistantInCourse(course, user)) { @GetMapping("courses/{courseId}/with-exercises") @EnforceAtLeastTutor public ResponseEntity getCourseWithExercises(@PathVariable Long courseId) { - log.debug("REST request to get Course : {}", courseId); + log.debug("REST request to get course {} for tutors", courseId); Course course = courseRepository.findWithEagerExercisesById(courseId); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.TEACHING_ASSISTANT, course, null); return ResponseEntity.ok(course); @@ -1075,20 +1037,9 @@ public ResponseEntity> searchUsersInCourse(@PathVariable if (loginOrName.length() < 3 && requestedRoles.contains(Role.STUDENT)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Query param 'loginOrName' must be three characters or longer if you search for students."); } - var groups = new HashSet(); - if (requestedRoles.contains(Role.STUDENT)) { - groups.add(course.getStudentGroupName()); - } - if (requestedRoles.contains(Role.TEACHING_ASSISTANT)) { - groups.add(course.getTeachingAssistantGroupName()); - // searching for tutors also searches for editors - groups.add(course.getEditorGroupName()); - } - if (requestedRoles.contains(Role.INSTRUCTOR)) { - groups.add(course.getInstructorGroupName()); - } + final var relevantCourseGroupNames = getRelevantCourseGroupNames(requestedRoles, course); User searchingUser = userRepository.getUser(); - var originalPage = userRepository.searchAllWithGroupsByLoginOrNameInGroupsNotUserId(PageRequest.of(0, 25), loginOrName, groups, searchingUser.getId()); + var originalPage = userRepository.searchAllWithGroupsByLoginOrNameInGroupsNotUserId(PageRequest.of(0, 25), loginOrName, relevantCourseGroupNames, searchingUser.getId()); var resultDTOs = new ArrayList(); for (var user : originalPage) { @@ -1103,6 +1054,22 @@ public ResponseEntity> searchUsersInCourse(@PathVariable return new ResponseEntity<>(dtoPage.getContent(), headers, HttpStatus.OK); } + private static HashSet getRelevantCourseGroupNames(Set requestedRoles, Course course) { + var groups = new HashSet(); + if (requestedRoles.contains(Role.STUDENT)) { + groups.add(course.getStudentGroupName()); + } + if (requestedRoles.contains(Role.TEACHING_ASSISTANT)) { + groups.add(course.getTeachingAssistantGroupName()); + // searching for tutors also searches for editors + groups.add(course.getEditorGroupName()); + } + if (requestedRoles.contains(Role.INSTRUCTOR)) { + groups.add(course.getInstructorGroupName()); + } + return groups; + } + /** * GET /courses/:courseId/tutors : Returns all users that belong to the tutor group of the course * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java index 0e90597a25bd..27332e5343a7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/FileResource.java @@ -219,7 +219,7 @@ public ResponseEntity getMarkdownFileForConversation(@PathVariable Long public ResponseEntity getMarkdownFile(@PathVariable String filename) { log.debug("REST request to get file : {}", filename); sanitizeFilenameElseThrow(filename); - return buildFileResponse(FilePathService.getMarkdownFilePath(), filename); + return buildFileResponse(FilePathService.getMarkdownFilePath(), filename, false); } /** @@ -416,7 +416,7 @@ public ResponseEntity getExamUserImage(@PathVariable Long examUserId) { @GetMapping("files/attachments/lecture/{lectureId}/{filename}") @EnforceAtLeastStudent public ResponseEntity getLectureAttachment(@PathVariable Long lectureId, @PathVariable String filename) { - log.debug("REST request to get file : {}", filename); + log.debug("REST request to get lecture attachment : {}", filename); String fileNameWithoutSpaces = filename.replaceAll(" ", "_"); sanitizeFilenameElseThrow(fileNameWithoutSpaces); @@ -431,25 +431,7 @@ public ResponseEntity getLectureAttachment(@PathVariable Long lectureId, // check if the user is authorized to access the requested attachment unit checkAttachmentAuthorizationOrThrow(course, attachment); - return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); - } - - /** - * GET /files/courses/{courseId}/attachments/{attachmentId} : Returns the file associated with the - * given attachment ID as a downloadable resource - * - * @param courseId The ID of the course that the Attachment belongs to - * @param attachmentId the ID of the attachment to retrieve - * @return ResponseEntity containing the file as a resource - */ - @GetMapping("files/courses/{courseId}/attachments/{attachmentId}") - @EnforceAtLeastEditorInCourse - public ResponseEntity getAttachmentFile(@PathVariable Long courseId, @PathVariable Long attachmentId) { - Attachment attachment = attachmentRepository.findByIdElseThrow(attachmentId); - Course course = courseRepository.findByIdElseThrow(courseId); - checkAttachmentExistsInCourseOrThrow(course, attachment); - - return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); + return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), Optional.of(attachment.getName())); } /** @@ -488,6 +470,7 @@ public ResponseEntity getLecturePdfAttachmentsMerged(@PathVariable Long /** * GET files/attachments/attachment-unit/:attachmentUnitId/:filename : Get the lecture unit attachment + * Accesses to this endpoint are created by the server itself in the FilePathService * * @param attachmentUnitId ID of the attachment unit, the attachment belongs to * @return The requested file, 403 if the logged-in user is not allowed to access it, or 404 if the file doesn't exist @@ -495,7 +478,7 @@ public ResponseEntity getLecturePdfAttachmentsMerged(@PathVariable Long @GetMapping("files/attachments/attachment-unit/{attachmentUnitId}/*") @EnforceAtLeastStudent public ResponseEntity getAttachmentUnitAttachment(@PathVariable Long attachmentUnitId) { - log.debug("REST request to get file for attachment unit : {}", attachmentUnitId); + log.debug("REST request to get the file for attachment unit {} for students", attachmentUnitId); AttachmentUnit attachmentUnit = attachmentUnitRepository.findByIdElseThrow(attachmentUnitId); // get the course for a lecture's attachment unit @@ -504,12 +487,11 @@ public ResponseEntity getAttachmentUnitAttachment(@PathVariable Long att // check if the user is authorized to access the requested attachment unit checkAttachmentAuthorizationOrThrow(course, attachment); - - return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); + return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), Optional.of(attachment.getName())); } /** - * GET files/courses/{courseId}/attachment-units/{attachmenUnitId} : Returns the file associated with the + * GET files/courses/{courseId}/attachment-units/{attachmentUnitId} : Returns the file associated with the * given attachmentUnit ID as a downloadable resource * * @param courseId The ID of the course that the Attachment belongs to @@ -519,7 +501,7 @@ public ResponseEntity getAttachmentUnitAttachment(@PathVariable Long att @GetMapping("files/courses/{courseId}/attachment-units/{attachmentUnitId}") @EnforceAtLeastEditorInCourse public ResponseEntity getAttachmentUnitFile(@PathVariable Long courseId, @PathVariable Long attachmentUnitId) { - log.debug("REST request to get file for attachment unit : {}", attachmentUnitId); + log.debug("REST request to get the file for attachment unit {} for editors", attachmentUnitId); AttachmentUnit attachmentUnit = attachmentUnitRepository.findByIdElseThrow(attachmentUnitId); Course course = courseRepository.findByIdElseThrow(courseId); Attachment attachment = attachmentUnit.getAttachment(); @@ -528,6 +510,25 @@ public ResponseEntity getAttachmentUnitFile(@PathVariable Long courseId, return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); } + /** + * GET /files/courses/{courseId}/attachments/{attachmentId} : Returns the file associated with the + * given attachment ID as a downloadable resource + * + * @param courseId The ID of the course that the Attachment belongs to + * @param attachmentId the ID of the attachment to retrieve + * @return ResponseEntity containing the file as a resource + */ + @GetMapping("files/courses/{courseId}/attachments/{attachmentId}") + @EnforceAtLeastEditorInCourse + public ResponseEntity getAttachmentFile(@PathVariable Long courseId, @PathVariable Long attachmentId) { + log.debug("REST request to get attachment file : {}", attachmentId); + Attachment attachment = attachmentRepository.findByIdElseThrow(attachmentId); + Course course = courseRepository.findByIdElseThrow(courseId); + checkAttachmentExistsInCourseOrThrow(course, attachment); + + return buildFileResponse(getActualPathFromPublicPathString(attachment.getLink()), false); + } + /** * GET files/attachments/attachment-unit/{attachmentUnitId}/slide/{slideNumber} : Get the lecture unit attachment slide by slide number * @@ -565,36 +566,49 @@ public ResponseEntity getAttachmentUnitAttachmentSlide(@PathVariable Lon } /** - * Builds the response with headers, body and content type for specified path and file name + * Builds the response with headers, body and content type for specified path containing the file name * - * @param path to the file - * @param filename the name of the file + * @param path to the file including the file name + * @param cache true if the response should contain a header that allows caching; false otherwise * @return response entity */ - private ResponseEntity buildFileResponse(Path path, String filename) { - return buildFileResponse(path, filename, false); + private ResponseEntity buildFileResponse(Path path, boolean cache) { + return buildFileResponse(path.getParent(), path.getFileName().toString(), Optional.empty(), cache); } /** * Builds the response with headers, body and content type for specified path containing the file name * - * @param path to the file including the file name - * @param cache true if the response should contain a header that allows caching; false otherwise + * @param path to the file including the file name + * @param filename the name of the file + * @param cache true if the response should contain a header that allows caching; false otherwise * @return response entity */ - private ResponseEntity buildFileResponse(Path path, boolean cache) { - return buildFileResponse(path.getParent(), path.getFileName().toString(), cache); + private ResponseEntity buildFileResponse(Path path, String filename, boolean cache) { + return buildFileResponse(path, filename, Optional.empty(), cache); } /** * Builds the response with headers, body and content type for specified path and file name * - * @param path to the file - * @param filename the name of the file - * @param cache true if the response should contain a header that allows caching; false otherwise + * @param path to the file + * @param replaceFilename replaces the downloaded file's name, if provided * @return response entity */ - private ResponseEntity buildFileResponse(Path path, String filename, boolean cache) { + private ResponseEntity buildFileResponse(Path path, Optional replaceFilename) { + return buildFileResponse(path.getParent(), path.getFileName().toString(), replaceFilename, false); + } + + /** + * Builds the response with headers, body and content type for specified path and file name + * + * @param path to the file + * @param filename the name of the file + * @param replaceFilename replaces the downloaded file's name, if provided + * @param cache true if the response should contain a header that allows caching; false otherwise + * @return response entity + */ + private ResponseEntity buildFileResponse(Path path, String filename, Optional replaceFilename, boolean cache) { try { Path actualPath = path.resolve(filename); byte[] file = fileService.getFileForPath(actualPath); @@ -609,7 +623,7 @@ private ResponseEntity buildFileResponse(Path path, String filename, boo String contentType = lowerCaseFilename.endsWith("htm") || lowerCaseFilename.endsWith("html") || lowerCaseFilename.endsWith("svg") || lowerCaseFilename.endsWith("svgz") ? "attachment" : "inline"; - headers.setContentDisposition(ContentDisposition.builder(contentType).filename(filename).build()); + headers.setContentDisposition(ContentDisposition.builder(contentType).filename(replaceFilename.orElse(filename)).build()); var response = ResponseEntity.ok().headers(headers).contentType(getMediaTypeFromFilename(filename)).header("filename", filename); if (cache) { diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java index 6c26cab4798f..2ac600477861 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java @@ -6,7 +6,7 @@ import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Arrays; -import java.util.LinkedHashSet; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -35,6 +35,7 @@ import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseDeletionSummaryDTO; +import de.tum.cit.aet.artemis.core.dto.CourseGroupsDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; @@ -94,14 +95,14 @@ public AdminCourseResource(UserRepository userRepository, CourseService courseSe @GetMapping("courses/groups") public ResponseEntity> getAllGroupsForAllCourses() { log.debug("REST request to get all Groups for all Courses"); - List courses = courseRepository.findAll(); - Set groups = new LinkedHashSet<>(); - for (Course course : courses) { - groups.add(course.getInstructorGroupName()); - groups.add(course.getEditorGroupName()); - groups.add(course.getTeachingAssistantGroupName()); - groups.add(course.getStudentGroupName()); - } + Set courseGroups = courseRepository.findAllCourseGroups(); + Set groups = new HashSet<>(); + courseGroups.forEach(courseGroup -> { + groups.add(courseGroup.instructorGroupName()); + groups.add(courseGroup.editorGroupName()); + groups.add(courseGroup.teachingAssistantGroupName()); + groups.add(courseGroup.studentGroupName()); + }); return ResponseEntity.ok().body(groups); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index 3681b3a94221..1101f94d4708 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -29,6 +29,7 @@ import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -417,7 +418,7 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH p.submissions WHERE p.exercise.id = :exerciseId AND p.student.id = :studentId - """) + """) List findByExerciseIdAndStudentIdWithEagerResultsAndSubmissions(@Param("exerciseId") long exerciseId, @Param("studentId") long studentId); @Query(""" @@ -455,12 +456,11 @@ default List findByExerciseIdWithManualResultAndFeedbacksA """) List findByExerciseIdAndTeamIdWithEagerResultsAndLegalSubmissionsAndTeamStudents(@Param("exerciseId") long exerciseId, @Param("teamId") long teamId); + // NOTE: we should not fetch too elements here so we leave out feedback and test cases, otherwise the query will be very slow @Query(""" SELECT DISTINCT p FROM StudentParticipation p LEFT JOIN FETCH p.results r - LEFT JOIN FETCH r.feedbacks f - LEFT JOIN FETCH f.testCase LEFT JOIN FETCH r.submission s WHERE p.exercise.id = :exerciseId AND p.student.id = :studentId @@ -487,13 +487,12 @@ Optional findByExerciseIdAndStudentIdAndTestRunWithLatestR * @param exerciseId the exercise id the participations should belong to * @return a list of participations including their submitted submissions that do not have a manual result */ + // NOTE: we should not fetch too elements here so we leave out feedback and test cases, otherwise the query will be very slow @Query(""" SELECT DISTINCT p FROM StudentParticipation p LEFT JOIN FETCH p.submissions submission LEFT JOIN FETCH submission.results result - LEFT JOIN FETCH result.feedbacks feedbacks - LEFT JOIN FETCH feedbacks.testCase LEFT JOIN FETCH result.assessor WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE @@ -503,7 +502,8 @@ SELECT COUNT(r2) WHERE r2.assessor IS NOT NULL AND (r2.rated IS NULL OR r2.rated = FALSE) AND r2.submission = submission - ) AND :correctionRound = ( + ) + AND :correctionRound = ( SELECT COUNT(r) FROM Result r WHERE r.assessor IS NOT NULL @@ -514,14 +514,16 @@ AND r.assessmentType IN ( de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL, de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC ) AND (p.exercise.dueDate IS NULL OR r.submission.submissionDate <= p.exercise.dueDate) - ) AND :correctionRound = ( + ) + AND :correctionRound = ( SELECT COUNT(prs) FROM p.results prs WHERE prs.assessmentType IN ( de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL, de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC ) - ) AND submission.submitted = TRUE + ) + AND submission.submitted = TRUE AND submission.id = (SELECT MAX(s.id) FROM p.submissions s) """) List findByExerciseIdWithLatestSubmissionWithoutManualResultsAndIgnoreTestRunParticipation(@Param("exerciseId") long exerciseId, @@ -529,13 +531,12 @@ List findByExerciseIdWithLatestSubmissionWithoutManualResu Set findDistinctAllByExerciseIdInAndStudentId(Set exerciseIds, Long studentId); + // NOTE: we should not fetch too elements here so we leave out feedback and test cases, otherwise the query will be very slow @Query(""" SELECT DISTINCT p FROM Participation p LEFT JOIN FETCH p.submissions s LEFT JOIN FETCH s.results r - LEFT JOIN FETCH r.feedbacks f - LEFT JOIN FETCH f.testCase WHERE p.exercise.id = :exerciseId AND (p.individualDueDate IS NULL OR p.individualDueDate <= :now) AND p.testRun = FALSE @@ -1016,7 +1017,7 @@ default List findByStudentExamWithEagerSubmissions(Student * Get a mapping of participation ids to the number of submission for each participation. * * @param exerciseId the id of the exercise for which to consider participations - * @return the number of submissions per participation in the given exercise + * @return a map of submissions per participation in the given exercise */ default Map countSubmissionsPerParticipationByExerciseIdAsMap(long exerciseId) { return convertListOfCountsIntoMap(countSubmissionsPerParticipationByExerciseId(exerciseId)); @@ -1027,7 +1028,7 @@ default Map countSubmissionsPerParticipationByExerciseIdAsMap(lon * * @param courseId the id of the course for which to consider participations * @param teamShortName the short name of the team for which to consider participations - * @return the number of submissions per participation in the given course for the team + * @return a map of submissions per participation in the given course for the team */ default Map countLegalSubmissionsPerParticipationByCourseIdAndTeamShortNameAsMap(long courseId, String teamShortName) { return convertListOfCountsIntoMap(countLegalSubmissionsPerParticipationByCourseIdAndTeamShortName(courseId, teamShortName)); @@ -1217,7 +1218,7 @@ SELECT COALESCE(AVG(p.presentationScore), 0) * - Occurrence range: Filters feedback where the number of occurrences (COUNT) is between the specified minimum and maximum values (inclusive). * - Error categories: Filters feedback based on error categories, which can be "Student Error", "Ares Error", or "AST Error". *
- * Grouping is done by feedback detail text, test case name, and error category. The occurrence count is filtered using the HAVING clause. + * Grouping is done by feedback detail text, test case name and error category. The occurrence count is filtered using the HAVING clause. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. * @param searchTerm The search term used for filtering the feedback detail text (optional). @@ -1233,6 +1234,7 @@ SELECT COALESCE(AVG(p.presentationScore), 0) */ @Query(""" SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO( + LISTAGG(CAST(f.id AS string), ',') WITHIN GROUP (ORDER BY f.id), COUNT(f.id), 0, f.detailText, @@ -1240,7 +1242,7 @@ SELECT COALESCE(AVG(p.presentationScore), 0) COALESCE(( SELECT MAX(t.taskName) FROM ProgrammingExerciseTask t - JOIN t.testCases tct + LEFT JOIN t.testCases tct WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName ), 'Not assigned to task'), CASE @@ -1250,12 +1252,12 @@ SELECT MAX(t.taskName) END ) FROM StudentParticipation p - JOIN p.results r ON r.id = ( + LEFT JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id ) - JOIN r.feedbacks f + LEFT JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE AND f.positive = FALSE @@ -1264,7 +1266,7 @@ SELECT MAX(pr.id) AND (:#{#filterTaskNames != NULL && #filterTaskNames.size() < 1} = TRUE OR f.testCase.testName NOT IN ( SELECT tct.testName FROM ProgrammingExerciseTask t - JOIN t.testCases tct + LEFT JOIN t.testCases tct WHERE t.taskName IN (:filterTaskNames) )) AND (:#{#filterErrorCategories != NULL && #filterErrorCategories.size() < 1} = TRUE OR CASE @@ -1290,7 +1292,7 @@ Page findFilteredFeedbackByExerciseId(@Param("exerciseId") lo @Query(""" SELECT COUNT(DISTINCT r.id) FROM StudentParticipation p - JOIN p.results r ON r.id = ( + LEFT JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id @@ -1311,17 +1313,18 @@ SELECT MAX(pr.id) * @param exerciseId The ID of the exercise for which the maximum feedback count is to be retrieved. * @return The maximum count of feedback occurrences for the given exercise. */ + // TODO: move this query to a more appropriate repository, either feedbackRepository or exerciseRepository @Query(""" SELECT MAX(feedbackCounts.feedbackCount) FROM ( SELECT COUNT(f.id) AS feedbackCount FROM StudentParticipation p - JOIN p.results r ON r.id = ( + LEFT JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id ) - JOIN r.feedbacks f + LEFT JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE AND f.positive = FALSE @@ -1329,4 +1332,33 @@ SELECT MAX(pr.id) ) AS feedbackCounts """) long findMaxCountForExercise(@Param("exerciseId") long exerciseId); + + /** + * Retrieves a paginated list of students affected by specific feedback entries for a given programming exercise. + *
+ * + * @param exerciseId for which the affected student participation data is requested. + * @param feedbackIds used to filter the participation to only those affected by specific feedback entries. + * @param pageable A {@link Pageable} object to control pagination and sorting of the results, specifying page number, page size, and sort order. + * @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback. + */ + @Query(""" + SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO( + p.exercise.course.id, + p.id, + p.student.firstName, + p.student.lastName, + p.student.login, + p.repositoryUri + ) + FROM ProgrammingExerciseStudentParticipation p + LEFT JOIN p.submissions s + LEFT JOIN s.results r + LEFT JOIN r.feedbacks f + WHERE p.exercise.id = :exerciseId + AND f.id IN :feedbackIds + AND p.testRun = FALSE + ORDER BY p.student.firstName ASC + """) + Page findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds, Pageable pageable); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/SubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/SubmissionService.java index 2669e856337f..006d52ac1eb7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/SubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/SubmissionService.java @@ -203,10 +203,13 @@ public List getAllSubmissionsAssessedByTutorForCorrect } protected List getAssessableSubmissions(Exercise exercise, boolean examMode, int correctionRound) { + // TODO: it really does not make sense to fetch these submissions with all related data from the database just to select one submission afterwards + // it would be better to fetch them with minimal related data (so we can select one) and then afterwards fetch the selected one with all related data + final List participations; if (examMode) { // Get all participations of submissions that are submitted and do not already have a manual result or belong to test run submissions. - // No manual result means that no user has started an assessment for the corresponding submission yet. + // No manual result means that no tutor has started an assessment for the corresponding submission yet. participations = studentParticipationRepository.findByExerciseIdWithLatestSubmissionWithoutManualResultsAndIgnoreTestRunParticipation(exercise.getId(), correctionRound); } @@ -218,17 +221,21 @@ protected List getAssessableSubmissions(Exercise exercise, boolean e ZonedDateTime.now()); } - List submissionsWithoutResult = participations.stream().map(Participation::findLatestLegalOrIllegalSubmission).filter(Optional::isPresent).map(Optional::get) - .toList(); + // TODO: we could move the ILLEGAL check into the database + var submissionsWithoutResult = participations.stream().map(Participation::findLatestLegalOrIllegalSubmission).filter(Optional::isPresent).map(Optional::get).toList(); if (correctionRound > 0) { // remove submission if user already assessed first correction round // if disabled, please switch tutorAssessUnique within the tests - submissionsWithoutResult = submissionsWithoutResult.stream() - .filter(submission -> !submission.getResultForCorrectionRound(correctionRound - 1).getAssessor().equals(userRepository.getUser())).toList(); + // TODO: we could move this check into the database call of the if clause above (examMode == true) to avoid fetching all results and assessors + final var user = userRepository.getUser(); + submissionsWithoutResult = submissionsWithoutResult.stream().filter(submission -> { + final var resultForCorrectionRound = submission.getResultForCorrectionRound(correctionRound - 1); + return resultForCorrectionRound != null && !resultForCorrectionRound.getAssessor().equals(user); + }).toList(); } - if (exercise.getDueDate() != null) { + if (!examMode && exercise.getDueDate() != null) { submissionsWithoutResult = selectOnlySubmissionsBeforeDueDate(submissionsWithoutResult); } diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadSubmissionService.java index ce3cc4b157d6..80d732be3981 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadSubmissionService.java @@ -255,9 +255,11 @@ public FileUploadSubmission lockAndGetFileUploadSubmission(Long submissionId, Fi * @return a locked file upload submission that needs an assessment */ public FileUploadSubmission lockAndGetFileUploadSubmissionWithoutResult(FileUploadExercise fileUploadExercise, boolean ignoreTestRunParticipations, int correctionRound) { - FileUploadSubmission fileUploadSubmission = getRandomFileUploadSubmissionEligibleForNewAssessment(fileUploadExercise, ignoreTestRunParticipations, correctionRound) + var submission = getRandomFileUploadSubmissionEligibleForNewAssessment(fileUploadExercise, ignoreTestRunParticipations, correctionRound) .orElseThrow(() -> new EntityNotFoundException("File upload submission for exercise " + fileUploadExercise.getId() + " could not be found")); - lockSubmission(fileUploadSubmission, correctionRound); - return fileUploadSubmission; + // NOTE: we load the feedback for the submission eagerly to avoid org.hibernate.LazyInitializationException + submission = fileUploadSubmissionRepository.findByIdWithEagerResultAndFeedbackAndAssessorAndAssessmentNoteAndParticipationResultsElseThrow(submission.getId()); + lockSubmission(submission, correctionRound); + return submission; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadSubmissionResource.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadSubmissionResource.java index 0d7c4a23280f..d036f978e157 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadSubmissionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadSubmissionResource.java @@ -6,7 +6,6 @@ import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; import jakarta.validation.constraints.NotNull; @@ -26,7 +25,6 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository; import de.tum.cit.aet.artemis.assessment.service.ResultService; @@ -256,18 +254,18 @@ public ResponseEntity getFileUploadSubmissionWithoutAssess if (!(fileUploadExercise instanceof FileUploadExercise)) { throw new BadRequestAlertException("The requested exercise was not found.", "exerciseId", "400"); } - Set gradingCriteria = gradingCriterionRepository.findByExerciseIdWithEagerGradingCriteria(exerciseId); - fileUploadExercise.setGradingCriteria(gradingCriteria); - final User user = userRepository.getUserWithGroupsAndAuthorities(); - + final var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, fileUploadExercise, user); // Check if tutors can start assessing the students submission - this.fileUploadSubmissionService.checkIfExerciseDueDateIsReached(fileUploadExercise); + fileUploadSubmissionService.checkIfExerciseDueDateIsReached(fileUploadExercise); // Check if the limit of simultaneously locked submissions has been reached fileUploadSubmissionService.checkSubmissionLockLimit(fileUploadExercise.getCourseViaExerciseGroupOrCourseMember().getId()); + final var gradingCriteria = gradingCriterionRepository.findByExerciseIdWithEagerGradingCriteria(exerciseId); + fileUploadExercise.setGradingCriteria(gradingCriteria); + final FileUploadSubmission submission; if (lockSubmission) { submission = fileUploadSubmissionService.lockAndGetFileUploadSubmissionWithoutResult((FileUploadExercise) fileUploadExercise, fileUploadExercise.isExamExercise(), @@ -284,7 +282,7 @@ public ResponseEntity getFileUploadSubmissionWithoutAssess final StudentParticipation studentParticipation = (StudentParticipation) submission.getParticipation(); studentParticipation.setExercise(fileUploadExercise); submission.getParticipation().getExercise().setGradingCriteria(gradingCriteria); - this.fileUploadSubmissionService.hideDetails(submission, user); + fileUploadSubmissionService.hideDetails(submission, user); } return ResponseEntity.ok(submission); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java index 4f2c5416211d..5392b3b6845d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java @@ -13,14 +13,13 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; -import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; -import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; -import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastTutorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastStudentInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastTutorInExercise; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; @@ -75,7 +74,7 @@ public ResponseEntity getGlobalSettings() { * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the course could not be found. */ @GetMapping("courses/{courseId}/raw-iris-settings") - @EnforceAtLeastEditorInCourse + @EnforceAtLeastTutorInCourse public ResponseEntity getRawCourseSettings(@PathVariable Long courseId) { var course = courseRepository.findByIdElseThrow(courseId); var irisSettings = irisSettingsService.getRawIrisSettingsFor(course); @@ -89,12 +88,9 @@ public ResponseEntity getRawCourseSettings(@PathVariable Long cour * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the exercise could not be found. */ @GetMapping("exercises/{exerciseId}/raw-iris-settings") - @EnforceAtLeastEditorInExercise + @EnforceAtLeastTutorInExercise public ResponseEntity getRawExerciseSettings(@PathVariable Long exerciseId) { var exercise = exerciseRepository.findByIdElseThrow(exerciseId); - var user = userRepository.getUserWithGroupsAndAuthorities(); - authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, exercise, user); - var combinedIrisSettings = irisSettingsService.getRawIrisSettingsFor(exercise); return ResponseEntity.ok(combinedIrisSettings); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/ExerciseUnit.java b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/ExerciseUnit.java index c609a9f7b049..8b7be910e689 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/ExerciseUnit.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/ExerciseUnit.java @@ -11,12 +11,14 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; +import jakarta.persistence.Transient; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; -import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -32,6 +34,13 @@ public class ExerciseUnit extends LectureUnit { @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) private Exercise exercise; + // Competency links are not persisted in this entity but only in the exercise itself + @Transient + @JsonSerialize + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonIgnoreProperties("lectureUnit") + private Set competencyLinks = new HashSet<>(); + public Exercise getExercise() { return exercise; } @@ -66,15 +75,16 @@ public void setReleaseDate(ZonedDateTime releaseDate) { } @Override - @JsonIgnore + @JsonSerialize + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonIgnoreProperties("lectureUnit") public Set getCompetencyLinks() { - // Set the links in the associated exercise instead - return new HashSet<>(); + return competencyLinks; } @Override public void setCompetencyLinks(Set competencyLinks) { - // Retrieve the link in the associated exercise instead" + this.competencyLinks = competencyLinks; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/AttachmentUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/AttachmentUnitRepository.java index 61f268d75b95..f43d8a101af5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/AttachmentUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/AttachmentUnitRepository.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.util.List; +import java.util.Optional; import jakarta.validation.constraints.NotNull; @@ -62,5 +63,9 @@ default List findAllByLectureIdAndAttachmentTypeElseThrow(Long l LEFT JOIN FETCH cl.competency WHERE attachmentUnit.id = :attachmentUnitId """) - AttachmentUnit findOneWithSlidesAndCompetencies(@Param("attachmentUnitId") long attachmentUnitId); + Optional findWithSlidesAndCompetenciesById(@Param("attachmentUnitId") long attachmentUnitId); + + default AttachmentUnit findWithSlidesAndCompetenciesByIdElseThrow(long attachmentUnitId) { + return getValueElseThrow(findWithSlidesAndCompetenciesById(attachmentUnitId), attachmentUnitId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java index e86d7c22e7f5..83d3ffdf6751 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/AttachmentUnitService.java @@ -117,6 +117,7 @@ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit existingAttachmentUnit.setDescription(updateUnit.getDescription()); existingAttachmentUnit.setName(updateUnit.getName()); existingAttachmentUnit.setReleaseDate(updateUnit.getReleaseDate()); + existingAttachmentUnit.setCompetencyLinks(updateUnit.getCompetencyLinks()); AttachmentUnit savedAttachmentUnit = lectureUnitService.saveWithCompetencyLinks(existingAttachmentUnit, attachmentUnitRepository::saveAndFlush); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java index 793752998f29..bda282499738 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitService.java @@ -253,7 +253,7 @@ public T saveWithCompetencyLinks(T lectureUnit, Function T savedLectureUnit = saveFunction.apply(lectureUnit); - if (Hibernate.isInitialized(links) && !links.isEmpty()) { + if (Hibernate.isInitialized(links) && links != null && !links.isEmpty()) { savedLectureUnit.setCompetencyLinks(links); reconnectCompetencyLectureUnitLinks(savedLectureUnit); savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyLectureUnitLinkRepository.saveAll(links))); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java index 0c7c2996e162..83aa9f8ebefb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentUnitResource.java @@ -100,7 +100,7 @@ public AttachmentUnitResource(AttachmentUnitRepository attachmentUnitRepository, @EnforceAtLeastEditor public ResponseEntity getAttachmentUnit(@PathVariable Long attachmentUnitId, @PathVariable Long lectureId) { log.debug("REST request to get AttachmentUnit : {}", attachmentUnitId); - AttachmentUnit attachmentUnit = attachmentUnitRepository.findByIdElseThrow(attachmentUnitId); + AttachmentUnit attachmentUnit = attachmentUnitRepository.findWithSlidesAndCompetenciesByIdElseThrow(attachmentUnitId); checkAttachmentUnitCourseAndLecture(attachmentUnit, lectureId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, attachmentUnit.getLecture().getCourse(), null); @@ -125,7 +125,7 @@ public ResponseEntity updateAttachmentUnit(@PathVariable Long le @RequestPart Attachment attachment, @RequestPart(required = false) MultipartFile file, @RequestParam(defaultValue = "false") boolean keepFilename, @RequestParam(value = "notificationText", required = false) String notificationText) { log.debug("REST request to update an attachment unit : {}", attachmentUnit); - AttachmentUnit existingAttachmentUnit = attachmentUnitRepository.findOneWithSlidesAndCompetencies(attachmentUnitId); + AttachmentUnit existingAttachmentUnit = attachmentUnitRepository.findWithSlidesAndCompetenciesByIdElseThrow(attachmentUnitId); checkAttachmentUnitCourseAndLecture(existingAttachmentUnit, lectureId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, existingAttachmentUnit.getLecture().getCourse(), null); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index df85a54b6675..baecd6cd7c57 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyService; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; @@ -66,6 +67,8 @@ public class LectureResource { private static final String ENTITY_NAME = "lecture"; + private final CompetencyService competencyService; + @Value("${jhipster.clientApp.name}") private String applicationName; @@ -89,7 +92,7 @@ public class LectureResource { public LectureResource(LectureRepository lectureRepository, LectureService lectureService, LectureImportService lectureImportService, CourseRepository courseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, ExerciseService exerciseService, ChannelService channelService, - ChannelRepository channelRepository) { + ChannelRepository channelRepository, CompetencyService competencyService) { this.lectureRepository = lectureRepository; this.lectureService = lectureService; this.lectureImportService = lectureImportService; @@ -99,6 +102,7 @@ public LectureResource(LectureRepository lectureRepository, LectureService lectu this.exerciseService = exerciseService; this.channelService = channelService; this.channelRepository = channelRepository; + this.competencyService = competencyService; } /** @@ -300,6 +304,7 @@ public ResponseEntity ingestLectures(@PathVariable Long courseId, @Requ public ResponseEntity getLectureWithDetails(@PathVariable Long lectureId) { log.debug("REST request to get lecture {} with details", lectureId); Lecture lecture = lectureRepository.findByIdWithAttachmentsAndPostsAndLectureUnitsAndCompetenciesAndCompletionsElseThrow(lectureId); + competencyService.addCompetencyLinksToExerciseUnits(lecture); Course course = lecture.getCourse(); if (course == null) { return ResponseEntity.badRequest().build(); @@ -326,9 +331,10 @@ public ResponseEntity getLectureWithDetailsAndSlides(@PathVariable long if (course == null) { return ResponseEntity.badRequest().build(); } - authCheckService.checkIsAllowedToSeeLectureElseThrow(lecture, userRepository.getUserWithGroupsAndAuthorities()); - User user = userRepository.getUserWithGroupsAndAuthorities(); + authCheckService.checkIsAllowedToSeeLectureElseThrow(lecture, user); + + competencyService.addCompetencyLinksToExerciseUnits(lecture); lectureService.filterActiveAttachmentUnits(lecture); lectureService.filterActiveAttachments(lecture, user); return ResponseEntity.ok(lecture); diff --git a/src/main/java/de/tum/cit/aet/artemis/lti/web/LtiResource.java b/src/main/java/de/tum/cit/aet/artemis/lti/web/LtiResource.java index 1dbfede94a83..cb27da1cf735 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lti/web/LtiResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lti/web/LtiResource.java @@ -18,9 +18,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,8 +37,10 @@ import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.lti.domain.LtiPlatformConfiguration; +import de.tum.cit.aet.artemis.lti.domain.OnlineCourseConfiguration; import de.tum.cit.aet.artemis.lti.repository.LtiPlatformConfigurationRepository; import de.tum.cit.aet.artemis.lti.service.LtiDeepLinkingService; +import de.tum.cit.aet.artemis.lti.service.OnlineCourseConfigurationService; import tech.jhipster.web.util.PaginationUtil; /** @@ -50,6 +55,8 @@ public class LtiResource { private final LtiDeepLinkingService ltiDeepLinkingService; + private final OnlineCourseConfigurationService onlineCourseConfigurationService; + private final CourseRepository courseRepository; private final AuthorizationCheckService authCheckService; @@ -64,13 +71,55 @@ public class LtiResource { * @param ltiDeepLinkingService Service for LTI deep linking. */ public LtiResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, LtiDeepLinkingService ltiDeepLinkingService, - LtiPlatformConfigurationRepository ltiPlatformConfigurationRepository) { + OnlineCourseConfigurationService onlineCourseConfigurationService, LtiPlatformConfigurationRepository ltiPlatformConfigurationRepository) { this.courseRepository = courseRepository; this.authCheckService = authCheckService; this.ltiDeepLinkingService = ltiDeepLinkingService; + this.onlineCourseConfigurationService = onlineCourseConfigurationService; this.ltiPlatformConfigurationRepository = ltiPlatformConfigurationRepository; } + /** + * PUT courses/:courseId/online-course-configuration : Updates the onlineCourseConfiguration for the given course. + * + * @param courseId the id of the course to update + * @param onlineCourseConfiguration the online course configuration to update + * @return the ResponseEntity with status 200 (OK) and with body the updated online course configuration + */ + @PutMapping("courses/{courseId}/online-course-configuration") + @EnforceAtLeastInstructor + @Profile(PROFILE_LTI) + public ResponseEntity updateOnlineCourseConfiguration(@PathVariable Long courseId, + @RequestBody OnlineCourseConfiguration onlineCourseConfiguration) { + log.debug("REST request to update the online course configuration for Course : {}", courseId); + + Course course = courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + + if (!course.isOnlineCourse()) { + throw new BadRequestAlertException("Course must be online course", Course.ENTITY_NAME, "courseMustBeOnline"); + } + + if (!course.getOnlineCourseConfiguration().getId().equals(onlineCourseConfiguration.getId())) { + throw new BadRequestAlertException("The onlineCourseConfigurationId does not match the id of the course's onlineCourseConfiguration", + OnlineCourseConfiguration.ENTITY_NAME, "idMismatch"); + } + + onlineCourseConfigurationService.validateOnlineCourseConfiguration(onlineCourseConfiguration); + course.setOnlineCourseConfiguration(onlineCourseConfiguration); + try { + onlineCourseConfigurationService.addOnlineCourseConfigurationToLtiConfigurations(onlineCourseConfiguration); + } + catch (Exception ex) { + log.error("Failed to add online course configuration to LTI configurations", ex); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Error when adding online course configuration to LTI configurations", ex); + } + + courseRepository.save(course); + + return ResponseEntity.ok(onlineCourseConfiguration); + } + /** * Handles the HTTP POST request for LTI 1.3 Deep Linking. This endpoint is used for deep linking of LTI links * for exercises within a course. The method populates content items with the provided course and exercise identifiers, diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingSubmissionRepository.java b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingSubmissionRepository.java index 07eb0e37c28e..70f9b42ba4f7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingSubmissionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingSubmissionRepository.java @@ -39,7 +39,7 @@ public interface ModelingSubmissionRepository extends ArtemisJpaRepository findWithResultsFeedbacksAssessorAssessmentNoteAndParticipationResultsById(Long submissionId); @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java index c8b4285de3b9..4172456a0a81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java @@ -90,18 +90,17 @@ public ModelingSubmissionService(ModelingSubmissionRepository modelingSubmission * @return the locked modeling submission */ public ModelingSubmission lockAndGetModelingSubmission(Long submissionId, ModelingExercise modelingExercise, int correctionRound) { - ModelingSubmission modelingSubmission = modelingSubmissionRepository - .findByIdWithEagerResultAndFeedbackAndAssessorAndAssessmentNoteAndParticipationResultsElseThrow(submissionId); + var submission = modelingSubmissionRepository.findByIdWithEagerResultAndFeedbackAndAssessorAndAssessmentNoteAndParticipationResultsElseThrow(submissionId); - if (modelingSubmission.getLatestResult() == null || modelingSubmission.getLatestResult().getAssessor() == null) { + if (submission.getLatestResult() == null || submission.getLatestResult().getAssessor() == null) { checkSubmissionLockLimit(modelingExercise.getCourseViaExerciseGroupOrCourseMember().getId()); if (compassService.isSupported(modelingExercise) && correctionRound == 0L) { - modelingSubmission = assignResultWithFeedbackSuggestionsToSubmission(modelingSubmission, modelingExercise); + submission = assignResultWithFeedbackSuggestionsToSubmission(submission, modelingExercise); } } - lockSubmission(modelingSubmission, correctionRound); - return modelingSubmission; + lockSubmission(submission, correctionRound); + return submission; } /** @@ -196,27 +195,18 @@ public Optional findRandomSubmissionWithoutExistingAssessmen return Optional.empty(); } - ModelingSubmission modelingSubmission = (ModelingSubmission) submissionWithoutResult.get(); + // NOTE: we load the feedback for the submission eagerly to avoid org.hibernate.LazyInitializationException + var submissionId = submissionWithoutResult.get().getId(); + var submission = modelingSubmissionRepository.findByIdWithEagerResultAndFeedbackAndAssessorAndAssessmentNoteAndParticipationResultsElseThrow(submissionId); if (lockSubmission) { if (compassService.isSupported(modelingExercise) && correctionRound == 0L) { - modelingSubmission = assignResultWithFeedbackSuggestionsToSubmission(modelingSubmission, modelingExercise); - setNumberOfAffectedSubmissionsPerElement(modelingSubmission); + submission = assignResultWithFeedbackSuggestionsToSubmission(submission, modelingExercise); + setNumberOfAffectedSubmissionsPerElement(submission); } - lockSubmission(modelingSubmission, correctionRound); + lockSubmission(submission, correctionRound); } - return Optional.of(modelingSubmission); - } - - /** - * Soft lock the submission to prevent other tutors from receiving and assessing it. We remove the model from the models waiting for assessment in Compass to prevent other - * tutors from retrieving it in the first place. Additionally, we set the assessor and save the result to soft lock the assessment in the client, i.e. the client will not allow - * tutors to assess a model when an assessor is already assigned. If no result exists for this submission we create one first. - * - * @param modelingSubmission the submission to lock - */ - private void lockSubmission(ModelingSubmission modelingSubmission, int correctionRound) { - super.lockSubmission(modelingSubmission, correctionRound); + return Optional.of(submission); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/BuildLogStatisticsDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/BuildLogStatisticsDTO.java index 00908d9d94d1..8103861bbc41 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/dto/BuildLogStatisticsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/BuildLogStatisticsDTO.java @@ -1,8 +1,46 @@ package de.tum.cit.aet.artemis.programming.dto; +import jakarta.validation.constraints.NotNull; + import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) public record BuildLogStatisticsDTO(Long buildCount, Double agentSetupDuration, Double testDuration, Double scaDuration, Double totalJobDuration, Double dependenciesDownloadedCount) { + + @NotNull + @Override + public Long buildCount() { + return buildCount != null ? buildCount : 0; + } + + @NotNull + @Override + public Double agentSetupDuration() { + return agentSetupDuration != null ? agentSetupDuration : 0.0; + } + + @NotNull + @Override + public Double testDuration() { + return testDuration != null ? testDuration : 0.0; + } + + @NotNull + @Override + public Double scaDuration() { + return scaDuration != null ? scaDuration : 0.0; + } + + @NotNull + @Override + public Double totalJobDuration() { + return totalJobDuration != null ? totalJobDuration : 0.0; + } + + @NotNull + @Override + public Double dependenciesDownloadedCount() { + return dependenciesDownloadedCount != null ? dependenciesDownloadedCount : 0.0; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildLogStatisticsEntryRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildLogStatisticsEntryRepository.java index b653d693bbf2..11f34865096b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildLogStatisticsEntryRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildLogStatisticsEntryRepository.java @@ -23,6 +23,20 @@ @Repository public interface BuildLogStatisticsEntryRepository extends ArtemisJpaRepository { + @Query(""" + SELECT new de.tum.cit.aet.artemis.programming.dto.BuildLogStatisticsDTO( + COUNT(b.id), + AVG(b.agentSetupDuration), + AVG(b.testDuration), + AVG(b.scaDuration), + AVG(b.totalJobDuration), + AVG(b.dependenciesDownloadedCount) + ) + FROM BuildLogStatisticsEntry b + WHERE b.programmingSubmission.participation.exercise = :exercise + """) + BuildLogStatisticsDTO findAverageStudentBuildLogStatistics(@Param("exercise") ProgrammingExercise exercise); + @Query(""" SELECT new de.tum.cit.aet.artemis.programming.dto.BuildLogStatisticsDTO( COUNT(b.id), @@ -35,16 +49,34 @@ public interface BuildLogStatisticsEntryRepository extends ArtemisJpaRepository< FROM BuildLogStatisticsEntry b LEFT JOIN b.programmingSubmission s LEFT JOIN s.participation p - WHERE p.exercise = :exercise - OR p.id = :templateParticipationId + WHERE p.id = :templateParticipationId OR p.id = :solutionParticipationId """) - BuildLogStatisticsDTO findAverageBuildLogStatistics(@Param("exercise") ProgrammingExercise exercise, @Param("templateParticipationId") Long templateParticipationId, + BuildLogStatisticsDTO findAverageExerciseBuildLogStatistics(@Param("templateParticipationId") Long templateParticipationId, @Param("solutionParticipationId") Long solutionParticipationId); + /** + * Find the average build log statistics for the given exercise. If the exercise has a template or solution participation, the statistics are also calculated for these + * NOTE: we cannot calculate this within one query, this would be way too slow, therefore, we split it into multiple queries and combine the result + * + * @param exercise the exercise for which the statistics should be calculated + * @return the average build log statistics + */ default BuildLogStatisticsDTO findAverageBuildLogStatistics(ProgrammingExercise exercise) { - return findAverageBuildLogStatistics(exercise, exercise.getTemplateParticipation() != null ? exercise.getTemplateParticipation().getId() : null, + var studentStatistics = findAverageStudentBuildLogStatistics(exercise); + var exerciseStatistics = findAverageExerciseBuildLogStatistics(exercise.getTemplateParticipation() != null ? exercise.getTemplateParticipation().getId() : null, exercise.getSolutionParticipation() != null ? exercise.getSolutionParticipation().getId() : null); + // build the average of two values based on the count + var studentCount = studentStatistics.buildCount(); + var exerciseCount = exerciseStatistics.buildCount(); + var count = studentCount + exerciseCount; + return new BuildLogStatisticsDTO(count, + count == 0 ? 0.0 : (studentStatistics.agentSetupDuration() * studentCount + exerciseStatistics.agentSetupDuration() * exerciseCount) / count, + count == 0 ? 0.0 : (studentStatistics.testDuration() * studentCount + exerciseStatistics.testDuration() * exerciseCount) / count, + count == 0 ? 0.0 : (studentStatistics.scaDuration() * studentCount + exerciseStatistics.scaDuration() * exerciseCount) / count, + count == 0 ? 0.0 : (studentStatistics.totalJobDuration() * studentCount + exerciseStatistics.totalJobDuration() * exerciseCount) / count, + count == 0 ? 0.0 : (studentStatistics.dependenciesDownloadedCount() * studentCount + exerciseStatistics.dependenciesDownloadedCount() * exerciseCount) / count); + } @Transactional // ok because of delete diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index 7d67f91ed418..4f9e61fda04d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -338,11 +338,6 @@ Optional findByIdWithEagerTestCasesStaticCodeAnalysisCatego Optional findByIdWithEagerBuildConfigTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndSolutionEntriesAndBuildConfig( @Param("exerciseId") long exerciseId); - default ProgrammingExercise findByIdWithEagerTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndBuildConfigElseThrow(long exerciseId) - throws EntityNotFoundException { - return getValueElseThrow(findByIdWithEagerTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndBuildConfig(exerciseId), exerciseId); - } - @Query(""" SELECT p FROM ProgrammingExercise p diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java index 628019f34eaa..76722cfd5eea 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java @@ -39,7 +39,7 @@ public interface VcsAccessLogRepository extends ArtemisJpaRepository findNewestByParticipationId(@Param("participationId") long participationId); /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ParticipationVcsAccessTokenService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ParticipationVcsAccessTokenService.java index 7c41d9f8452d..db9cebb2eb6c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ParticipationVcsAccessTokenService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ParticipationVcsAccessTokenService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ParticipationVCSAccessToken; import de.tum.cit.aet.artemis.programming.repository.ParticipationVCSAccessTokenRepository; @@ -46,18 +47,24 @@ public ParticipationVCSAccessToken createParticipationVCSAccessToken(User user, } /** - * Retrieves the participationVCSAccessToken for a User,Participation pair if it exists + * Retrieves the participationVCSAccessToken for a User,Participation pair if it exists and if the user owns the participation * - * @param userId the user's id which is owner of the token + * @param user the user which is owner of the token * @param participationId the participation's id which the token belongs to * @return an Optional participationVCSAccessToken, */ - public ParticipationVCSAccessToken findByUserIdAndParticipationIdOrElseThrow(long userId, long participationId) { - return participationVcsAccessTokenRepository.findByUserIdAndParticipationIdOrElseThrow(userId, participationId); + public ParticipationVCSAccessToken findByUserAndParticipationIdOrElseThrow(User user, long participationId) { + var participation = programmingExerciseStudentParticipationRepository.findByIdElseThrow(participationId); + if (participation.isOwnedBy(user)) { + return participationVcsAccessTokenRepository.findByUserIdAndParticipationIdOrElseThrow(user.getId(), participationId); + } + else { + throw new AccessForbiddenException("Participation not owned by user"); + } } /** - * Checks if the participationVCSAccessToken for a User,Participation pair exists, and creates a new one if not + * Checks if the participationVCSAccessToken for a User,Participation pair exists, and creates a new one if not; if the user owns the participation * * @param user the user's id which is owner of the token * @param participationId the participation's id which the token belongs to @@ -66,7 +73,12 @@ public ParticipationVCSAccessToken findByUserIdAndParticipationIdOrElseThrow(lon public ParticipationVCSAccessToken createVcsAccessTokenForUserAndParticipationIdOrElseThrow(User user, long participationId) { participationVcsAccessTokenRepository.findByUserIdAndParticipationIdAndThrowIfExists(user.getId(), participationId); var participation = programmingExerciseStudentParticipationRepository.findByIdElseThrow(participationId); - return createParticipationVCSAccessToken(user, participation); + if (participation.isOwnedBy(user)) { + return createParticipationVCSAccessToken(user, participation); + } + else { + throw new AccessForbiddenException("Participation not owned by user"); + } } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseExportService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseExportService.java index 024d34348470..20d98af84bc8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseExportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseExportService.java @@ -186,6 +186,7 @@ protected void exportProblemStatementAndEmbeddedFilesAndExerciseDetails(Exercise if (exercise instanceof ProgrammingExercise programmingExercise) { // Used for a save typecast, this should always be true since this class only works with programming exercises. programmingExerciseTaskService.replaceTestIdsWithNames(programmingExercise); + programmingExercise.setAuxiliaryRepositories(auxiliaryRepositoryRepository.findByExerciseId(exercise.getId())); } super.exportProblemStatementAndEmbeddedFilesAndExerciseDetails(exercise, exportErrors, exportDir, pathsToBeZipped); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseGradingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseGradingService.java index 097e0db39d3c..7738b6615b2f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseGradingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseGradingService.java @@ -1078,7 +1078,25 @@ private static Map categorizeStaticCodeAnalysisIssues(Result re private static void updateTestCaseMapBasedOnResultFeedback(Result result, HashMap testCaseStatsMap) { result.getFeedbacks().stream() // Filter the feedbacks to include only those that are automatic and have an assigned test case - .filter(feedback -> FeedbackType.AUTOMATIC.equals(feedback.getType()) && feedback.getTestCase() != null) + .filter(feedback -> { + if (!FeedbackType.AUTOMATIC.equals(feedback.getType())) { + return false; + } + if (feedback.getTestCase() == null) { + return false; + } + if (feedback.getTestCase().getTestName() == null) { + // Log the feedback id with null test name to analyse NullPointer issue if it occurs again in the future + log.warn("Feedback with ID {} has a test case with a null test name.", feedback.getId()); + return false; + } + if (feedback.isPositive() == null) { + // Log the feedback with null isPositive value to analyse NullPointer issue if it occurs again in the future + log.warn("Feedback with ID {} has a test case with a null isPositive value.", feedback.getId()); + return false; + } + return true; + }) // Collect the filtered feedbacks into a map grouped by test case name, and partitioned by whether the feedback is positive .collect(Collectors.groupingBy( // Group by the name of the test case associated with the feedback diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java index 3c8b1671cba9..784e29599185 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java @@ -8,6 +8,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -33,6 +35,7 @@ import de.tum.cit.aet.artemis.core.service.FileService; import de.tum.cit.aet.artemis.core.service.ProfileService; import de.tum.cit.aet.artemis.core.service.ZipFileService; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.Repository; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; @@ -170,44 +173,72 @@ private void importRepositoriesFromFile(ProgrammingExercise newExercise, Path ba Repository templateRepo = gitService.getOrCheckoutRepository(new VcsRepositoryUri(newExercise.getTemplateRepositoryUri()), false); Repository solutionRepo = gitService.getOrCheckoutRepository(new VcsRepositoryUri(newExercise.getSolutionRepositoryUri()), false); Repository testRepo = gitService.getOrCheckoutRepository(new VcsRepositoryUri(newExercise.getTestRepositoryUri()), false); + List auxiliaryRepositories = new ArrayList<>(); + for (AuxiliaryRepository auxiliaryRepository : newExercise.getAuxiliaryRepositories()) { + auxiliaryRepositories.add(gitService.getOrCheckoutRepository(auxiliaryRepository.getVcsRepositoryUri(), false)); + } - copyImportedExerciseContentToRepositories(templateRepo, solutionRepo, testRepo, basePath); - replaceImportedExerciseShortName(Map.of(oldExerciseShortName, newExercise.getShortName()), templateRepo, solutionRepo, testRepo); + copyImportedExerciseContentToRepositories(templateRepo, solutionRepo, testRepo, auxiliaryRepositories, basePath); + replaceImportedExerciseShortName(Map.of(oldExerciseShortName, newExercise.getShortName()), List.of(solutionRepo, templateRepo, testRepo)); + replaceImportedExerciseShortName(Map.of(oldExerciseShortName, newExercise.getShortName()), auxiliaryRepositories); gitService.stageAllChanges(templateRepo); gitService.stageAllChanges(solutionRepo); gitService.stageAllChanges(testRepo); + for (Repository auxRepo : auxiliaryRepositories) { + gitService.stageAllChanges(auxRepo); + } gitService.commitAndPush(templateRepo, "Import template from file", true, user); gitService.commitAndPush(solutionRepo, "Import solution from file", true, user); gitService.commitAndPush(testRepo, "Import tests from file", true, user); + for (Repository auxRepo : auxiliaryRepositories) { + gitService.commitAndPush(auxRepo, "Import auxiliary repo from file", true, user); + } + } - private void replaceImportedExerciseShortName(Map replacements, Repository... repositories) { + private void replaceImportedExerciseShortName(Map replacements, List repositories) { for (Repository repository : repositories) { fileService.replaceVariablesInFileRecursive(repository.getLocalPath(), replacements, SHORT_NAME_REPLACEMENT_EXCLUSIONS); } } - private void copyImportedExerciseContentToRepositories(Repository templateRepo, Repository solutionRepo, Repository testRepo, Path basePath) throws IOException { + private void copyImportedExerciseContentToRepositories(Repository templateRepo, Repository solutionRepo, Repository testRepo, List auxiliaryRepositories, + Path basePath) throws IOException { repositoryService.deleteAllContentInRepository(templateRepo); repositoryService.deleteAllContentInRepository(solutionRepo); repositoryService.deleteAllContentInRepository(testRepo); - copyExerciseContentToRepository(templateRepo, RepositoryType.TEMPLATE, basePath); - copyExerciseContentToRepository(solutionRepo, RepositoryType.SOLUTION, basePath); - copyExerciseContentToRepository(testRepo, RepositoryType.TESTS, basePath); + for (Repository auxRepo : auxiliaryRepositories) { + repositoryService.deleteAllContentInRepository(auxRepo); + } + + copyExerciseContentToRepository(templateRepo, RepositoryType.TEMPLATE.getName(), basePath); + copyExerciseContentToRepository(solutionRepo, RepositoryType.SOLUTION.getName(), basePath); + copyExerciseContentToRepository(testRepo, RepositoryType.TESTS.getName(), basePath); + for (Repository auxRepo : auxiliaryRepositories) { + String[] parts = auxRepo.getLocalPath().toString().split("-"); + var auxRepoName = String.join("-", Arrays.copyOfRange(parts, 1, parts.length)); + copyExerciseContentToRepository(auxRepo, auxRepoName, basePath); + } } /** * Copies everything from the extracted zip file to the repository, except the .git folder * - * @param repository the repository to which the content should be copied - * @param repositoryType the type of the repository - * @param basePath the path to the extracted zip file + * @param repository the repository to which the content should be copied + * @param repoName the name of the repository + * @param basePath the path to the extracted zip file **/ - private void copyExerciseContentToRepository(Repository repository, RepositoryType repositoryType, Path basePath) throws IOException { - FileUtils.copyDirectory(retrieveRepositoryDirectoryPath(basePath, repositoryType.getName()).toFile(), repository.getLocalPath().toFile(), - new NotFileFilter(new NameFileFilter(".git"))); + private void copyExerciseContentToRepository(Repository repository, String repoName, Path basePath) throws IOException { + // @formatter:off + FileUtils.copyDirectory( + retrieveRepositoryDirectoryPath(basePath, repoName).toFile(), + repository.getLocalPath().toFile(), + new NotFileFilter(new NameFileFilter(".git")) + ); + // @formatter:on + try (var files = Files.walk(repository.getLocalPath())) { files.filter(file -> "gradlew".equals(file.getFileName().toString())).forEach(file -> file.toFile().setExecutable(true)); } @@ -242,17 +273,17 @@ private void checkRepositoryForTypeExists(Path path, RepositoryType repoType) th } } - private Path retrieveRepositoryDirectoryPath(Path dirPath, String repoType) { + private Path retrieveRepositoryDirectoryPath(Path dirPath, String repoName) { List result; try (Stream walk = Files.walk(dirPath)) { - result = walk.filter(Files::isDirectory).filter(file -> file.getFileName().toString().endsWith("-" + repoType)).toList(); + result = walk.filter(Files::isDirectory).filter(file -> file.getFileName().toString().endsWith("-" + repoName)).toList(); } catch (IOException e) { throw new BadRequestAlertException("Could not read the directory", "programmingExercise", "couldnotreaddirectory"); } if (result.size() != 1) { throw new IllegalArgumentException( - "There are either no or more than one sub-directories containing " + repoType + " in their name. Please make sure that there is exactly one."); + "There are either no or more than one sub-directories containing " + repoName + " in their name. Please make sure that there is exactly one."); } return result.getFirst(); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java index 1684cf52c018..0b1a14be8646 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseTaskService.java @@ -350,7 +350,7 @@ private String convertTestNameToTestIdReplacement(String testName, Set service.updateRepositoryActionType(participation, repositoryActionType)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingSubmissionResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingSubmissionResource.java index db197fed2c26..7762e4f07c30 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingSubmissionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingSubmissionResource.java @@ -398,8 +398,8 @@ public ResponseEntity getProgrammingSubmissionWithoutAsse if (submission != null) { if (lockSubmission) { - Result lockedResult = programmingSubmissionService.lockSubmission(submission, correctionRound); - submission = (ProgrammingSubmission) lockedResult.getSubmission(); + // NOTE: we explicitly load the feedback for the submission eagerly to avoid org.hibernate.LazyInitializationException + submission = programmingSubmissionService.lockAndGetProgrammingSubmission(submission.getId(), correctionRound); } submission.getParticipation().setExercise(programmingExercise); programmingSubmissionService.hideDetails(submission, user); diff --git a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextSubmissionRepository.java b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextSubmissionRepository.java index 9929c1b5f9db..604964966874 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextSubmissionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextSubmissionRepository.java @@ -33,8 +33,8 @@ public interface TextSubmissionRepository extends ArtemisJpaRepository findWithEagerResultsById(long submissionId); + @EntityGraph(type = LOAD, attributePaths = { "results.assessor" }) + Optional findWithEagerResultsAssessorById(long submissionId); /** * @param submissionId the submission id we are interested in @@ -43,7 +43,7 @@ public interface TextSubmissionRepository extends ArtemisJpaRepository findWithEagerResultsAndFeedbackAndTextBlocksById(long submissionId); - @EntityGraph(type = LOAD, attributePaths = { "results", "results.assessor", "blocks", "results.feedbacks" }) + @EntityGraph(type = LOAD, attributePaths = { "results.assessor", "blocks", "results.feedbacks" }) Optional findWithEagerResultAndTextBlocksAndFeedbackByResults_Id(long resultId); @NotNull diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextSubmissionService.java index a7f5ff325f1a..2df4ae21c2ab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextSubmissionService.java @@ -155,11 +155,15 @@ public Optional getRandomTextSubmissionEligibleForNewAssessment( /** * Lock a given text submission that still needs to be assessed to prevent other tutors from receiving and assessing it. * - * @param textSubmission textSubmission to be locked - * @param correctionRound get submission with results in the correction round + * @param textSubmissionId id of the textSubmission to be locked + * @param correctionRound get submission with results in the correction round + * @return the locked textSubmission */ - public void lockTextSubmissionToBeAssessed(TextSubmission textSubmission, int correctionRound) { + public TextSubmission lockTextSubmissionToBeAssessed(long textSubmissionId, int correctionRound) { + // NOTE: we load the feedback for the submission eagerly to avoid org.hibernate.LazyInitializationException + final var textSubmission = textSubmissionRepository.findByIdWithEagerResultsAndFeedbackAndTextBlocksElseThrow(textSubmissionId); lockSubmission(textSubmission, correctionRound); + return textSubmission; } public TextSubmission findOneWithEagerResultFeedbackAndTextBlocks(Long submissionId) { diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextAssessmentResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextAssessmentResource.java index 900347ecc54e..0f1df354c04b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextAssessmentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextAssessmentResource.java @@ -348,14 +348,14 @@ public ResponseEntity deleteAssessment(@PathVariable Long participationId, * @param submissionId the id of the submission we want * @param correctionRound correction round for which we want the submission * @param resultId if result already exists, we want to get the submission for this specific result - * @return a Participation of the tutor in the submission + * @return a Participation with relevant data for a tutor or instructor to assess the submission */ @GetMapping("text-submissions/{submissionId}/for-assessment") @EnforceAtLeastTutor public ResponseEntity retrieveParticipationForSubmission(@PathVariable Long submissionId, @RequestParam(value = "correction-round", defaultValue = "0") int correctionRound, @RequestParam(value = "resultId", required = false) Long resultId) { log.debug("REST request to get data for tutors text assessment submission: {}", submissionId); - final var textSubmission = textSubmissionRepository.findByIdWithParticipationExerciseResultAssessorAssessmentNoteElseThrow(submissionId); + var textSubmission = textSubmissionRepository.findByIdWithParticipationExerciseResultAssessorAssessmentNoteElseThrow(submissionId); final Participation participation = textSubmission.getParticipation(); final var exercise = participation.getExercise(); final User user = userRepository.getUserWithGroupsAndAuthorities(); @@ -387,7 +387,9 @@ public ResponseEntity retrieveParticipationForSubmission(@PathVar .build(); } - textSubmissionService.lockTextSubmissionToBeAssessed(textSubmission, correctionRound); + textSubmission = textSubmissionService.lockTextSubmissionToBeAssessed(textSubmission.getId(), correctionRound); + // reconnect with participation + textSubmission.setParticipation(participation); // set it since it has changed result = textSubmission.getResultForCorrectionRound(correctionRound); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextSubmissionResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextSubmissionResource.java index 359077783330..b25506a90aef 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextSubmissionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextSubmissionResource.java @@ -166,7 +166,7 @@ private ResponseEntity handleTextSubmission(long exerciseId, Tex @EnforceAtLeastStudent public ResponseEntity getTextSubmissionWithResults(@PathVariable long submissionId) { log.debug("REST request to get text submission: {}", submissionId); - var textSubmission = textSubmissionRepository.findWithEagerResultsById(submissionId).orElseThrow(() -> new EntityNotFoundException("TextSubmission", submissionId)); + var textSubmission = textSubmissionRepository.findWithEagerResultsAssessorById(submissionId).orElseThrow(() -> new EntityNotFoundException("TextSubmission", submissionId)); if (!authCheckService.isAtLeastTeachingAssistantForExercise(textSubmission.getParticipation().getExercise())) { // anonymize and throw exception if not authorized to view submission @@ -219,7 +219,7 @@ public ResponseEntity getTextSubmissionWithoutAssessment(@PathVa } // Check if tutors can start assessing the students submission - this.textSubmissionService.checkIfExerciseDueDateIsReached(exercise); + textSubmissionService.checkIfExerciseDueDateIsReached(exercise); // Check if the limit of simultaneously locked submissions has been reached textSubmissionService.checkSubmissionLockLimit(exercise.getCourseViaExerciseGroupOrCourseMember().getId()); @@ -232,10 +232,10 @@ public ResponseEntity getTextSubmissionWithoutAssessment(@PathVa return ResponseEntity.ok(null); } - final TextSubmission textSubmission = optionalTextSubmission.get(); + TextSubmission textSubmission = optionalTextSubmission.get(); if (lockSubmission) { - textSubmissionService.lockTextSubmissionToBeAssessed(optionalTextSubmission.get(), correctionRound); + textSubmission = textSubmissionService.lockTextSubmissionToBeAssessed(textSubmission.getId(), correctionRound); textAssessmentService.prepareSubmissionForAssessment(textSubmission, textSubmission.getResultForCorrectionRound(correctionRound)); } diff --git a/src/main/resources/config/liquibase/changelog/20241112123600_changelog.xml b/src/main/resources/config/liquibase/changelog/20241112123600_changelog.xml new file mode 100644 index 000000000000..c49a4b3c480a --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241112123600_changelog.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index dcc5ce7ab632..b85c513ebd96 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -34,6 +34,7 @@ + diff --git a/src/main/webapp/app/app.module.ts b/src/main/webapp/app/app.module.ts index 8f90b73a34cb..1e43beff4fc1 100644 --- a/src/main/webapp/app/app.module.ts +++ b/src/main/webapp/app/app.module.ts @@ -21,7 +21,7 @@ import { OrionOutdatedComponent } from 'app/shared/orion/outdated-plugin-warning import { LoadingNotificationComponent } from 'app/shared/notification/loading-notification/loading-notification.component'; import { NotificationPopupComponent } from 'app/shared/notification/notification-popup/notification-popup.component'; import { UserSettingsModule } from 'app/shared/user-settings/user-settings.module'; -import { ThemeModule } from 'app/core/theme/theme.module'; +import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { artemisIconPack } from 'src/main/webapp/content/icons/icons'; @@ -42,7 +42,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; ArtemisComplaintsModule, ArtemisHeaderExercisePageWithDetailsModule, UserSettingsModule, - ThemeModule, + ThemeSwitchComponent, ArtemisSharedComponentModule, ScrollingModule, ], diff --git a/src/main/webapp/app/core/theme/theme-switch.component.html b/src/main/webapp/app/core/theme/theme-switch.component.html index 24772ad09ea6..55581a7c807b 100644 --- a/src/main/webapp/app/core/theme/theme-switch.component.html +++ b/src/main/webapp/app/core/theme/theme-switch.component.html @@ -2,9 +2,9 @@

☾ ☾
-
{{ 'artemisApp.theme.sync' | artemisTranslate }}
+
- +
@@ -17,10 +17,10 @@ [triggers]="''" #popover="ngbPopover" [autoClose]="false" - [animation]="animate" - [placement]="popoverPlacement" + [animation]="true" + [placement]="popoverPlacement()" > -
+

- @if (irisCompetencyGenerationEnabled) { - + @if (irisCompetencyGenerationEnabled()) { + @@ -24,7 +24,7 @@

- @if (isLoading) { + @if (isLoading()) {
@@ -32,19 +32,19 @@

}
diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts index e54e4f3975a1..c0c3c3936f71 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts @@ -1,9 +1,8 @@ -import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, computed, effect, inject, signal, untracked } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; -import { Competency, CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; -import { onError } from 'app/shared/util/global.utils'; -import { Subject, Subscription } from 'rxjs'; +import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; +import { firstValueFrom, map } from 'rxjs'; import { faCircleQuestion, faEdit, faFileImport, faPencilAlt, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; @@ -11,7 +10,6 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { PROFILE_IRIS } from 'app/app.constants'; import { FeatureToggle, FeatureToggleService } from 'app/shared/feature-toggle/feature-toggle.service'; -import { Prerequisite } from 'app/entities/prerequisite.model'; import { ImportAllCourseCompetenciesModalComponent, ImportAllCourseCompetenciesResult, @@ -23,6 +21,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { CourseCompetenciesRelationModalComponent } from 'app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component'; import { CourseCompetencyExplanationModalComponent } from 'app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'jhi-competency-management', @@ -30,20 +29,7 @@ import { CourseCompetencyExplanationModalComponent } from 'app/course/competenci standalone: true, imports: [CompetencyManagementTableComponent, TranslateDirective, FontAwesomeModule, RouterModule, ArtemisSharedComponentModule], }) -export class CompetencyManagementComponent implements OnInit, OnDestroy { - courseId: number; - isLoading = false; - irisCompetencyGenerationEnabled = false; - private dialogErrorSource = new Subject(); - dialogError = this.dialogErrorSource.asObservable(); - standardizedCompetenciesEnabled = false; - private standardizedCompetencySubscription: Subscription; - - competencies: Competency[] = []; - prerequisites: Prerequisite[] = []; - courseCompetencies: CourseCompetency[] = []; - - // Icons +export class CompetencyManagementComponent implements OnInit { protected readonly faEdit = faEdit; protected readonly faPlus = faPlus; protected readonly faFileImport = faFileImport; @@ -52,12 +38,10 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { protected readonly faRobot = faRobot; protected readonly faCircleQuestion = faCircleQuestion; - // other constants readonly getIcon = getIcon; readonly documentationType: DocumentationType = 'Competencies'; readonly CourseCompetencyType = CourseCompetencyType; - // Injected services private readonly activatedRoute = inject(ActivatedRoute); private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); private readonly alertService = inject(AlertService); @@ -66,58 +50,61 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { private readonly irisSettingsService = inject(IrisSettingsService); private readonly featureToggleService = inject(FeatureToggleService); - ngOnInit(): void { - this.activatedRoute.parent!.params.subscribe(async (params) => { - this.courseId = Number(params['courseId']); - await this.loadData(); - this.loadIrisEnabled(); + readonly courseId = toSignal(this.activatedRoute.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); + readonly isLoading = signal(false); + + readonly courseCompetencies = signal([]); + competencies = computed(() => this.courseCompetencies().filter((cc) => cc.type === CourseCompetencyType.COMPETENCY)); + prerequisites = computed(() => this.courseCompetencies().filter((cc) => cc.type === CourseCompetencyType.PREREQUISITE)); + + private readonly irisEnabled = toSignal(this.profileService.getProfileInfo().pipe(map((profileInfo) => profileInfo?.activeProfiles?.includes(PROFILE_IRIS))), { + initialValue: false, + }); + + irisCompetencyGenerationEnabled = signal(false); + standardizedCompetenciesEnabled = toSignal(this.featureToggleService.getFeatureToggleActive(FeatureToggle.StandardizedCompetencies), { requireSync: true }); + + constructor() { + effect(() => { + const courseId = this.courseId(); + untracked(async () => await this.loadCourseCompetencies(courseId)); + }); + effect(() => { + const irisEnabled = this.irisEnabled(); + untracked(async () => { + if (irisEnabled) { + await this.loadIrisEnabled(); + } + }); }); + } + + ngOnInit(): void { const lastVisit = sessionStorage.getItem('lastTimeVisitedCourseCompetencyExplanation'); if (!lastVisit) { this.openCourseCompetencyExplanation(); } sessionStorage.setItem('lastTimeVisitedCourseCompetencyExplanation', Date.now().toString()); - this.standardizedCompetencySubscription = this.featureToggleService.getFeatureToggleActive(FeatureToggle.StandardizedCompetencies).subscribe((isActive) => { - this.standardizedCompetenciesEnabled = isActive; - }); } - ngOnDestroy() { - this.dialogErrorSource.unsubscribe(); - if (this.standardizedCompetencySubscription) { - this.standardizedCompetencySubscription.unsubscribe(); + private async loadIrisEnabled() { + try { + const combinedCourseSettings = await firstValueFrom(this.irisSettingsService.getCombinedCourseSettings(this.courseId())); + this.irisCompetencyGenerationEnabled.set(combinedCourseSettings?.irisCompetencyGenerationSettings?.enabled ?? false); + } catch (error) { + this.alertService.error(error); } } - /** - * Sends a request to determine if Iris and Competency Generation is enabled - * - * @private - */ - private loadIrisEnabled() { - this.profileService.getProfileInfo().subscribe((profileInfo) => { - const irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); - if (irisEnabled) { - this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { - this.irisCompetencyGenerationEnabled = settings?.irisCompetencyGenerationSettings?.enabled ?? false; - }); - } - }); - } - - /** - * Loads all data for the competency management: Prerequisites and competencies (with average course progress) - */ - async loadData() { + private async loadCourseCompetencies(courseId: number) { try { - this.isLoading = true; - this.courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesByCourseId(this.courseId); - this.competencies = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.COMPETENCY); - this.prerequisites = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.PREREQUISITE); + this.isLoading.set(true); + const courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesByCourseId(courseId); + this.courseCompetencies.set(courseCompetencies); } catch (error) { - onError(this.alertService, error); + this.alertService.error(error); } finally { - this.isLoading = false; + this.isLoading.set(false); } } @@ -127,8 +114,8 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { backdrop: 'static', windowClass: 'course-competencies-relation-graph-modal', }); - modalRef.componentInstance.courseId = signal(this.courseId); - modalRef.componentInstance.courseCompetencies = signal(this.courseCompetencies); + modalRef.componentInstance.courseId = signal(this.courseId()); + modalRef.componentInstance.courseCompetencies = signal(this.courseCompetencies()); } /** @@ -139,14 +126,14 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { size: 'lg', backdrop: 'static', }); - modalRef.componentInstance.courseId = signal(this.courseId); + modalRef.componentInstance.courseId = signal(this.courseId()); const importResults: ImportAllCourseCompetenciesResult | undefined = await modalRef.result; if (!importResults) { return; } const courseTitle = importResults.course.title ?? ''; try { - const importedCompetencies = await this.courseCompetencyApiService.importAllByCourseId(this.courseId, importResults.courseCompetencyImportOptions); + const importedCompetencies = await this.courseCompetencyApiService.importAllByCourseId(this.courseId(), importResults.courseCompetencyImportOptions); if (importedCompetencies.length) { this.alertService.success(`artemisApp.courseCompetency.importAll.success`, { noOfCompetencies: importedCompetencies.length, @@ -157,7 +144,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { this.alertService.warning(`artemisApp.courseCompetency.importAll.warning`, { courseTitle: courseTitle }); } } catch (error) { - onError(this.alertService, error); + this.alertService.error(error); } } @@ -167,18 +154,15 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { * @private */ updateDataAfterImportAll(res: Array) { - const importedCompetencies = res.map((dto) => dto.competency).filter((element): element is Competency => element?.type === CourseCompetencyType.COMPETENCY); - const importedPrerequisites = res.map((dto) => dto.competency).filter((element): element is Prerequisite => element?.type === CourseCompetencyType.PREREQUISITE); - - this.competencies = this.competencies.concat(importedCompetencies); - this.prerequisites = this.prerequisites.concat(importedPrerequisites); - this.courseCompetencies = this.competencies.concat(this.prerequisites); + const importedCourseCompetencies = res.map((dto) => dto.competency!); + const newCourseCompetencies = importedCourseCompetencies.filter( + (competency) => !this.courseCompetencies().some((existingCompetency) => existingCompetency.id === competency.id), + ); + this.courseCompetencies.update((courseCompetencies) => courseCompetencies.concat(newCourseCompetencies)); } onRemoveCompetency(competencyId: number) { - this.competencies = this.competencies.filter((competency) => competency.id !== competencyId); - this.prerequisites = this.prerequisites.filter((prerequisite) => prerequisite.id !== competencyId); - this.courseCompetencies = this.competencies.concat(this.prerequisites); + this.courseCompetencies.update((courseCompetencies) => courseCompetencies.filter((cc) => cc.id !== competencyId)); } openCourseCompetencyExplanation(): void { diff --git a/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.html b/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.html index afd6368b570d..56a6347b3aed 100644 --- a/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.html +++ b/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.html @@ -55,6 +55,7 @@ labelName="{{ 'artemisApp.' + courseCompetency?.type + '.create.softDueDate' | artemisTranslate }}" labelTooltip="{{ 'artemisApp.' + courseCompetency?.type + '.create.softDueDateHint' | artemisTranslate }}" formControlName="softDueDate" + [pickerType]="DateTimePickerType.CALENDAR" /> @if (!isInConnectMode) { diff --git a/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.ts b/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.ts index cd512a1d12f6..e7c2cadf3ad4 100644 --- a/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.ts +++ b/src/main/webapp/app/course/competencies/forms/common-course-competency-form.component.ts @@ -15,6 +15,7 @@ import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.mo import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { merge } from 'rxjs'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { DateTimePickerType } from 'app/shared/date-time-picker/date-time-picker.component'; @Component({ selector: 'jhi-common-course-competency-form', @@ -54,6 +55,7 @@ export class CommonCourseCompetencyFormComponent implements OnInit, OnChanges { onTitleOrDescriptionChange = new EventEmitter(); protected readonly competencyValidators = CourseCompetencyValidators; + protected readonly DateTimePickerType = DateTimePickerType; suggestedTaxonomies: string[] = []; diff --git a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts index ac7a27af9f09..28594a2f49d9 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts @@ -1,25 +1,26 @@ -import { Component, effect, inject, input, signal } from '@angular/core'; -import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome'; +import { ChangeDetectionStrategy, Component, effect, inject, input, signal, untracked } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; import { AlertService } from 'app/core/util/alert.service'; import { CompetencyGraphDTO } from 'app/entities/competency/learning-path.model'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-competency-graph-modal', standalone: true, - imports: [FontAwesomeModule, CompetencyGraphComponent, ArtemisSharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FontAwesomeModule, CompetencyGraphComponent, TranslateDirective], templateUrl: './competency-graph-modal.component.html', styleUrl: './competency-graph-modal.component.scss', }) export class CompetencyGraphModalComponent { - protected readonly closeIcon: IconDefinition = faXmark; + protected readonly closeIcon = faXmark; - private readonly learningPathApiService: LearningPathApiService = inject(LearningPathApiService); - private readonly alertService: AlertService = inject(AlertService); + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); readonly learningPathId = input.required(); @@ -28,7 +29,13 @@ export class CompetencyGraphModalComponent { private readonly activeModal: NgbActiveModal = inject(NgbActiveModal); constructor() { - effect(() => this.loadCompetencyGraph(this.learningPathId()), { allowSignalWrites: true }); + effect( + () => { + const learningPathId = this.learningPathId(); + untracked(() => this.loadCompetencyGraph(learningPathId)); + }, + { allowSignalWrites: true }, + ); } private async loadCompetencyGraph(learningPathId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/competency-graph/competency-graph.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-graph/competency-graph.component.ts index 88cd93ac83e2..0e0bbe4c14a1 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-graph/competency-graph.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-graph/competency-graph.component.ts @@ -1,20 +1,18 @@ -import { Component, computed, effect, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, signal } from '@angular/core'; import { NgxGraphModule, NgxGraphZoomOptions } from '@swimlane/ngx-graph'; import { Subject } from 'rxjs'; -import { CompetencyGraphDTO, NodeType } from 'app/entities/competency/learning-path.model'; +import { CompetencyGraphDTO } from 'app/entities/competency/learning-path.model'; import { CompetencyNodeComponent, SizeUpdate } from 'app/course/learning-paths/components/competency-node/competency-node.component'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; @Component({ selector: 'jhi-competency-graph', standalone: true, - imports: [CompetencyNodeComponent, NgxGraphModule, ArtemisSharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CompetencyNodeComponent, NgxGraphModule], templateUrl: './competency-graph.component.html', styleUrl: './competency-graph.component.scss', }) export class CompetencyGraphComponent { - protected readonly NodeType = NodeType; - readonly competencyGraph = input.required(); private readonly internalCompetencyGraph = signal({ diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts index 5365bb22387b..e4d60b32ef47 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, computed, inject, input, output } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, computed, inject, input, output } from '@angular/core'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { NgbAccordionModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { NodeDimension } from '@swimlane/ngx-graph'; @@ -13,6 +13,7 @@ export interface SizeUpdate { @Component({ selector: 'jhi-learning-path-competency-node', standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgbDropdownModule, FontAwesomeModule, NgbAccordionModule, CommonModule], templateUrl: './competency-node.component.html', styleUrl: './competency-node.component.scss', diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-exercise/learning-path-exercise.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-exercise/learning-path-exercise.component.ts index 05f904046533..99f230ce1938 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-exercise/learning-path-exercise.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-exercise/learning-path-exercise.component.ts @@ -1,18 +1,19 @@ -import { Component, InputSignal, ViewContainerRef, effect, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewContainerRef, effect, inject, input } from '@angular/core'; import { CourseExerciseDetailsComponent } from 'app/overview/exercise-details/course-exercise-details.component'; import { CourseExerciseDetailsModule } from 'app/overview/exercise-details/course-exercise-details.module'; @Component({ selector: 'jhi-learning-path-exercise', standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [CourseExerciseDetailsModule], templateUrl: './learning-path-exercise.component.html', }) export class LearningPathExerciseComponent { - public readonly courseId: InputSignal = input.required(); - public readonly exerciseId: InputSignal = input.required(); + public readonly courseId = input.required(); + public readonly exerciseId = input.required(); - private readonly viewContainerRef: ViewContainerRef = inject(ViewContainerRef); + private readonly viewContainerRef = inject(ViewContainerRef); constructor() { effect(() => { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts index 77f4cf3dd262..04822ff754d8 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AlertService } from 'app/core/util/alert.service'; import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; @@ -6,18 +6,19 @@ import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture- import { LectureUnitCompletionEvent } from 'app/overview/course-lectures/course-lecture-details.component'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; import { lastValueFrom } from 'rxjs'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; import { VideoUnitComponent } from 'app/overview/course-lectures/video-unit/video-unit.component'; import { TextUnitComponent } from 'app/overview/course-lectures/text-unit/text-unit.component'; import { AttachmentUnitComponent } from 'app/overview/course-lectures/attachment-unit/attachment-unit.component'; import { OnlineUnitComponent } from 'app/overview/course-lectures/online-unit/online-unit.component'; import { isCommunicationEnabled } from 'app/entities/course.model'; import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-learning-path-lecture-unit', standalone: true, - imports: [ArtemisLectureUnitsModule, ArtemisSharedModule, VideoUnitComponent, TextUnitComponent, AttachmentUnitComponent, OnlineUnitComponent, DiscussionSectionComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ArtemisLectureUnitsModule, VideoUnitComponent, TextUnitComponent, AttachmentUnitComponent, OnlineUnitComponent, DiscussionSectionComponent, TranslateDirective], templateUrl: './learning-path-lecture-unit.component.html', }) export class LearningPathLectureUnitComponent { @@ -36,7 +37,13 @@ export class LearningPathLectureUnitComponent { readonly isCommunicationEnabled = computed(() => isCommunicationEnabled(this.lecture()?.course)); constructor() { - effect(() => this.loadLectureUnit(this.lectureUnitId()), { allowSignalWrites: true }); + effect( + () => { + const lectureUnitId = this.lectureUnitId(); + untracked(() => this.loadLectureUnit(lectureUnitId)); + }, + { allowSignalWrites: true }, + ); } async loadLectureUnit(lectureUnitId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts index 97609dd46c27..21c0fa4c1c32 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component.ts @@ -1,47 +1,49 @@ -import { Component, InputSignal, OutputEmitterRef, Signal, WritableSignal, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; import { AlertService } from 'app/core/util/alert.service'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; import { LearningPathNavigationObjectDTO } from 'app/entities/competency/learning-path.model'; -import { IconDefinition, faCheckCircle, faLock } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faLock } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { CommonModule } from '@angular/common'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-learning-path-nav-overview-learning-objects', standalone: true, - imports: [NgbAccordionModule, FontAwesomeModule, ArtemisSharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgbAccordionModule, FontAwesomeModule, CommonModule, TranslateDirective], templateUrl: './learning-path-nav-overview-learning-objects.component.html', styleUrl: './learning-path-nav-overview-learning-objects.component.scss', }) export class LearningPathNavOverviewLearningObjectsComponent { - protected readonly faCheckCircle: IconDefinition = faCheckCircle; - protected readonly faLock: IconDefinition = faLock; + protected readonly faCheckCircle = faCheckCircle; + protected readonly faLock = faLock; - private readonly alertService: AlertService = inject(AlertService); - private readonly learningPathApiService: LearningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly learningPathApiService = inject(LearningPathApiService); private readonly learningPathNavigationService = inject(LearningPathNavigationService); - readonly learningPathId: InputSignal = input.required(); - readonly competencyId: InputSignal = input.required(); + readonly learningPathId = input.required(); + readonly competencyId = input.required(); // competency id of current competency of learning path (not the one of the selected learning object) - readonly currentCompetencyIdOnPath: InputSignal = input.required(); - readonly currentLearningObject: Signal = this.learningPathNavigationService.currentLearningObject; + readonly currentCompetencyIdOnPath = input.required(); + readonly currentLearningObject = this.learningPathNavigationService.currentLearningObject; - readonly isLoading: WritableSignal = signal(false); - readonly learningObjects: WritableSignal = signal(undefined); + readonly isLoading = signal(false); + readonly learningObjects = signal(undefined); - readonly nextLearningObjectOnPath: Signal = computed(() => + readonly nextLearningObjectOnPath = computed(() => this.competencyId() === this.currentCompetencyIdOnPath() ? this.learningObjects()?.find((learningObject) => !learningObject.completed) : undefined, ); - readonly onLearningObjectSelected: OutputEmitterRef = output(); + readonly onLearningObjectSelected = output(); constructor() { effect( () => { - untracked(async () => await this.loadLearningObjects()); + untracked(() => this.loadLearningObjects()); }, { allowSignalWrites: true }, ); diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts index 07722fb3d0e7..6bf5c49cea7a 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts @@ -1,46 +1,53 @@ -import { Component, InputSignal, OutputEmitterRef, Signal, WritableSignal, computed, effect, inject, input, output, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal, untracked, viewChild } from '@angular/core'; import { NgbAccordionDirective, NgbAccordionModule, NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { IconDefinition, faCheckCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'; import { AlertService } from 'app/core/util/alert.service'; import { LearningPathCompetencyDTO } from 'app/entities/competency/learning-path.model'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; import { CompetencyGraphModalComponent } from 'app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component'; import { LearningPathNavOverviewLearningObjectsComponent } from 'app/course/learning-paths/components/learning-path-nav-overview-learning-objects/learning-path-nav-overview-learning-objects.component'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-learning-path-nav-overview', standalone: true, - imports: [FontAwesomeModule, CommonModule, NgbDropdownModule, NgbAccordionModule, ArtemisSharedModule, LearningPathNavOverviewLearningObjectsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FontAwesomeModule, CommonModule, NgbDropdownModule, NgbAccordionModule, LearningPathNavOverviewLearningObjectsComponent, TranslateDirective], templateUrl: './learning-path-nav-overview.component.html', styleUrl: './learning-path-nav-overview.component.scss', }) export class LearningPathNavOverviewComponent { - protected readonly faCheckCircle: IconDefinition = faCheckCircle; + protected readonly faCheckCircle = faCheckCircle; - private readonly alertService: AlertService = inject(AlertService); - private readonly modalService: NgbModal = inject(NgbModal); - private readonly learningPathApiService: LearningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly modalService = inject(NgbModal); + private readonly learningPathApiService = inject(LearningPathApiService); private readonly learningPathNavigationService = inject(LearningPathNavigationService); - readonly learningPathId: InputSignal = input.required(); + readonly learningPathId = input.required(); - readonly competencyAccordion: Signal = viewChild.required(NgbAccordionDirective); + readonly competencyAccordion = viewChild.required(NgbAccordionDirective); - readonly onLearningObjectSelected: OutputEmitterRef = output(); - readonly isLoading: WritableSignal = signal(false); + readonly onLearningObjectSelected = output(); + readonly isLoading = signal(false); readonly competencies = signal([]); // competency id of currently selected learning object - readonly currentCompetencyId: Signal = computed(() => this.learningPathNavigationService.currentLearningObject()?.competencyId); + readonly currentCompetencyId = computed(() => this.learningPathNavigationService.currentLearningObject()?.competencyId); // current competency of learning path (not the one of the selected learning object) - readonly currentCompetencyOnPath: Signal = computed(() => this.competencies()?.find((competency) => competency.masteryProgress < 1)); + readonly currentCompetencyOnPath = computed(() => this.competencies()?.find((competency) => competency.masteryProgress < 1)); constructor() { - effect(async () => await this.loadCompetencies(this.learningPathId()), { allowSignalWrites: true }); + effect( + () => { + const learningPathId = this.learningPathId(); + untracked(() => this.loadCompetencies(learningPathId)); + }, + { allowSignalWrites: true }, + ); } async loadCompetencies(learningPathId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts index d243c506179b..dd938e28bd80 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.ts @@ -1,17 +1,18 @@ -import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { LearningPathNavigationObjectDTO } from 'app/entities/competency/learning-path.model'; import { CommonModule } from '@angular/common'; import { NgbAccordionModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faCheckCircle, faChevronDown, faChevronLeft, faChevronRight, faFlag, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { LearningPathNavOverviewComponent } from 'app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-learning-path-student-nav', standalone: true, - imports: [CommonModule, NgbDropdownModule, NgbAccordionModule, FontAwesomeModule, LearningPathNavOverviewComponent, ArtemisSharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NgbDropdownModule, NgbAccordionModule, FontAwesomeModule, LearningPathNavOverviewComponent, TranslateDirective], templateUrl: './learning-path-student-nav.component.html', styleUrl: './learning-path-student-nav.component.scss', }) diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts index 41a8261eb206..6c2678d30c5a 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts @@ -1,15 +1,17 @@ -import { Component, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, input, signal, untracked } from '@angular/core'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; import { CompetencyGraphDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; import { AlertService } from 'app/core/util/alert.service'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; import { onError } from 'app/shared/util/global.utils'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { CommonModule } from '@angular/common'; @Component({ selector: 'jhi-learning-paths-analytics', standalone: true, - imports: [ArtemisSharedCommonModule, CompetencyGraphComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CompetencyGraphComponent, TranslateDirective, CommonModule], templateUrl: './learning-paths-analytics.component.html', styleUrl: './learning-paths-analytics.component.scss', }) @@ -27,7 +29,13 @@ export class LearningPathsAnalyticsComponent { readonly valueSelection = signal(CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS); constructor() { - effect(() => this.loadInstructionCompetencyGraph(this.courseId()), { allowSignalWrites: true }); + effect( + () => { + const courseId = this.courseId(); + untracked(() => this.loadInstructionCompetencyGraph(courseId)); + }, + { allowSignalWrites: true }, + ); } private async loadInstructionCompetencyGraph(courseId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts index 23e5cb8e8612..6f2d305f54cc 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts @@ -1,17 +1,18 @@ -import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { LearningPathApiService } from '../../services/learning-path-api.service'; import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ selector: 'jhi-learning-paths-configuration', standalone: true, - imports: [FontAwesomeModule, ArtemisSharedCommonModule, ArtemisSharedComponentModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FontAwesomeModule, ArtemisSharedComponentModule, TranslateDirective], templateUrl: './learning-paths-configuration.component.html', styleUrls: ['../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], }) @@ -32,7 +33,13 @@ export class LearningPathsConfigurationComponent { readonly includeAllGradedExercisesEnabled = computed(() => this.learningPathsConfiguration()?.includeAllGradedExercises ?? false); constructor() { - effect(() => this.loadLearningPathsConfiguration(this.courseId()), { allowSignalWrites: true }); + effect( + () => { + const courseId = this.courseId(); + untracked(() => this.loadLearningPathsConfiguration(courseId)); + }, + { allowSignalWrites: true }, + ); } private async loadLearningPathsConfiguration(courseId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts index e5fc57092472..a8460830ca71 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts @@ -1,16 +1,19 @@ -import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; -import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { ActivatedRoute, Router } from '@angular/router'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @Component({ selector: 'jhi-learning-paths-state', standalone: true, - imports: [ArtemisSharedCommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslateDirective, CommonModule, FontAwesomeModule], templateUrl: './learning-paths-state.component.html', styleUrls: ['./learning-paths-state.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], }) @@ -42,7 +45,13 @@ export class LearningPathsStateComponent { readonly learningPathHealthState = computed(() => this.learningPathHealth()?.status ?? []); constructor() { - effect(() => this.loadLearningPathHealthState(this.courseId()), { allowSignalWrites: true }); + effect( + () => { + const courseId = this.courseId(); + untracked(() => this.loadLearningPathHealthState(courseId)); + }, + { allowSignalWrites: true }, + ); } protected async loadLearningPathHealthState(courseId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html index 4f4b23a690e0..a97a5bbcd377 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html @@ -55,6 +55,7 @@
(false); constructor() { - effect(() => this.loadCourse(this.courseId()), { allowSignalWrites: true }); + effect( + () => { + const courseId = this.courseId(); + untracked(() => this.loadCourse(courseId)); + }, + { allowSignalWrites: true }, + ); } private async loadCourse(courseId: number): Promise { diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts b/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts index 7d83742015f0..72c77fbf2d40 100644 --- a/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-student-page/learning-path-student-page.component.ts @@ -1,4 +1,4 @@ -import { Component, effect, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, signal, untracked } from '@angular/core'; import { LearningObjectType, LearningPathDTO } from 'app/entities/competency/learning-path.model'; import { map } from 'rxjs'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -16,6 +16,7 @@ import { TranslateDirective } from 'app/shared/language/translate.directive'; selector: 'jhi-learning-path-student-page', templateUrl: './learning-path-student-page.component.html', styleUrl: './learning-path-student-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [LearningPathNavComponent, LearningPathLectureUnitComponent, LearningPathExerciseComponent, TranslateDirective], }) @@ -34,7 +35,13 @@ export class LearningPathStudentPageComponent { readonly isLearningPathNavigationLoading = this.learningPathNavigationService.isLoading; constructor() { - effect(() => this.loadLearningPath(this.courseId()), { allowSignalWrites: true }); + effect( + () => { + const courseId = this.courseId(); + untracked(() => this.loadLearningPath(courseId)); + }, + { allowSignalWrites: true }, + ); } private async loadLearningPath(courseId: number): Promise { diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.html b/src/main/webapp/app/exam/participate/exam-participation.component.html index 391c435fee09..e90b28528020 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.html +++ b/src/main/webapp/app/exam/participate/exam-participation.component.html @@ -56,22 +56,40 @@
@switch (exercise.type) { @case (QUIZ) { - + @if (exercise.studentParticipations[0].submissions) { + + } } @case (FILEUPLOAD) { - + @if (exercise.studentParticipations[0].submissions) { + + } } @case (TEXT) { - + @if (exercise.studentParticipations[0].submissions) { + + } } @case (MODELING) { - + @if (exercise.studentParticipations[0].submissions) { + + } } @case (PROGRAMMING) { + + + + diff --git a/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.scss b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.scss new file mode 100644 index 000000000000..0b4d4eb0b98c --- /dev/null +++ b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.scss @@ -0,0 +1,3 @@ +.saved { + --fa-secondary-opacity: 1; +} diff --git a/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.ts b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.ts new file mode 100644 index 000000000000..01e627a6941f --- /dev/null +++ b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.ts @@ -0,0 +1,25 @@ +import { Component, input, output } from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons'; +import { facSaveSuccess } from '../../../../../content/icons/icons'; +import { Submission } from 'app/entities/submission.model'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +@Component({ + selector: 'jhi-exercise-save-button', + templateUrl: './exercise-save-button.component.html', + styleUrls: ['./exercise-save-button.component.scss'], + standalone: true, + imports: [FaIconComponent, TranslateDirective], +}) +export class ExerciseSaveButtonComponent { + protected readonly faFloppyDisk = faFloppyDisk; + protected readonly facSaveSuccess = facSaveSuccess; + + submission = input(); + save = output(); + + onSave() { + this.save.emit(); + } +} diff --git a/src/main/webapp/app/exam/participate/exercises/file-upload/file-upload-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/file-upload/file-upload-exam-submission.component.html index be860bc79852..e85e710164e9 100644 --- a/src/main/webapp/app/exam/participate/exercises/file-upload/file-upload-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/file-upload/file-upload-exam-submission.component.html @@ -3,14 +3,14 @@

{{ exercise.exerciseGroup?.title }} - -  ({{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}@if (exercise.bonusPoints) { - , {{ exercise.bonusPoints }} {{ 'artemisApp.examParticipation.bonus' | artemisTranslate }} - }) @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { - - } + + + @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { + + }


diff --git a/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.html index aa3306277c53..d1e89ff3f1ea 100644 --- a/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.html @@ -1,17 +1,20 @@ @if (exercise) { -

- - {{ exercise.exerciseGroup?.title }} - - -  ({{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}@if (exercise.bonusPoints) { +
+

+ + {{ exercise.exerciseGroup?.title }} + , {{ exercise.bonusPoints }} {{ 'artemisApp.examParticipation.bonus' | artemisTranslate }} - }) @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { - - } -

+ [jhiTranslate]="exercise.bonusPoints ? 'artemisApp.examParticipation.bonus' : 'artemisApp.examParticipation.points'" + [translateValues]="{ points: exercise.maxPoints, bonusPoints: exercise.bonusPoints }" + > + + @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { + + } +

+ +

@@ -38,7 +41,7 @@

-   +   diff --git a/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.ts b/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.ts index 60957411fcf8..d16f4d56ff6d 100644 --- a/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.ts +++ b/src/main/webapp/app/exam/participate/exercises/modeling/modeling-exam-submission.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, ViewChild, input, output } from '@angular/core'; import { UMLModel } from '@ls1intum/apollon'; import dayjs from 'dayjs/esm'; import { ModelingSubmission } from 'app/entities/modeling-submission.model'; @@ -34,12 +34,17 @@ export class ModelingExamSubmissionComponent extends ExamSubmissionComponent imp exercise: ModelingExercise; umlModel: UMLModel; // input model for Apollon+ + // explicitly needed to track if submission.isSynced is changed, otherwise component + // does not update the state due to onPush strategy + isSubmissionSynced = input(); + saveCurrentExercise = output(); + explanationText: string; // current explanation text readonly IncludedInOverallScore = IncludedInOverallScore; // Icons - farListAlt = faListAlt; + protected readonly faListAlt = faListAlt; constructor(changeDetectorReference: ChangeDetectorRef) { super(changeDetectorReference); @@ -154,4 +159,11 @@ export class ModelingExamSubmissionComponent extends ExamSubmissionComponent imp this.changeDetectorReference.detectChanges(); } } + + /** + * Trigger save action in exam participation component + */ + notifyTriggerSave() { + this.saveCurrentExercise.emit(); + } } diff --git a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html index 2bb8078c07ce..35129261f2ec 100644 --- a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html @@ -2,14 +2,14 @@

{{ exercise.exerciseGroup?.title }} - -  ({{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}@if (exercise.bonusPoints) { - , {{ exercise.bonusPoints }} {{ 'artemisApp.examParticipation.bonus' | artemisTranslate }} - }) @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { - - } + + + @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { + + }


diff --git a/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.html index ace69b781b7e..3bbe2afbdfe3 100644 --- a/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.html @@ -1,14 +1,15 @@ -

- - {{ quizConfiguration.exerciseGroup?.title }} - - ({{ quizConfiguration.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}) +
+

+ + {{ quizConfiguration.exerciseGroup?.title }} + + @if (quizConfiguration.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { } - -

+

+ +
diff --git a/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.ts b/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.ts index f967003238ba..26aa88a762d5 100644 --- a/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.ts +++ b/src/main/webapp/app/exam/participate/exercises/quiz/quiz-exam-submission.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, Input, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, QueryList, ViewChildren, output } from '@angular/core'; import { Exercise, ExerciseType, IncludedInOverallScore } from 'app/entities/exercise.model'; import { AbstractQuizSubmission } from 'app/entities/quiz/abstract-quiz-exam-submission.model'; import { AnswerOption } from 'app/entities/quiz/answer-option.model'; @@ -53,6 +53,8 @@ export class QuizExamSubmissionComponent extends ExamSubmissionComponent impleme @Input() examTimeline = false; @Input() quizConfiguration: QuizConfiguration; + saveCurrentExercise = output(); + selectedAnswerOptions = new Map(); dragAndDropMappings = new Map(); shortAnswerSubmittedTexts = new Map(); @@ -285,4 +287,11 @@ export class QuizExamSubmissionComponent extends ExamSubmissionComponent impleme this.submissionVersion = submissionVersion; this.updateViewFromSubmissionVersion(); } + + /** + * Trigger save action in exam participation component + */ + notifyTriggerSave() { + this.saveCurrentExercise.emit(); + } } diff --git a/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.html index cc0a6fba438f..4087130be3ea 100644 --- a/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.html @@ -1,17 +1,20 @@ @if (exercise) { -

- - {{ exercise.exerciseGroup?.title }} - - -  ({{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}@if (exercise.bonusPoints) { +
+

+ + {{ exercise.exerciseGroup?.title }} + , {{ exercise.bonusPoints }} {{ 'artemisApp.examParticipation.bonus' | artemisTranslate }} - }) @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { - - } -

+ [jhiTranslate]="exercise.bonusPoints ? 'artemisApp.examParticipation.bonus' : 'artemisApp.examParticipation.points'" + [translateValues]="{ points: exercise.maxPoints, bonusPoints: exercise.bonusPoints }" + > + + @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { + + } +

+ +

@@ -40,7 +43,7 @@

-   +   diff --git a/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.ts b/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.ts index 3a493af08507..cb2dc3c0fd51 100644 --- a/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.ts +++ b/src/main/webapp/app/exam/participate/exercises/text/text-exam-submission.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, output } from '@angular/core'; import { TextEditorService } from 'app/exercises/text/participate/text-editor.service'; import { Subject } from 'rxjs'; import { TextSubmission } from 'app/entities/text/text-submission.model'; @@ -6,7 +6,7 @@ import { StringCountService } from 'app/exercises/text/participate/string-count. import { Exercise, ExerciseType, IncludedInOverallScore } from 'app/entities/exercise.model'; import { ExamSubmissionComponent } from 'app/exam/participate/exercises/exam-submission.component'; import { Submission } from 'app/entities/submission.model'; -import { faListAlt } from '@fortawesome/free-regular-svg-icons'; +import { faListAlt } from '@fortawesome/free-solid-svg-icons'; import { MAX_SUBMISSION_TEXT_LENGTH } from 'app/shared/constants/input.constants'; import { SubmissionVersion } from 'app/entities/submission-version.model'; import { htmlForMarkdown } from 'app/shared/util/markdown.conversion.util'; @@ -26,6 +26,8 @@ export class TextExamSubmissionComponent extends ExamSubmissionComponent impleme @Input() exercise: Exercise; + saveCurrentExercise = output(); + readonly IncludedInOverallScore = IncludedInOverallScore; readonly maxCharacterCount = MAX_SUBMISSION_TEXT_LENGTH; @@ -35,7 +37,7 @@ export class TextExamSubmissionComponent extends ExamSubmissionComponent impleme private textEditorInput = new Subject(); // Icons - farListAlt = faListAlt; + protected readonly faListAlt = faListAlt; constructor( private textService: TextEditorService, @@ -121,4 +123,11 @@ export class TextExamSubmissionComponent extends ExamSubmissionComponent impleme this.submissionVersion = submissionVersion; this.updateViewFromSubmissionVersion(); } + + /** + * Trigger save action in exam participation component + */ + notifyTriggerSave() { + this.saveCurrentExercise.emit(); + } } diff --git a/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html index 74dfce8f5638..4c9d11e7aab6 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html @@ -11,7 +11,7 @@
@if (resultsPublished && exerciseInfo?.achievedPoints !== undefined) {
- [{{ exerciseInfo?.achievedPoints }} / {{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}] + [{{ exerciseInfo?.achievedPoints }} / {{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.exercisePoints' | artemisTranslate }}]
@if (exerciseInfo?.resultIconClass) { @@ -22,7 +22,7 @@
} @else { - [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}] + [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.exercisePoints' | artemisTranslate }}] }
diff --git a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.html b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.html index 8f0ff9e7f5dd..0c47167bc90a 100644 --- a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.html +++ b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.html @@ -129,9 +129,9 @@
- @if (highlightedElements && highlightedElements.size > 0) { + @if (highlightedElements() && highlightedElements().size > 0) {
-
+
@@ -160,7 +160,7 @@
} diff --git a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts index 72328f495613..27d7cd03894d 100644 --- a/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/example-modeling/example-modeling-submission.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; +import { Component, OnInit, ViewChild, computed, effect, inject, signal, untracked } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; @@ -40,6 +40,8 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke @ViewChild(ModelingAssessmentComponent, { static: false }) assessmentEditor: ModelingAssessmentComponent; + private readonly themeService = inject(ThemeService); + isNewSubmission: boolean; assessmentMode = false; exerciseId: number; @@ -93,15 +95,14 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke return [...this.referencedFeedback, ...this.unreferencedFeedback]; } - highlightedElements = new Map(); + highlightedElements = signal>(new Map()); referencedExampleFeedback: Feedback[] = []; - highlightColor = 'lightblue'; + highlightColor = computed(() => (this.themeService.userPreference() === Theme.DARK ? 'darkblue' : 'lightblue')); // Icons faSave = faSave; faCircle = faCircle; faInfoCircle = faInfoCircle; - faExclamation = faExclamation; faCodeBranch = faCodeBranch; faChalkboardTeacher = faChalkboardTeacher; @@ -114,9 +115,19 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke private route: ActivatedRoute, private router: Router, private navigationUtilService: ArtemisNavigationUtilService, - private changeDetector: ChangeDetectorRef, - private themeService: ThemeService, - ) {} + ) { + effect(() => { + // Update highlighted elements as soon as current theme changes + const highlightColor = this.highlightColor(); + untracked(() => { + const updatedHighlights = new Map(); + this.highlightedElements().forEach((_, key) => { + updatedHighlights.set(key, highlightColor); + }); + this.highlightedElements.set(updatedHighlights); + }); + }); + } ngOnInit(): void { this.exerciseId = Number(this.route.snapshot.paramMap.get('exerciseId')); @@ -138,20 +149,6 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke this.assessmentMode = true; } this.loadAll(); - - this.themeService.getPreferenceObservable().subscribe((themeOrUndefined) => { - if (themeOrUndefined === Theme.DARK) { - this.highlightColor = 'darkblue'; - } else { - this.highlightColor = 'lightblue'; - } - - const updatedHighlights = new Map(); - this.highlightedElements.forEach((_, key) => { - updatedHighlights.set(key, this.highlightColor); - }); - this.highlightedElements = updatedHighlights; - }); } private loadAll(): void { @@ -478,11 +475,11 @@ export class ExampleModelingSubmissionComponent implements OnInit, FeedbackMarke const missedReferencedExampleFeedbacks = this.referencedExampleFeedback.filter( (feedback) => !this.referencedFeedback.some((referencedFeedback) => referencedFeedback.reference === feedback.reference), ); - this.highlightedElements = new Map(); + const highlightedElements = new Map(); for (const feedback of missedReferencedExampleFeedbacks) { - this.highlightedElements.set(feedback.referenceId!, this.highlightColor); + highlightedElements.set(feedback.referenceId!, this.highlightColor()); } - this.changeDetector.detectChanges(); + this.highlightedElements.set(highlightedElements); } readAndUnderstood() { diff --git a/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.html b/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.html index d29c46e6c02f..1c7637eb8455 100644 --- a/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.html +++ b/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.html @@ -108,7 +108,11 @@