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(? if (result.resultType == TestResult.ResultType.FAILURE) { - String failedTest = "${descriptor.className}::${descriptor.name}" + var failedTest = "${descriptor.className}::${descriptor.name}" logger.debug("Adding " + failedTest + " to failedTests...") failedTests << [failedTest] } @@ -618,7 +634,7 @@ tasks.withType(Test).configureEach { } wrapper { - gradleVersion = "8.10.2" + gradleVersion = "8.11" } tasks.register("stage") { diff --git a/docker/playwright-E2E-tests-multi-node.yml b/docker/playwright-E2E-tests-multi-node.yml index bd8a0f52ff05..62b424023433 100644 --- a/docker/playwright-E2E-tests-multi-node.yml +++ b/docker/playwright-E2E-tests-multi-node.yml @@ -109,14 +109,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-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/admin/cleanup-service.rst b/docs/admin/cleanup-service.rst new file mode 100644 index 000000000000..38cb74ad98f3 --- /dev/null +++ b/docs/admin/cleanup-service.rst @@ -0,0 +1,47 @@ +.. _cleanup: + +Cleanup Service +=============== + +Artemis provides a feature to delete data from older courses. + +.. _cleanup-menu: + +.. figure:: cleanup/cleanup-menu.png + :align: center + :alt: Cleanup view + +As shown in the image, administrators can delete the following data types: + +* Plagiarism results with an undecided outcome +* Orphaned data +* Non-rated results from older courses +* Rated results from older courses + +Since orphaned data has no connections to other data by nature, it is deleted without considering specific dates. +For other types, administrators can track the related exercises and courses. +When a cleanup operation is performed with specified "from" and "to" dates, all data associated with that type and related to courses that started after the "from" date and ended before the "to" date is deleted. + +Data Deletion by Operation Type +------------------------------------------ + +1. **Orphaned Data**: + - Long Feedback Text with feedback that has no results + - Text Block with feedback that has no results + - Feedback records without results + - Student and team scores where either a student or a team is specified + - Long Feedback Text where both participation and submission are missing + - Text Block where the referenced feedback has no associated participation or submission + - Feedback with no associated participation or submission + - All Ratings where the related result has no associated participation or submission + - Results without associated participation or submission + +2. **Plagiarism Results with an Undecided Outcome**: + - All plagiarism comparisons related to courses within the specified dates and marked as undecided. + +3. **Rated and Non-rated Results**: + - Both types follow the same logic, except for the rating status of results (rated vs. non-rated). + - For each type, only the latest valid result within a participation is retained, while all others are deleted. + - Because direct result deletion is restricted due to data integrity reasons, Artemis first removes associated data for results scheduled for deletion, including Long Feedback Text, Text Block, Feedback, Student Score, and Team Score. + +Artemis also records the date of the last cleanup operation, as seen in the last column of the table shown in the image. diff --git a/docs/admin/cleanup/cleanup-menu.png b/docs/admin/cleanup/cleanup-menu.png new file mode 100644 index 000000000000..59b00b1dc503 Binary files /dev/null and b/docs/admin/cleanup/cleanup-menu.png differ 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/dev/setup/docker-compose.rst b/docs/dev/setup/docker-compose.rst index a655f44e6ab7..0ed185bab82f 100644 --- a/docs/dev/setup/docker-compose.rst +++ b/docs/dev/setup/docker-compose.rst @@ -21,7 +21,7 @@ Getting Started with Docker Compose Make sure that Docker Desktop has enough memory (~ 6GB). To adapt it, go to ``Settings -> Resources``. 2. Check that all local network ports used by Docker Compose are free (e.g. you haven't started a local MySQL server - when you would like to start a Docker Compose instance of mysql) + when you would like to start a Docker Compose instance of MySQL). 3. Run ``docker compose pull && docker compose up`` in the directory ``docker/`` 4. Open the Artemis instance in your browser at https://localhost 5. Run ``docker compose down`` in the directory ``docker/`` to stop and remove the docker containers @@ -62,13 +62,12 @@ Three example commands to run such setups: .. code:: bash - docker compose -f docker/atlassian.yml up docker compose -f docker/mysql.yml -f docker/gitlab-jenkins.yml up docker compose -f docker/artemis-dev-postgres.yml up .. tip:: There is also a single ``docker-compose.yml`` in the directory ``docker/`` which mirrors the setup of ``artemis-prod-mysql.yml``. - This should provide a quick way, without manual changes necessary, for new contributors to startup an Artemis instance. + This should provide a quick way, without manual changes necessary, for new contributors to start up an Artemis instance. If the documentation just mentions to run ``docker compose`` without a ``-f `` argument, it's assumed you are running the command from the ``docker/`` directory. @@ -82,7 +81,7 @@ is defined in the following files: * ``gitlab.yml``: **GitLab Service** * ``jenkins.yml``: **Jenkins Service** -For testing mails or SAML logins, you can append the following services to any setup with an artemis container: +For testing mails or SAML logins, you can append the following services to any setup with an Artemis container: * ``mailhog.yml``: **Mailhog Service** (email testing tool) * ``saml-test.yml``: **Saml-Test Service** (SAML Test Identity Provider for testing SAML features) @@ -145,7 +144,7 @@ Get a shell into the containers ``docker compose exec artemis-app bash`` or if the container is not yet running: ``docker compose run --rm artemis-app bash`` - mysql container: - ``docker compose exec mysql bash`` or directly into mysql ``docker compose exec mysql mysql`` + ``docker compose exec mysql bash`` or directly into MySQL ``docker compose exec mysql mysql`` Analog for other services. @@ -157,7 +156,7 @@ Other useful commands - Stop, remove containers and volumes: ``docker compose down -v`` - Remove Artemis-related volumes/state: ``docker volume rm artemis-data artemis-mysql-data`` - This is helpful in setups where you just want to delete the state of artemis + This is helpful in setups where you just want to delete the state of Artemis but not of Jenkins and GitLab for instance. - Stop a service: ``docker compose stop `` (restart via ``docker compose start ``) diff --git a/docs/dev/setup/jenkins-gitlab.rst b/docs/dev/setup/jenkins-gitlab.rst index 3adbbabac478..8f117cff3cca 100644 --- a/docs/dev/setup/jenkins-gitlab.rst +++ b/docs/dev/setup/jenkins-gitlab.rst @@ -890,7 +890,8 @@ and the corresponding Docker image can be found on .. code:: bash - docker compose -f docker/.yml up --build -d + docker compose -f docker/.yml build --no-cache + docker compose -f docker/.yml up -d 3. Build the new Docker image: diff --git a/docs/index.rst b/docs/index.rst index f4a7bc4cf449..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 @@ -79,6 +78,7 @@ All these exercises are supposed to be run either live in the lecture with insta admin/knownIssues admin/benchmarking-tool admin/telemetry + admin/cleanup-service .. toctree:: 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/docs/user/exams/student/access_exam.png b/docs/user/exams/student/access_exam.png index 76a0dddd02f3..a3579e5fc874 100644 Binary files a/docs/user/exams/student/access_exam.png and b/docs/user/exams/student/access_exam.png differ diff --git a/docs/user/exams/student/buttons/exam_hand_in_early.png b/docs/user/exams/student/buttons/exam_hand_in_early.png index d443c4116554..266d30608fbf 100644 Binary files a/docs/user/exams/student/buttons/exam_hand_in_early.png and b/docs/user/exams/student/buttons/exam_hand_in_early.png differ diff --git a/docs/user/exams/student/buttons/save_exercise.png b/docs/user/exams/student/buttons/save_exercise.png new file mode 100644 index 000000000000..7e16a5f622b5 Binary files /dev/null and b/docs/user/exams/student/buttons/save_exercise.png differ diff --git a/docs/user/exams/student/buttons/upload.png b/docs/user/exams/student/buttons/upload.png new file mode 100644 index 000000000000..cbe7b44079f7 Binary files /dev/null and b/docs/user/exams/student/buttons/upload.png differ diff --git a/docs/user/exams/student/exam_bar.png b/docs/user/exams/student/exam_bar.png index d1a4728bcc57..02914123a784 100644 Binary files a/docs/user/exams/student/exam_bar.png and b/docs/user/exams/student/exam_bar.png differ diff --git a/docs/user/exams/student/exam_modeling_exercises.png b/docs/user/exams/student/exam_modeling_exercises.png index 037cb07283aa..8fdd0912935e 100644 Binary files a/docs/user/exams/student/exam_modeling_exercises.png and b/docs/user/exams/student/exam_modeling_exercises.png differ diff --git a/docs/user/exams/student/exam_navigation_sidebar.png b/docs/user/exams/student/exam_navigation_sidebar.png index e8455e426df5..f6a45add9523 100644 Binary files a/docs/user/exams/student/exam_navigation_sidebar.png and b/docs/user/exams/student/exam_navigation_sidebar.png differ diff --git a/docs/user/exams/student/exam_overview.png b/docs/user/exams/student/exam_overview.png index eab66ecf60a7..84a85532187c 100644 Binary files a/docs/user/exams/student/exam_overview.png and b/docs/user/exams/student/exam_overview.png differ diff --git a/docs/user/exams/student/exam_programming_exercises.png b/docs/user/exams/student/exam_programming_exercises.png index b2018403e57a..1956f4fd173d 100644 Binary files a/docs/user/exams/student/exam_programming_exercises.png and b/docs/user/exams/student/exam_programming_exercises.png differ diff --git a/docs/user/exams/student/exam_quiz_exercises.png b/docs/user/exams/student/exam_quiz_exercises.png index 32c8d3634610..0c4bed59d3f6 100644 Binary files a/docs/user/exams/student/exam_quiz_exercises.png and b/docs/user/exams/student/exam_quiz_exercises.png differ diff --git a/docs/user/exams/student/exam_text_exercises.png b/docs/user/exams/student/exam_text_exercises.png index f81591045eb6..d79126ed398f 100644 Binary files a/docs/user/exams/student/exam_text_exercises.png and b/docs/user/exams/student/exam_text_exercises.png differ diff --git a/docs/user/exams/students_guide.rst b/docs/user/exams/students_guide.rst index b38278b75d22..f56f5fa94894 100644 --- a/docs/user/exams/students_guide.rst +++ b/docs/user/exams/students_guide.rst @@ -105,6 +105,14 @@ Welcome Screen Welcome Screen, waiting for exam start +This video offers a detailed guide on accessing your exams: + +.. raw:: html + + + Exam Conduction ^^^^^^^^^^^^^^^ - Once the exam working time starts and you have confirmed your participation, the *Exercise Overview* screen will appear. This screen lists all exercises that are part of your exam with their respective amount of points, title and exercise type. The status column indicates the status of each exercise and whether you have a submission in them or not. @@ -132,6 +140,14 @@ Exam Conduction Exam Navigation Sidebar +- You have two options to save your changes for an exercise: + + 1. Click the |save_exercise| button to manually save and submit your changes. + 2. Select an exercise in the navigation sidebar (either the current one or a different exercise), which will automatically save and submit your changes. + + .. warning:: + The |save_exercise| button is only available for text, modeling, and quiz exercises. For file upload exercises, you need to manually click the |upload| button, and for programming exercises, you need to manually click the |submit| button to save and submit your changes. + - On the header, you will find the exam bar that includes the remaining time and the |exam_hand_in_early| button. If you click this button, you will be sent to the exam `End Screen`_. - The *time left* until the end of the exam is also shown next to the button. @@ -373,6 +389,14 @@ Summary Complaining about the Assessment of a Text Exercise +This video offers a detailed guide on participating in your exams: + +.. raw:: html + + + Example Solutions ^^^^^^^^^^^^^^^^^ - If the instructor sets the example solution publication date of the exam, the solutions will be available after that date. @@ -470,3 +494,5 @@ Grades .. |exam_no_results_found| image:: student/buttons/exam_no_results_found.png .. |exam_hand_in_early| image:: student/buttons/exam_hand_in_early.png .. |saved_exercises| image:: student/buttons/saved_exercises.png +.. |upload| image:: student/buttons/upload.png +.. |save_exercise| image:: student/buttons/save_exercise.png diff --git a/gradle.properties b/gradle.properties index 8e95ead2b4ff..b234044bcc8f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ npm_version=10.8.0 # Dependency versions jhipster_dependencies_version=8.7.2 -spring_boot_version=3.3.5 -spring_security_version=6.3.4 +spring_boot_version=3.3.6 +spring_security_version=6.3.5 # TODO: upgrading to 6.6.0 currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code hibernate_version=6.4.10.Final # TODO: can we update to 5.x? @@ -25,18 +25,20 @@ 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.18.0 liquibase_version=4.30.0 docker_java_version=3.4.0 logback_version=1.5.12 java_parser_version=3.26.2 byte_buddy_version=1.15.10 +netty_version=4.1.115.Final # testing # make sure both versions are compatible -junit_version=5.11.0 +junit_version=5.11.3 junit_platform_version=1.11.3 mockito_version=5.14.2 +testcontainer_version=1.20.4 # gradle plugin version diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72b8b91..e2847c820046 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.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/jest.config.js b/jest.config.js index 8fbf56f12e91..96eeb24f0890 100644 --- a/jest.config.js +++ b/jest.config.js @@ -105,10 +105,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 87.55, - branches: 73.68, - functions: 82.12, - lines: 87.61, + statements: 87.66, + branches: 73.79, + functions: 82.17, + lines: 87.72, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], diff --git a/linting.sh b/linting.sh index 1bb19ae388e6..1fff1374d5be 100755 --- a/linting.sh +++ b/linting.sh @@ -1,16 +1,3 @@ #!/bin/sh -join_by () { - local IFS="$1"; - shift; - echo "$*"; -} -FILES=$(join_by "," "$@") - -if [[ "$OSTYPE" == "msys" ]]; then - # replace backslashes with double backslashes in Windows file paths when - # using MinGW (msys = lightweight shell and GNU utilities compiled for Windows (part of MinGW) - FILES=$(echo $FILES | sed 's/\\/\\\\/g') -fi - -./gradlew spotlessApply -PspotlessFiles="${FILES}" +./gradlew spotlessApply -PratchetFrom='develop' diff --git a/package-lock.json b/package-lock.json index 017c505d0dbd..eec4beaec47e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,41 @@ { "name": "artemis", - "version": "7.7.0", + "version": "7.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "7.7.0", + "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", "@fortawesome/angular-fontawesome": "0.15.0", - "@fortawesome/fontawesome-svg-core": "6.6.0", - "@fortawesome/free-regular-svg-icons": "6.6.0", - "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/fontawesome-svg-core": "6.7.1", + "@fortawesome/free-regular-svg-icons": "6.7.1", + "@fortawesome/free-solid-svg-icons": "6.7.1", "@ls1intum/apollon": "3.3.15", "@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.39.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.1", "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -64,8 +64,9 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", + "pdf-lib": "1.17.1", "pdfjs-dist": "4.8.69", - "posthog-js": "1.181.0", + "posthog-js": "1.187.2", "rxjs": "7.8.1", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", @@ -74,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-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-devkit/build-angular": "18.2.12", + "@angular-eslint/builder": "18.4.1", + "@angular-eslint/eslint-plugin": "18.4.1", + "@angular-eslint/eslint-plugin-template": "18.4.1", + "@angular-eslint/schematics": "18.4.1", + "@angular-eslint/template-parser": "18.4.1", + "@angular/cli": "18.2.12", + "@angular/compiler-cli": "18.2.12", + "@angular/language-service": "18.2.12", + "@sentry/types": "8.39.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -98,35 +99,35 @@ "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", - "@types/node": "22.9.0", + "@types/node": "22.9.1", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", "@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", - "eslint": "9.14.0", + "@typescript-eslint/eslint-plugin": "8.15.0", + "@typescript-eslint/parser": "8.15.0", + "eslint": "9.15.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.9.0", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", - "husky": "9.1.6", + "husky": "9.1.7", "jest": "29.7.0", "jest-canvas-mock": "2.5.2", "jest-date-mock": "1.0.10", "jest-extended": "4.0.2", "jest-fail-on-console": "3.3.1", "jest-junit": "16.0.0", - "jest-preset-angular": "14.2.4", + "jest-preset-angular": "14.3.2", "lint-staged": "15.2.10", "ng-mocks": "14.13.1", "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" @@ -217,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": { @@ -233,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", @@ -254,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", @@ -361,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", @@ -387,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": { @@ -407,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": { @@ -435,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", @@ -454,9 +462,9 @@ } }, "node_modules/@angular-eslint/builder": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-18.4.0.tgz", - "integrity": "sha512-FOzGHX/nHSV1wSduSsabsx3aqC1nfde0opEpEDSOJhxExDxKCwoS1XPy1aERGyKip4ZVA6phC3dLtoBH3QMkVQ==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-18.4.1.tgz", + "integrity": "sha512-Ofkwd9Rg52K+AgvnV1RXYXVBGJvl5jD7+4dqwoprqXG7YKNTdHy5vqNZ5XDSMb26qjoZF7JC+IKruKFaON/ZaA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -465,21 +473,21 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-18.4.0.tgz", - "integrity": "sha512-HlFHt2qgdd+jqyVIkCXmrjHauXo/XY3Rp0UNabk83ejGi/raM/6lEFI7iFWzHxLyiAKk4OgGI5W26giSQw991A==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-18.4.1.tgz", + "integrity": "sha512-gCQC0mgBO1bwHDXL9CUgHW+Rf1XGZCLAopoXnggwxGkBCx+oww507t+jrSOxdh+4OTKU4ZfmbtWd7Y8AeXns8w==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-18.4.0.tgz", - "integrity": "sha512-Saz9lkWPN3da7ZKW17UsOSN7DeY+TPh+wz/6GCNZCh67Uw2wvMC9agb+4hgpZNXYCP5+u7erqzxQmBoWnS/A+A==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-18.4.1.tgz", + "integrity": "sha512-FoHwj+AFo8ONKb8wEK5qpo6uefuyklZlDqErJxeC3fpNIJzDe8PWBcJsuZt7Wwm/HeggWgt0Au6h+3IEa0V3BQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", - "@angular-eslint/utils": "18.4.0" + "@angular-eslint/bundled-angular-compiler": "18.4.1", + "@angular-eslint/utils": "18.4.1" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -488,14 +496,14 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-18.4.0.tgz", - "integrity": "sha512-n3uZFCy76DnggPqjSVFV3gYD1ik7jCG28o2/HO4kobcMNKnwW8XAlFUagQ4TipNQh7fQiAefsEqvv2quMsYDVw==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-18.4.1.tgz", + "integrity": "sha512-sofnKpi6wOZ6avVfYYqB7sCgGgWF2HgCZfW+IAp1MtVD2FBa1zTSbbfIZ1I8Akpd22UXa4LKJd0TLwm5XHHkiQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", - "@angular-eslint/utils": "18.4.0", + "@angular-eslint/bundled-angular-compiler": "18.4.1", + "@angular-eslint/utils": "18.4.1", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, @@ -507,15 +515,15 @@ } }, "node_modules/@angular-eslint/schematics": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-18.4.0.tgz", - "integrity": "sha512-ssqe+0YCfekbWIXNdCrHfoPK/bPZAWybs0Bn/b99dfd8h8uyXkERo9AzIOx4Uyj/08SkP9aPL/0uOOEHDsRGwQ==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-18.4.1.tgz", + "integrity": "sha512-1+gGodwh+UevtEx9mzZbzP1uY/9NAGEbsn8jisG1TEPDby2wKScQj6U6JwGxoW/Dd/4SIeSdilruZPALkqha7g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/eslint-plugin": "18.4.0", - "@angular-eslint/eslint-plugin-template": "18.4.0", - "ignore": "5.3.2", + "@angular-eslint/eslint-plugin": "18.4.1", + "@angular-eslint/eslint-plugin-template": "18.4.1", + "ignore": "6.0.2", "semver": "7.6.3", "strip-json-comments": "3.1.1" }, @@ -524,14 +532,24 @@ "@angular-devkit/schematics": ">= 18.0.0 < 19.0.0" } }, + "node_modules/@angular-eslint/schematics/node_modules/ignore": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", + "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@angular-eslint/template-parser": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-18.4.0.tgz", - "integrity": "sha512-VTep3Xd3IOaRIPL+JN/TV4/2DqUPbjtF3TNY15diD/llnrEhqFnmsvMihexbQyTqzOG+zU554oK44YfvAtHOrw==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-18.4.1.tgz", + "integrity": "sha512-LsStXVyso/89gQU5eiJebB/b1j+wrRtTLjk+ODVUTa7NGCCT7B7xI6ToTchkBEpSTHLT9pEQXHsHer3FymsQRQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0", + "@angular-eslint/bundled-angular-compiler": "18.4.1", "eslint-scope": "^8.0.2" }, "peerDependencies": { @@ -540,13 +558,13 @@ } }, "node_modules/@angular-eslint/utils": { - "version": "18.4.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.4.0.tgz", - "integrity": "sha512-At1yS8GRviGBoaupiQwEOL4/IcZJCE/+2vpXdItMWPGB1HWetxlKAUZTMmIBX/r5Z7CoXxl+LbqpGhrhyzIQAg==", + "version": "18.4.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.4.1.tgz", + "integrity": "sha512-F5UGE1J/CRmTbl8vjexQRwRglNqnJwdXCUejaG+qlGssSHoWcRB+ubbR/na3PdnzeJdBE6DkLYElXnOQZ6YKfg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.4.0" + "@angular-eslint/bundled-angular-compiler": "18.4.1" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -555,9 +573,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" @@ -566,18 +584,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", @@ -638,6 +656,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", @@ -657,9 +682,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" @@ -674,18 +699,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", @@ -708,9 +733,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" @@ -719,14 +744,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" @@ -735,7 +760,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": { @@ -744,9 +769,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", @@ -767,7 +792,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" } }, @@ -800,9 +825,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" @@ -816,9 +841,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" @@ -827,16 +852,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": { @@ -844,9 +869,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", @@ -863,21 +888,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", @@ -886,9 +911,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" @@ -897,9 +922,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": { @@ -908,9 +933,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" @@ -919,16 +944,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" @@ -937,16 +962,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" @@ -958,8 +983,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": { @@ -1145,9 +1170,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": { @@ -3412,9 +3437,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", - "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.0.tgz", + "integrity": "sha512-zdHg2FPIFNKPdcHWtiNT+jEFCHYVplAXRDlQDyqy0zGx/q2parwh7brGJSiTxRk/TSMkbM//zt/f5CHgyTyaSQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3451,9 +3476,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", - "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.0.tgz", + "integrity": "sha512-7ATR9F0e4W85D/0w7cU0SNj7qkAexMG+bAHEZOjo9akvGuhHE2m7umzWzfnpa0XAg5Kxc1BWmtPMV67jJ+9VUg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3461,9 +3486,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", "dev": true, "license": "MIT", "dependencies": { @@ -3546,9 +3571,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", - "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.15.0.tgz", + "integrity": "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg==", "dev": true, "license": "MIT", "engines": { @@ -3566,9 +3591,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": { @@ -3601,45 +3626,45 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", - "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.1.tgz", + "integrity": "sha512-gbDz3TwRrIPT3i0cDfujhshnXO9z03IT1UKRIVi/VEjpNHtSBIP2o5XSm+e816FzzCFEzAxPw09Z13n20PaQJQ==", "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", - "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", + "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", - "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.1.tgz", + "integrity": "sha512-e13cp+bAx716RZOTQ59DhqikAgETA9u1qTBHO3e3jMQQ+4H/N1NC1ZVeFYt1V0m+Th68BrEL1/X6XplISutbXg==", "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", - "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.1.tgz", + "integrity": "sha512-BTKc0b0mgjWZ2UDKVgmwaE0qt0cZs6ITcDgjrti5f/ki7aF5zs+N91V6hitGo3TItCFtnKg6cUVGdTmBFICFRg==", "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" @@ -3819,9 +3844,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": { @@ -5023,9 +5048,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": { @@ -5352,9 +5377,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": { @@ -5398,9 +5423,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" ], @@ -5415,9 +5440,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" ], @@ -5432,9 +5457,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" ], @@ -5449,9 +5474,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" ], @@ -5466,9 +5491,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" ], @@ -5483,9 +5508,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" ], @@ -5500,9 +5525,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" ], @@ -5517,9 +5542,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" ], @@ -5534,9 +5559,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" ], @@ -5551,9 +5576,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" ], @@ -5899,6 +5924,24 @@ "license": "MIT", "optional": true }, + "node_modules/@pdf-lib/standard-fonts": { + "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" + } + }, + "node_modules/@pdf-lib/upng": { + "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" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6215,14 +6258,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": { @@ -6232,73 +6275,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.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.39.0.tgz", + "integrity": "sha512-5jcO3os1aQIMNZptniMUCCkZ3KOvyUPSyrQeGB7NxhJoieIwmopo5qIXyeRLHu0htL7H7A1gPYln6Ji3d/KUUA==", "license": "MIT", "dependencies": { - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.39.0.tgz", + "integrity": "sha512-V5J/tnzAK8bXdXQzY7lnlYMqfTKgI+9BD7L7oHxQnDUzlShsV14xFGZVhEbPsjYficdIN9wpoYIyWDxwrFX1Qg==", "license": "MIT", "dependencies": { - "@sentry/core": "8.37.1", - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.39.0.tgz", + "integrity": "sha512-1IEXhg2XuKC1hx/Pf5p2L7McKjQPfVOWyQhjNUH2mHWbpOyvc1BhZoZKCgbbspwOAVuvj4n40PvOVyjfzU5Yew==", "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.39.0", + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.39.0.tgz", + "integrity": "sha512-NCp4E60SFfg9pXdMgcdpctYENFOvJ58UPGllGjO3xpYoMkd4DGZQp947Tgw9hATTCDnyYNIy5v/zYbDV4Wbw3w==", "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.39.0", + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.39.0.tgz", + "integrity": "sha512-yke0NULFosz4Fap9NGKTVzRKoJRx8+sAC8jA2qdU49SUtxon+L3LN5D6QbE402kdMWEscxKa1cHrgfIvJfOZZA==", "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.39.0", + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.0", "tslib": "^2.4.1" }, "engines": { @@ -6312,52 +6355,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.39.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.39.0.tgz", + "integrity": "sha512-Xpqh84MnqoFID0owbugTeq/3QXgNwc3EdHAN/HFUdxEAyJS4j7Wi1DIBXN+ZRzMYX3m2QHOAymCWjnFtv+H8WQ==", "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.39.0", + "@sentry-internal/feedback": "8.39.0", + "@sentry-internal/replay": "8.39.0", + "@sentry-internal/replay-canvas": "8.39.0", + "@sentry/core": "8.39.0", + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.39.0.tgz", + "integrity": "sha512-rg2mHtwdCaedqub7bd+ht08vZgtwPO7el5m5sPNeb7V75GcQwSziu6G02vGxCBCsAHpoFn1A+0JLEajaYzZI7w==", "license": "MIT", "dependencies": { - "@sentry/types": "8.37.1", - "@sentry/utils": "8.37.1" + "@sentry/types": "8.39.0", + "@sentry/utils": "8.39.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.39.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.39.0.tgz", + "integrity": "sha512-/n1bGkbJcSLZQpzd1Oksi8LFAMbcO8j/d+N8mcXS74GuhGgkxQiEwHF2CKTz6SHt8J4hrlyzqIwVzCevUOxZ2Q==", "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.39.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-pIBnr/cROds92CcYWBW3z1zFH4uJkMPL2AxEv/ZcLg/NTb1Okz/ZaDP+NMzUfzriYvFBOFk0wPk0h5sYx6Umqw==", "license": "MIT", "dependencies": { - "@sentry/types": "8.37.1" + "@sentry/types": "8.39.0" }, "engines": { "node": ">=14.18" @@ -6499,36 +6542,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": { @@ -6567,12 +6621,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", @@ -6622,12 +6670,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", @@ -7049,9 +7091,9 @@ } }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.1.tgz", + "integrity": "sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==", "dev": true, "license": "MIT", "dependencies": { @@ -7197,7 +7239,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/turndown": { @@ -7255,17 +7297,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", + "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==", "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.15.0", + "@typescript-eslint/type-utils": "8.15.0", + "@typescript-eslint/utils": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7289,16 +7331,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz", + "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==", "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.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4" }, "engines": { @@ -7318,14 +7360,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", + "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0" + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7336,14 +7378,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz", + "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.13.0", - "@typescript-eslint/utils": "8.13.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/utils": "8.15.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7354,6 +7396,9 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -7361,9 +7406,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", + "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", "dev": true, "license": "MIT", "engines": { @@ -7375,14 +7420,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", + "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "@typescript-eslint/visitor-keys": "8.13.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7404,16 +7449,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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", + "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", "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.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7424,17 +7469,22 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "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.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", + "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.13.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.15.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7444,6 +7494,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", @@ -8267,14 +8330,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": { @@ -8296,13 +8359,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" @@ -8798,9 +8861,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", @@ -9702,13 +9765,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", @@ -9744,18 +9804,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", @@ -9924,13 +9972,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", @@ -10308,10 +10353,13 @@ } }, "node_modules/dompurify": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", - "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.1.tgz", + "integrity": "sha512-NBHEsc0/kzRYQd+AY6HR6B/IgsqzBABrqJbpCDQII/OK6h7B7LXzweZTDsqSW2LkTRpoxf18YUP+YjGySk6B3w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/domutils": { "version": "3.1.0", @@ -10342,13 +10390,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" @@ -10388,9 +10436,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": { @@ -10689,27 +10737,27 @@ } }, "node_modules/eslint": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", - "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.15.0.tgz", + "integrity": "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.18.0", - "@eslint/core": "^0.7.0", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.14.0", - "@eslint/plugin-kit": "^0.2.0", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.15.0", + "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.0", + "@humanwhocodes/retry": "^0.4.1", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.5", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", @@ -10728,8 +10776,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -12653,9 +12700,9 @@ } }, "node_modules/husky": { - "version": "9.1.6", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", - "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", "bin": { @@ -12769,9 +12816,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" }, @@ -13856,9 +13903,9 @@ } }, "node_modules/jest-preset-angular": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-14.2.4.tgz", - "integrity": "sha512-xyhkaiBdn3keBgxxkcbqZu/my3ADU9NcDrz6DaMuGRaxz/bf6ZC1qxZ1eQuz5V1WuA3/rD64VA3Kke8P6E9qNg==", + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-14.3.2.tgz", + "integrity": "sha512-Aoei1O/o7x1I6bSCpU08jGqtQ2RBq7HvNbMIo/vHHbM50v4HX1gF3sWZTkM0U0KorNkdwZeONjMsPNwHyUAKqA==", "dev": true, "license": "MIT", "dependencies": { @@ -16035,9 +16082,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": { @@ -16330,9 +16377,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", @@ -16375,16 +16422,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", @@ -16817,9 +16864,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": { @@ -17113,6 +17160,24 @@ "node": ">=6" } }, + "node_modules/pdf-lib": { + "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", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "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==", + "license": "0BSD" + }, "node_modules/pdfjs-dist": { "version": "4.8.69", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.8.69.tgz", @@ -17404,14 +17469,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": { @@ -17422,13 +17487,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" @@ -17454,9 +17519,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": { @@ -17474,9 +17539,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.187.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.187.2.tgz", + "integrity": "sha512-IGKsZ7M4AYACm5I6gGGFrv9kR/MOnVYw11XFYCLk363n0nq+ghwenoW1jJVL9gZLGKiMsConUR8rG2DD2OMKyg==", "license": "MIT", "dependencies": { "core-js": "^3.38.1", @@ -18398,6 +18463,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": { @@ -18631,14 +18697,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": { @@ -20116,13 +20182,6 @@ "node": "*" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/thingies": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", @@ -20150,22 +20209,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" }, @@ -20801,9 +20860,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" @@ -20874,9 +20933,9 @@ } }, "node_modules/vite": { - "version": "5.4.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", - "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -21465,12 +21524,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", @@ -21496,12 +21549,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 6ffc53b8f45f..06aa6ac1cfe0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "7.7.0", + "version": "7.7.2", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", @@ -13,32 +13,32 @@ "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", "@fortawesome/angular-fontawesome": "0.15.0", - "@fortawesome/fontawesome-svg-core": "6.6.0", - "@fortawesome/free-regular-svg-icons": "6.6.0", - "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/fontawesome-svg-core": "6.7.1", + "@fortawesome/free-regular-svg-icons": "6.7.1", + "@fortawesome/free-solid-svg-icons": "6.7.1", "@ls1intum/apollon": "3.3.15", "@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.39.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.1", "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", @@ -67,8 +67,9 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", + "pdf-lib": "1.17.1", "pdfjs-dist": "4.8.69", - "posthog-js": "1.181.0", + "posthog-js": "1.187.2", "rxjs": "7.8.1", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", @@ -77,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" @@ -91,14 +92,14 @@ "d3-transition": "^3.0.1" }, "@typescript-eslint/utils": { - "eslint": "^9.14.0" + "eslint": "^9.15.0" }, "braces": "3.0.3", "cookie": "1.0.1", "critters": "0.0.25", "debug": "4.3.7", "eslint-plugin-deprecation": { - "eslint": "^9.14.0" + "eslint": "^9.15.0" }, "express": "5.0.1", "jsdom": "25.0.1", @@ -106,7 +107,7 @@ "rimraf": "6.0.1", "semver": "7.6.3", "tough-cookie": "5.0.0", - "vite": "5.4.10", + "vite": "5.4.11", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.1.0", "word-wrap": "1.2.5", @@ -115,16 +116,16 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.11", - "@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-devkit/build-angular": "18.2.12", + "@angular-eslint/builder": "18.4.1", + "@angular-eslint/eslint-plugin": "18.4.1", + "@angular-eslint/eslint-plugin-template": "18.4.1", + "@angular-eslint/schematics": "18.4.1", + "@angular-eslint/template-parser": "18.4.1", + "@angular/cli": "18.2.12", + "@angular/compiler-cli": "18.2.12", + "@angular/language-service": "18.2.12", + "@sentry/types": "8.39.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -132,35 +133,35 @@ "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", - "@types/node": "22.9.0", + "@types/node": "22.9.1", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", "@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", - "eslint": "9.14.0", + "@typescript-eslint/eslint-plugin": "8.15.0", + "@typescript-eslint/parser": "8.15.0", + "eslint": "9.15.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.9.0", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", - "husky": "9.1.6", + "husky": "9.1.7", "jest": "29.7.0", "jest-canvas-mock": "2.5.2", "jest-date-mock": "1.0.10", "jest-extended": "4.0.2", "jest-fail-on-console": "3.3.1", "jest-junit": "16.0.0", - "jest-preset-angular": "14.2.4", + "jest-preset-angular": "14.3.2", "lint-staged": "15.2.10", "ngxtension": "4.1.0", "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/FeedbackAnalysisResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java index d913f0c96e3f..e56722f079cf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java @@ -1,11 +1,13 @@ package de.tum.cit.aet.artemis.assessment.dto; import java.util.List; +import java.util.Set; import com.fasterxml.jackson.annotation.JsonInclude; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackAnalysisResponseDTO(SearchResultPageDTO feedbackDetails, long totalItems, int totalAmountOfTasks, List testCaseNames) { +public record FeedbackAnalysisResponseDTO(SearchResultPageDTO feedbackDetails, long totalItems, Set taskNames, List testCaseNames, + List errorCategories) { } 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 7b3fd09ad57d..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 taskNumber, 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/dto/FeedbackPageableDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java index c63f9b5540f7..b65d09545fc2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java @@ -14,6 +14,8 @@ public class FeedbackPageableDTO extends PageableSearchDTO { private String searchTerm; + private List filterErrorCategories; + public List getFilterTasks() { return filterTasks; } @@ -45,4 +47,12 @@ public String getSearchTerm() { public void setSearchTerm(String searchTerm) { this.searchTerm = searchTerm; } + + public List getFilterErrorCategories() { + return filterErrorCategories; + } + + public void setFilterErrorCategories(List filterErrorCategories) { + this.filterErrorCategories = filterErrorCategories; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ComplaintRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ComplaintRepository.java index 627bae47f858..4903913b1775 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ComplaintRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ComplaintRepository.java @@ -256,7 +256,6 @@ SELECT COUNT(c) @EntityGraph(type = LOAD, attributePaths = { "result.participation", "result.submission", "result.assessor" }) List getAllByResult_Assessor_IdAndResult_Participation_Exercise_Course_Id(Long assessorId, Long courseId); - // Valid JPQL syntax. Only SCA fails to properly detect the types. /** * Get the number of Complaints for all tutors of a course * @@ -267,8 +266,8 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardComplaintsDTO( r.assessor.id, COUNT(c), - SUM( CASE WHEN (c.accepted = TRUE ) THEN 1L ELSE 0L END), - SUM( CASE WHEN (c.accepted = TRUE) THEN e.maxPoints ELSE 0.0 END) + SUM(CASE WHEN c.accepted = TRUE THEN 1L ELSE 0L END), + CAST(SUM(CASE WHEN c.accepted = TRUE THEN e.maxPoints ELSE 0.0 END) AS double) ) FROM Complaint c JOIN c.result r @@ -282,7 +281,6 @@ SELECT COUNT(c) """) List findTutorLeaderboardComplaintsByCourseId(@Param("courseId") long courseId); - // Valid JPQL syntax. Only SCA fails to properly detect the types. /** * Get the number of Complaints for all tutors of an exercise * @@ -293,8 +291,8 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardComplaintsDTO( r.assessor.id, COUNT(c), - SUM( CASE WHEN (c.accepted = TRUE ) THEN 1L ELSE 0L END), - SUM( CASE WHEN (c.accepted = TRUE) THEN e.maxPoints ELSE 0.0 END) + SUM(CASE WHEN c.accepted = TRUE THEN 1L ELSE 0L END), + CAST(SUM(CASE WHEN c.accepted = TRUE THEN e.maxPoints ELSE 0.0 END) AS double) ) FROM Complaint c JOIN c.result r @@ -308,7 +306,6 @@ SELECT COUNT(c) """) List findTutorLeaderboardComplaintsByExerciseId(@Param("exerciseId") long exerciseId); - // Valid JPQL syntax. Only SCA fails to properly detect the types. /** * Get the number of Complaints for all tutors of an exam * @@ -319,8 +316,8 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardComplaintsDTO( r.assessor.id, COUNT(c), - SUM( CASE WHEN (c.accepted = TRUE ) THEN 1L ELSE 0L END), - SUM( CASE WHEN (c.accepted = TRUE) THEN e.maxPoints ELSE 0.0 END) + SUM(CASE WHEN c.accepted = TRUE THEN 1L ELSE 0L END), + CAST(SUM(CASE WHEN c.accepted = TRUE THEN e.maxPoints ELSE 0.0 END) AS double) ) FROM Complaint c JOIN c.result r @@ -345,7 +342,7 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardComplaintResponsesDTO( cr.reviewer.id, COUNT(c), - SUM(e.maxPoints) + SUM(CAST(e.maxPoints AS double)) ) FROM Complaint c JOIN c.complaintResponse cr @@ -422,8 +419,8 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardMoreFeedbackRequestsDTO( r.assessor.id, COUNT(c), - SUM( CASE WHEN (c.accepted IS NULL) THEN 1L ELSE 0L END), - SUM( CASE WHEN (c.accepted IS NULL) THEN e.maxPoints ELSE 0.0 END) + SUM(CASE WHEN c.accepted IS NULL THEN 1L ELSE 0L END), + CAST(SUM(CASE WHEN c.accepted IS NULL THEN e.maxPoints ELSE 0.0 END) AS double) ) FROM Complaint c JOIN c.result r @@ -447,8 +444,8 @@ SELECT COUNT(c) SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardMoreFeedbackRequestsDTO( r.assessor.id, COUNT(c), - SUM( CASE WHEN (c.accepted IS NULL) THEN 1L ELSE 0L END), - SUM( CASE WHEN (c.accepted IS NULL) THEN e.maxPoints ELSE 0.0 END) + SUM(CASE WHEN c.accepted IS NULL THEN 1L ELSE 0L END), + CAST(SUM(CASE WHEN c.accepted IS NULL THEN e.maxPoints ELSE 0.0 END) AS double) ) FROM Complaint c JOIN c.result r diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/FeedbackRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/FeedbackRepository.java index 12f4e658b7fc..5d42abe8f971 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/FeedbackRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/FeedbackRepository.java @@ -32,8 +32,8 @@ public interface FeedbackRepository extends ArtemisJpaRepository @Query(""" SELECT feedback - FROM Feedback feedback - WHERE feedback.gradingInstruction.id IN :gradingInstructionsIds + FROM Feedback feedback + WHERE feedback.gradingInstruction.id IN :gradingInstructionsIds """) List findFeedbackByGradingInstructionIds(@Param("gradingInstructionsIds") List gradingInstructionsIds); 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/repository/ResultRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java index 5a7b75e82de5..5e70f4c83668 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java @@ -618,7 +618,6 @@ else if (Boolean.FALSE.equals(ratedCount.rated())) { """) List findTutorLeaderboardAssessmentByExerciseId(@Param("exerciseId") long exerciseId); - // Valid JPQL syntax, only SCA is not able to parse it due to mixing primitive and object types @Query(""" SELECT new de.tum.cit.aet.artemis.assessment.dto.tutor.TutorLeaderboardAssessmentsDTO( r.assessor.id, diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/StudentScoreRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/StudentScoreRepository.java index dbf32eb37bad..51cc56a015da 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/StudentScoreRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/StudentScoreRepository.java @@ -69,4 +69,5 @@ public interface StudentScoreRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link Feedback} entries where the associated {@link Result} has no submission and no participation. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + // Subquery ok + @Query(""" + DELETE FROM Feedback f + WHERE f.result IN ( + SELECT r + FROM Result r + WHERE r.submission IS NULL + AND r.participation IS NULL + ) + """) + int deleteFeedbackForOrphanResults(); + + /** + * Deletes {@link Feedback} entries with a {@code null} result. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM Feedback f + WHERE f.result IS NULL + """) + int deleteOrphanFeedback(); + + /** + * Deletes {@link Feedback} entries associated with rated {@link Result} that are not the latest rated result + * for a {@link Participation}, within courses conducted between the specified date range. + * This query removes old feedback entries that are not part of the latest rated results, for courses whose + * end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM Feedback f + WHERE f.result IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = TRUE + ) + AND r.rated = TRUE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes non-rated {@link Feedback} entries that are not the latest non-rated result, where the associated course's start and end dates + * are between the specified date range. + * This query removes old feedback entries that are not part of the latest non-rated result within courses whose end date is before + * {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM Feedback f + WHERE f.result IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + ) + AND r.rated = FALSE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteOldNonRatedFeedbackWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java new file mode 100644 index 000000000000..09dbf01baff2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/LongFeedbackTextCleanupRepository.java @@ -0,0 +1,131 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.Feedback; +import de.tum.cit.aet.artemis.assessment.domain.LongFeedbackText; +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; + +/** + * Spring Data JPA repository for cleaning up old and orphaned long feedback text entries. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface LongFeedbackTextCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link LongFeedbackText} entries linked to {@link Feedback} where the associated + * {@link Result} has no participation and no submission. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM LongFeedbackText lft + WHERE lft.feedback.id IN ( + SELECT f.id + FROM Feedback f + WHERE f.result.participation IS NULL + AND f.result.submission IS NULL + ) + """) + int deleteLongFeedbackTextForOrphanResult(); + + /** + * Deletes {@link LongFeedbackText} linked to {@link Feedback} with a {@code null} result. + * + * @return the number of deleted {@link LongFeedbackText} entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM LongFeedbackText lft + WHERE lft.feedback IN ( + SELECT f + FROM Feedback f + WHERE f.result IS NULL + ) + """) + int deleteLongFeedbackTextForOrphanedFeedback(); + + /** + * Deletes {@link LongFeedbackText} entries associated with rated {@link Result} that are not the latest rated result + * for a {@link Participation}, within courses conducted between the specified date range. + * This query removes old long feedback text that is not part of the latest rated results, for courses whose + * end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM LongFeedbackText lft + WHERE lft.feedback IN ( + SELECT f + FROM Feedback f + LEFT JOIN f.result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE f.result.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = TRUE + ) + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + AND r.rated = TRUE + ) + """) + int deleteLongFeedbackTextForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes {@link LongFeedbackText} entries linked to non-rated {@link Feedback} that are not the latest non-rated result where the associated course's start + * and end dates are between the specified date range. + * This query deletes long feedback text for feedback associated with non-rated results, within courses whose + * end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM LongFeedbackText lft + WHERE lft.feedback IN ( + SELECT f + FROM Feedback f + LEFT JOIN f.result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE f.result.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = FALSE + ) + AND r.rated = FALSE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteLongFeedbackTextForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java new file mode 100644 index 000000000000..50fcdd5b14b3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ParticipantScoreCleanupRepository.java @@ -0,0 +1,158 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.ParticipantScore; +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; + +/** + * Spring Data JPA repository for cleaning up old and orphaned participant scores. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface ParticipantScoreCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link ParticipantScore} entries where the associated {@link Result} is not the latest rated result + * for a {@link Participation}, within courses conducted between the specified date range. + * This query removes participant scores linked to results that are not the most recent rated results, for courses + * whose end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM ParticipantScore ps + WHERE ps.lastResult IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = TRUE + ) + AND r.rated = TRUE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteParticipantScoresForNonLatestLastResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes {@link ParticipantScore} entries where the associated last rated {@link Result} is not the latest rated result + * for a {@link Participation}, within courses conducted between the specified date range. + * This query removes participant scores linked to rated results that are not the most recent rated results, for courses + * whose end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM ParticipantScore ps + WHERE ps.lastRatedResult IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = TRUE + ) + AND r.rated = TRUE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteParticipantScoresForNonLatestLastRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes {@link ParticipantScore} entries where the associated {@link Result} is not the latest result and is non-rated, + * and the course's start and end dates are between the specified date range. + * This query deletes participant scores for non-rated results within courses whose end date is before + * {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM ParticipantScore ps + WHERE ps.lastResult IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = FALSE + ) + AND r.rated = FALSE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom ) + """) + int deleteParticipantScoresForLatestNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes {@link ParticipantScore} entries where the associated {@link Result} is not latest and is non-rated, even though + * it is marked as the last rated result, to prevent potential integrity violations. + * The deletion is based on courses whose start and end dates fall within the specified range. + * This scenario should not normally occur, as non-rated results cannot be marked as rated, but the + * method ensures cleanup in case of any potential integrity issues. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM ParticipantScore ps + WHERE ps.lastRatedResult IN ( + SELECT r + FROM Result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE r.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = FALSE + ) + AND r.rated = FALSE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteParticipantScoresForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java new file mode 100644 index 000000000000..a93ba9604223 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/PlagiarismComparisonCleanupRepository.java @@ -0,0 +1,91 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismComparison; + +/** + * Spring Data JPA repository for cleaning up old plagiarism comparison entries. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface PlagiarismComparisonCleanupRepository extends ArtemisJpaRepository, Long> { + + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE + FROM PlagiarismComparison pc + WHERE pc.id IN :ids + """) + int deleteByIdsIn(@Param("ids") List ids); + + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE + FROM PlagiarismSubmissionElement e + WHERE e.plagiarismSubmission.plagiarismComparison.id IN :ids + """) + int deletePlagiarismSubmissionElementsByComparisonIdsIn(@Param("ids") List ids); + + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE + FROM PlagiarismSubmission s + WHERE s.plagiarismComparison.id IN :ids + """) + int deletePlagiarismSubmissionsByComparisonIdsIn(@Param("ids") List ids); + + @Modifying + @Transactional // ok because of modifying + @Query(""" + UPDATE PlagiarismComparison pc + SET pc.submissionA = NULL, pc.submissionB = NULL + WHERE pc.id IN :ids + """) + int setPlagiarismSubmissionsToNullInComparisonsWithIds(@Param("ids") List ids); + + @Modifying + @Transactional // ok because of delete + @Query(nativeQuery = true, value = """ + DELETE + FROM plagiarism_comparison_matches m + WHERE m.plagiarism_comparison_id IN :ids + """) + int deletePlagiarismComparisonMatchesByComparisonIdsIn(@Param("ids") List ids); + + /** + * Retrieves a list of unnecessary plagiarism comparison IDs based on the associated course's date range. + * A plagiarism comparison is considered unnecessary if its status is 'NONE' and the related course's + * start and end dates fall within the provided range. Also deletes orphan objects. + * + * @param deleteFrom The start of the date range for filtering unnecessary plagiarism comparisons. + * @param deleteTo The end of the date range for filtering unnecessary plagiarism comparisons. + * @return A list of Long values representing the IDs of unnecessary plagiarism comparisons. + */ + @Query(""" + SELECT pc.id + FROM PlagiarismComparison pc + LEFT JOIN pc.plagiarismResult pr + LEFT JOIN pr.exercise ex + LEFT JOIN ex.course c + WHERE pc.status = de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismStatus.NONE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + """) + List findPlagiarismComparisonIdWithStatusNoneThatBelongToCourseWithDates(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java new file mode 100644 index 000000000000..07f10ec6d3d3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/RatingCleanupRepository.java @@ -0,0 +1,41 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.Rating; +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +/** + * Spring Data JPA repository for cleaning up old and orphaned ratings. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface RatingCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link Rating} entries where the associated {@link Result} has no submission and no participation. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE + FROM Rating rt + WHERE rt.result IN ( + SELECT r + FROM Result r + WHERE r.submission IS NULL + AND r.participation IS NULL + ) + """) + int deleteOrphanRating(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java new file mode 100644 index 000000000000..7d9e413aa5e7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/ResultCleanupRepository.java @@ -0,0 +1,115 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; + +/** + * Spring Data JPA repository for cleaning up old and orphaned results. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface ResultCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link Result} entries that have no participation and no submission. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE + FROM Result r + WHERE r.participation IS NULL + AND r.submission IS NULL + """) + int deleteResultWithoutParticipationAndSubmission(); + + /** + * Deletes non-rated {@link Result} entries that are not the latest result where the associated {@link Participation} and {@link Exercise} are not null, + * and the course's start and end dates fall between the specified date range. + * This query deletes non-rated results associated with exercises within courses whose end date is before + * {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM Result r + WHERE r.rated = FALSE + AND r.participation IS NOT NULL + AND r.participation.exercise IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM Course c + LEFT JOIN c.exercises e + WHERE e = r.participation.exercise + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + AND r.id NOT IN ( + SELECT max_id + FROM ( + SELECT MAX(r2.id) AS max_id + FROM Result r2 + WHERE r2.rated = FALSE + GROUP BY r2.participation.id + ) + ) + """) + int deleteNonLatestNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes rated {@link Result} entries that are not the latest rated result for a {@link Participation}, within courses + * conducted between the specified date range. + * This query removes rated results that are not the most recent for a participation, for courses whose end date is + * before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM Result r + WHERE r.rated = TRUE + AND r.participation IS NOT NULL + AND r.participation.exercise IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM Course c + LEFT JOIN c.exercises e + WHERE e = r.participation.exercise + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + AND r.id NOT IN ( + SELECT max_id + FROM ( + SELECT MAX(r2.id) AS max_id + FROM Result r2 + WHERE r2.rated = TRUE + GROUP BY r2.participation.id + ) + ) + """) + int deleteNonLatestRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java new file mode 100644 index 000000000000..d45bf8243e50 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/StudentScoreCleanupRepository.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.StudentScore; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +/** + * Spring Data JPA repository for cleaning up old and orphaned student score entries. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface StudentScoreCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link StudentScore} entries where the associated user is {@code null}. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM StudentScore ps + WHERE ps.user IS NULL + """) + int deleteOrphanStudentScore(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java new file mode 100644 index 000000000000..2c1009f481b8 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TeamScoreCleanupRepository.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.TeamScore; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +/** + * Spring Data JPA repository for cleaning up old and orphaned team score entries. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface TeamScoreCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link TeamScore} entries where the associated team is {@code null}. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM TeamScore ps + WHERE ps.team IS NULL + """) + int deleteOrphanTeamScore(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TextBlockCleanupRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TextBlockCleanupRepository.java new file mode 100644 index 000000000000..132fcc4f9726 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/cleanup/TextBlockCleanupRepository.java @@ -0,0 +1,133 @@ +package de.tum.cit.aet.artemis.assessment.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.assessment.domain.Feedback; +import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; +import de.tum.cit.aet.artemis.text.domain.TextBlock; + +/** + * Spring Data JPA repository for cleaning up old and orphaned text block entries. + * THE FOLLOWING METHODS ARE USED FOR CLEANUP PURPOSES AND SHOULD NOT BE USED IN OTHER CASES + */ +@Profile(PROFILE_CORE) +@Repository +public interface TextBlockCleanupRepository extends ArtemisJpaRepository { + + /** + * Deletes {@link TextBlock} entries linked to {@link Feedback} where the associated {@link Result} + * has no submission and no participation. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM TextBlock tb + WHERE tb.feedback IN ( + SELECT f + FROM Feedback f + LEFT JOIN f.result r + WHERE r.submission IS NULL + AND r.participation IS NULL + ) + """) + int deleteTextBlockForOrphanResults(); + + /** + * Deletes {@link TextBlock} entries linked to {@link Feedback} with a {@code null} result. + * + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM TextBlock tb + WHERE tb.feedback IN ( + SELECT f + FROM Feedback f + WHERE f.result IS NULL + ) + """) + int deleteTextBlockForEmptyFeedback(); + + /** + * Deletes {@link TextBlock} entries associated with rated {@link Result} that are not the latest rated result + * for a {@link Participation}, within courses conducted between the specified date range. + * This query removes old text blocks that are not part of the latest rated results, for courses whose + * end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM TextBlock tb + WHERE tb.feedback IN ( + SELECT f + FROM Feedback f + LEFT JOIN f.result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE f.result.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = TRUE + ) + AND r.rated = TRUE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteTextBlockForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); + + /** + * Deletes {@link TextBlock} entries linked to non-rated {@link Result} that are not the latest non-rated result + * for a {@link Participation}, where the associated course's start and end dates + * are between the specified date range. + * This query deletes text blocks for feedback associated with results that are not rated, within the courses + * whose end date is before {@code deleteTo} and start date is after {@code deleteFrom}. + * + * @param deleteFrom the start date for selecting courses + * @param deleteTo the end date for selecting courses + * @return the number of deleted entities + */ + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM TextBlock tb + WHERE tb.feedback IN ( + SELECT f + FROM Feedback f + LEFT JOIN f.result r + LEFT JOIN r.participation p + LEFT JOIN p.exercise e + LEFT JOIN e.course c + WHERE f.result.id NOT IN ( + SELECT MAX(r2.id) + FROM Result r2 + WHERE r2.participation.id = p.id + AND r2.rated = FALSE + ) + AND r.rated = FALSE + AND c.endDate < :deleteTo + AND c.startDate > :deleteFrom + ) + """) + int deleteTextBlockForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo); +} 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 21cfc87c6c31..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; @@ -547,73 +551,85 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { } /** - * Retrieves paginated and filtered aggregated feedback details for a given exercise. + * Retrieves paginated and filtered aggregated feedback details for a given exercise, including the count of each unique feedback detail text, + * test case name, task name, and error category. *
* For each feedback detail: - * 1. The relative count is calculated as a percentage of the total number of distinct results for the exercise. - * 2. The task numbers are assigned based on the associated test case names. A mapping between test cases and tasks is created using the set of tasks retrieved from the - * database. + * 1. The relative count is calculated as a percentage of the total distinct results for the exercise. + * 2. Task names are assigned based on associated test case names, with a mapping created between test cases and tasks from the exercise database. + * Feedback items not assigned to any task are labeled as "Not assigned to a task." + * 3. Error categories are classified as one of "Student Error," "Ares Error," or "AST Error," based on feedback content. *
- * Filtering: - * - **Search term**: Filters feedback details by the search term (case-insensitive). - * - **Test case names**: Filters feedback based on specific test case names (if provided). - * - **Task names**: Maps provided task numbers to task names and filters feedback based on the test cases associated with those tasks. - * - **Occurrences**: Filters feedback where the number of occurrences (COUNT) is between the provided minimum and maximum values (inclusive). + * It supports filtering by: + * - Search term: Case-insensitive filtering on feedback detail text. + * - Test case names: Filters feedback based on specific test case names. Only active test cases are included in the filtering options. + * - Task names: Filters feedback based on specified task names and includes unassigned tasks if "Not assigned to a task" is selected. + * - Occurrence range: Filters feedback where the number of occurrences (COUNT) is within the specified minimum and maximum range. + * - Error categories: Filters feedback based on selected error categories, such as "Student Error," "Ares Error," and "AST Error." *
* Pagination and sorting: * - Sorting is applied based on the specified column and order (ascending or descending). - * - The result is paginated based on the provided page number and page size. + * - The result is paginated according to the provided page number and page size. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters (task names, test cases, - * occurrence range). + * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters + * (task names, test cases, occurrence range, error categories). * @return A {@link FeedbackAnalysisResponseDTO} object containing: * - A {@link SearchResultPageDTO} of paginated feedback details. * - The total number of distinct results for the exercise. - * - The total number of tasks associated with the feedback. - * - A list of test case names included in the feedback. + * - A set of task names, including "Not assigned to a task" if applicable. + * - A list of active test case names used in the feedback. + * - A list of predefined error categories ("Student Error," "Ares Error," "AST Error") available for filtering. */ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data) { // 1. Fetch programming exercise with associated test cases ProgrammingExercise programmingExercise = programmingExerciseRepository.findWithTestCasesByIdElseThrow(exerciseId); + // 2. Get the distinct count of results for calculating relative feedback counts long distinctResultCount = studentParticipationRepository.countDistinctResultsByExerciseId(exerciseId); - // 2. Extract test case names using streams - List testCaseNames = programmingExercise.getTestCases().stream().map(ProgrammingExerciseTestCase::getTestName).toList(); + // 3. Extract only active test case names for use in filtering options + List activeTestCaseNames = programmingExercise.getTestCases().stream().filter(ProgrammingExerciseTestCase::isActive).map(ProgrammingExerciseTestCase::getTestName) + .toList(); + // 4. Retrieve all tasks associated with the exercise and map their names List tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + Set taskNames = tasks.stream().map(ProgrammingExerciseTask::getTaskName).collect(Collectors.toSet()); - // 3. Generate filter task names directly - List filterTaskNames = data.getFilterTasks().stream().map(index -> { - int idx = Integer.parseInt(index); - return (idx > 0 && idx <= tasks.size()) ? tasks.get(idx - 1).getTaskName() : null; - }).filter(Objects::nonNull).toList(); + // 5. Include unassigned tasks if specified by the filter; otherwise, only include specified tasks + List includeUnassignedTasks = new ArrayList<>(taskNames); + if (!data.getFilterTasks().isEmpty()) { + includeUnassignedTasks.removeAll(data.getFilterTasks()); + } + else { + includeUnassignedTasks.clear(); + } - // 4. Set minOccurrence and maxOccurrence based on filterOccurrence + // 6. Define the occurrence range based on filter parameters long minOccurrence = data.getFilterOccurrence().length == 2 ? Long.parseLong(data.getFilterOccurrence()[0]) : 0; long maxOccurrence = data.getFilterOccurrence().length == 2 ? Long.parseLong(data.getFilterOccurrence()[1]) : Integer.MAX_VALUE; - // 5. Create pageable object for pagination + // 7. Define the error categories to filter based on user selection + List filterErrorCategories = data.getFilterErrorCategories(); + + // 8. Set up pagination and sorting based on input data final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS); - // 6. Fetch filtered feedback from the repository + // 9. Query the database to retrieve paginated and filtered feedback final Page feedbackDetailPage = studentParticipationRepository.findFilteredFeedbackByExerciseId(exerciseId, - StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), filterTaskNames, minOccurrence, maxOccurrence, - pageable); - - // 7. Process feedback details - // Map to index (+1 for 1-based indexing) - List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> { - String taskIndex = tasks.stream().filter(task -> task.getTaskName().equals(detail.taskNumber())).findFirst().map(task -> String.valueOf(tasks.indexOf(task) + 1)) - .orElse("0"); - return new FeedbackDetailDTO(detail.count(), (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), taskIndex, "StudentError"); - }).toList(); - - // 8. Return the response DTO containing feedback details, total elements, and test case/task info - return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), tasks.size(), - testCaseNames); + StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeUnassignedTasks, minOccurrence, + 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.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"); + + // 12. Return response containing processed feedback details, task names, active test case names, and error categories + return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), taskNames, + activeTestCaseNames, ERROR_CATEGORIES); } /** @@ -629,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 5e28aa48b288..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; @@ -283,39 +287,52 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo } /** - * GET /exercises/{exerciseId}/feedback-details : Retrieves paginated and filtered aggregated feedback details for a given exercise. - * The feedback details include counts and relative counts of feedback occurrences, test case names, and task numbers. - * The method allows filtering by a search term and sorting by various fields. + * GET /exercises/{exerciseId}/feedback-details : Retrieves paginated and filtered aggregated feedback details for a specified exercise. *
- * Pagination is applied based on the provided query parameters, including page number, page size, sorting order, and search term. - * Sorting is applied by the specified sorted column and sorting order. If the provided sorted column is not valid for sorting (e.g., "taskNumber" or "errorCategory"), - * the sorting defaults to "count". + * This endpoint provides detailed feedback analytics, including: + * - The count and relative count (percentage) of each unique feedback entry. + * - Associated test case names. + * - Task names, mapped from test cases. *
- * Filtering is applied based on: - * - Task numbers (mapped to task names) - * - Test case names - * - Occurrence range (minimum and maximum occurrences) - *
- * The response contains both the paginated feedback details and the total count of distinct results for the exercise. + * Pagination, sorting, and filtering options allow flexible data retrieval: + *
    + *
  • Pagination: Based on page number and page size, as specified in the request.
  • + *
  • Sorting: By column (e.g., "count" or "detailText") and sorting order (ASCENDING or DESCENDING). + * If the specified column is not valid for sorting, the default sorting column is "count".
  • + *
  • Filtering: + *
      + *
    • Task names: Filters feedback entries by specific task names, including "Not assigned to task" if unassigned feedback is requested.
    • + *
    • Test case names: Filters feedback by specified test cases, using only active test cases from the exercise.
    • + *
    • Occurrence range: Filters by the minimum and maximum number of occurrences (inclusive).
    • + *
    • Search term: Case-insensitive filter applied to feedback detail text.
    • + *
    • Error categories: Filters feedback entries by specified error categories (e.g., "Student Error," "Ares Error," and "AST Error").
    • + *
    + *
  • + *
* - * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @param data A {@link FeedbackPageableDTO} object containing pagination and filtering parameters, such as: - * - Page number - * - Page size - * - Search term (optional) - * - Sorting order (ASCENDING or DESCENDING) - * - Sorted column - * - Filter task numbers (optional) - * - Filter test case names (optional) - * - Occurrence range (optional) + * @param exerciseId The unique identifier of the exercise for which feedback details are requested. + * @param data A {@link FeedbackPageableDTO} object containing pagination, sorting, and filtering parameters, including: + *
    + *
  • Page number and page size
  • + *
  • Search term (optional)
  • + *
  • Sorting order (ASCENDING or DESCENDING)
  • + *
  • Sorted column
  • + *
  • Filter task names (optional)
  • + *
  • Filter test case names (optional)
  • + *
  • Occurrence range (optional)
  • + *
  • Error categories (optional)
  • + *
* @return A {@link ResponseEntity} containing a {@link FeedbackAnalysisResponseDTO}, which includes: - * - {@link SearchResultPageDTO < FeedbackDetailDTO >} feedbackDetails: Paginated feedback details for the exercise. - * - long totalItems: The total number of feedback items (used for pagination). - * - int totalAmountOfTasks: The total number of tasks associated with the feedback. - * - List testCaseNames: A list of test case names included in the feedback. + *
    + *
  • {@link SearchResultPageDTO < FeedbackDetailDTO >} feedbackDetails: Paginated and filtered feedback details for the exercise.
  • + *
  • long totalItems: The total count of feedback entries (for pagination).
  • + *
  • Set taskNames: A set of task names relevant to the feedback items, including "Not assigned to task" if applicable.
  • + *
  • List testCaseNames: A list of active test case names used in the feedback.
  • + *
  • List errorCategories: The list of error categories included in the feedback details, such as "Student Error," "Ares Error," and "AST Error".
  • + *
*/ @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); @@ -330,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/athena/service/AthenaRepositoryExportService.java b/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaRepositoryExportService.java index bf7a95437789..e719a74d131b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaRepositoryExportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaRepositoryExportService.java @@ -89,12 +89,8 @@ public File exportRepository(long exerciseId, Long submissionId, RepositoryType var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); checkFeedbackSuggestionsOrAutomaticFeedbackEnabledElseThrow(programmingExercise); - var exportOptions = new RepositoryExportOptionsDTO(); - exportOptions.setAnonymizeRepository(true); - exportOptions.setExportAllParticipants(false); - exportOptions.setFilterLateSubmissions(true); - exportOptions.setFilterLateSubmissionsDate(programmingExercise.getDueDate()); - exportOptions.setFilterLateSubmissionsIndividualDueDate(false); // Athena currently does not support individual due dates + // Athena currently does not support individual due dates + var exportOptions = new RepositoryExportOptionsDTO(false, true, false, programmingExercise.getDueDate(), false, false, false, true, false); if (!Files.exists(repoDownloadClonePath)) { Files.createDirectories(repoDownloadClonePath); 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..3b0a4bc083c9 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,18 @@ 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); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.competencyRepository = competencyRepository; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; } /** @@ -59,17 +65,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 +117,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..88cad15f1000 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; @@ -31,7 +30,6 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; -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; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -84,15 +82,13 @@ public class CourseCompetencyService { private final LearningObjectImportService learningObjectImportService; - private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; - private final CourseRepository courseRepository; public CourseCompetencyService(CompetencyProgressRepository competencyProgressRepository, CourseCompetencyRepository courseCompetencyRepository, CompetencyRelationRepository competencyRelationRepository, CompetencyProgressService competencyProgressService, ExerciseService exerciseService, LectureUnitService lectureUnitService, LearningPathService learningPathService, AuthorizationCheckService authCheckService, StandardizedCompetencyRepository standardizedCompetencyRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { this.competencyProgressRepository = competencyProgressRepository; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyRelationRepository = competencyRelationRepository; @@ -104,7 +100,6 @@ public CourseCompetencyService(CompetencyProgressRepository competencyProgressRe this.standardizedCompetencyRepository = standardizedCompetencyRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.learningObjectImportService = learningObjectImportService; - this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; this.courseRepository = courseRepository; } @@ -213,34 +208,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); - - importedCompetency = courseCompetencyRepository.save(importedCompetency); - idToImportedCompetency.put(courseCompetency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); - } + Function createNewCourseCompetency = courseCompetency -> switch (courseCompetency) { + case Competency competency -> new Competency(competency); + case Prerequisite prerequisite -> new Prerequisite(prerequisite); + default -> throw new IllegalStateException("Unexpected value: " + courseCompetency); + }; - 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..96a68280f334 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; @@ -45,8 +43,7 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, - competencyLectureUnitLinkRepository, courseRepository); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.prerequisiteRepository = prerequisiteRepository; } @@ -59,17 +56,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/atlas/web/CompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java index 9bfb3a92acfe..1bc5b2202aeb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java @@ -337,8 +337,12 @@ private void checkCompetencyAttributesForUpdate(Competency competency) { } private void checkCompetencyAttributes(Competency competency) { - if (competency.getTitle() == null || competency.getTitle().trim().isEmpty() || competency.getMasteryThreshold() < 1 || competency.getMasteryThreshold() > 100) { - throw new BadRequestAlertException("The attributes of the competency are invalid!", ENTITY_NAME, "invalidPrerequisiteAttributes"); + if (competency.getTitle() == null || competency.getTitle().trim().isEmpty()) { + throw new BadRequestAlertException("The title of a competency is invalid!", ENTITY_NAME, "invalidCompetencyTitle"); + } + if (competency.getMasteryThreshold() < 1 || competency.getMasteryThreshold() > 100) { + throw new BadRequestAlertException("The mastery threshold of the competency '" + competency.getTitle() + "' is invalid!", ENTITY_NAME, + "invalidCompetencyMasteryThreshold"); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java index 21b3dfac0b81..2952c5213432 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java @@ -137,21 +137,4 @@ default Post findMessagePostByIdElseThrow(Long postId) throws EntityNotFoundExce WHERE p.id = :postId AND answer.author = cp.user """) Set findUsersWhoRepliedInMessage(@Param("postId") Long postId); - - /** - * Finds tags of course-wide messages - * - * @param courseId the course - * @return list of tags - */ - // TODO: unused, delete - @Query(""" - SELECT DISTINCT tag - FROM Post post - LEFT JOIN post.tags tag - LEFT JOIN Channel channel ON channel.id = post.conversation.id - WHERE channel.course.id = :courseId - AND channel.isCourseWide = TRUE - """) - List findPostTagsForCourse(@Param("courseId") Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepositoryImpl.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepositoryImpl.java index cfdd5f71443e..aae587022c93 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepositoryImpl.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepositoryImpl.java @@ -1,6 +1,8 @@ package de.tum.cit.aet.artemis.communication.repository; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import jakarta.persistence.EntityManager; @@ -50,6 +52,8 @@ public Page findPostIdsWithSpecification(Specification specification query.setMaxResults(pageable.getPageSize()); List postIds = query.getResultList(); + // removes all duplicates from the answer posts + List uniquePostIds = new ArrayList<>(new LinkedHashSet<>(postIds)); // Count query CriteriaQuery countQuery = builder.createQuery(Long.class); @@ -66,6 +70,6 @@ public Page findPostIdsWithSpecification(Specification specification Long countResult = entityManager.createQuery(countQuery).getSingleResult(); long count = countResult != null ? countResult : 0L; - return new PageImpl<>(postIds, pageable, count); + return new PageImpl<>(uniquePostIds, pageable, count); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java index 19063ed66a09..f1912c85c795 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java @@ -40,7 +40,7 @@ public interface FaqRepository extends ArtemisJpaRepository { Set findAllByCourseIdAndFaqState(Long courseId, FaqState faqState); - @Transactional + @Transactional // ok because of delete @Modifying void deleteAllByCourseId(Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java index 0020deca7faf..a54058431b76 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java @@ -3,8 +3,6 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; -import java.util.Comparator; -import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -24,8 +22,6 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import com.google.common.collect.Lists; - import de.tum.cit.aet.artemis.communication.domain.ConversationNotificationRecipientSummary; import de.tum.cit.aet.artemis.communication.domain.CreatedConversationMessage; import de.tum.cit.aet.artemis.communication.domain.DisplayPriority; @@ -49,7 +45,6 @@ import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; import de.tum.cit.aet.artemis.communication.service.notifications.ConversationNotificationService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; -import de.tum.cit.aet.artemis.communication.service.similarity.PostSimilarityComparisonStrategy; 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.exception.AccessForbiddenException; @@ -66,8 +61,6 @@ @Service public class ConversationMessagingService extends PostingService { - private static final int TOP_K_SIMILARITY_RESULTS = 5; - private static final Logger log = LoggerFactory.getLogger(ConversationMessagingService.class); private final ConversationService conversationService; @@ -82,14 +75,11 @@ public class ConversationMessagingService extends PostingService { private final SingleUserNotificationRepository singleUserNotificationRepository; - private final PostSimilarityComparisonStrategy postContentCompareStrategy; - protected ConversationMessagingService(CourseRepository courseRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, ConversationMessageRepository conversationMessageRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, UserRepository userRepository, ConversationService conversationService, ConversationParticipantRepository conversationParticipantRepository, ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, - GroupNotificationService groupNotificationService, SingleUserNotificationRepository singleUserNotificationRepository, - PostSimilarityComparisonStrategy postContentCompareStrategy) { + GroupNotificationService groupNotificationService, SingleUserNotificationRepository singleUserNotificationRepository) { super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); this.conversationService = conversationService; this.conversationMessageRepository = conversationMessageRepository; @@ -97,7 +87,6 @@ protected ConversationMessagingService(CourseRepository courseRepository, Exerci this.channelAuthorizationService = channelAuthorizationService; this.groupNotificationService = groupNotificationService; this.singleUserNotificationRepository = singleUserNotificationRepository; - this.postContentCompareStrategy = postContentCompareStrategy; } /** @@ -433,41 +422,6 @@ private Conversation mayUpdateOrDeleteMessageElseThrow(Post existingMessagePost, } } - /** - * Calculates k similar posts based on the underlying content comparison strategy - * - * @param courseId id of the course in which similar posts are searched for - * @param post post that is to be created and check for similar posts beforehand - * @return list of similar posts - */ - // TODO: unused, remove - public List getSimilarPosts(Long courseId, Post post) { - PostContextFilterDTO postContextFilter = new PostContextFilterDTO(courseId, null, null, null, null, false, false, false, null, null); - List coursePosts = this.getCourseWideMessages(Pageable.unpaged(), postContextFilter, userRepository.getUser(), courseId).stream() - .sorted(Comparator.comparing(coursePost -> postContentCompareStrategy.performSimilarityCheck(post, coursePost))).toList(); - - // sort course posts by calculated similarity scores - setAuthorRoleOfPostings(coursePosts, courseId); - return Lists.reverse(coursePosts).stream().limit(TOP_K_SIMILARITY_RESULTS).toList(); - } - - /** - * Checks course and user validity, - * retrieves all tags for posts in a certain course - * - * @param courseId id of the course the tags belongs to - * @return tags of all posts that belong to the course - */ - // TODO: unused, delete - public List getAllCourseTags(Long courseId) { - final User user = userRepository.getUserWithGroupsAndAuthorities(); - final Course course = courseRepository.findByIdElseThrow(courseId); - - // checks - preCheckUserAndCourseForCommunicationOrMessaging(user, course); - return conversationMessageRepository.findPostTagsForCourse(courseId); - } - @Override public String getEntityName() { return METIS_POST_ENTITY_NAME; diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java index d009074927b2..cf5cc2c67cc8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java @@ -41,11 +41,15 @@ public class ConversationNotificationService { private final SingleUserNotificationRepository singleUserNotificationRepository; + private final SingleUserNotificationService singleUserNotificationService; + public ConversationNotificationService(ConversationNotificationRepository conversationNotificationRepository, - GeneralInstantNotificationService generalInstantNotificationService, SingleUserNotificationRepository singleUserNotificationRepository) { + GeneralInstantNotificationService generalInstantNotificationService, SingleUserNotificationRepository singleUserNotificationRepository, + SingleUserNotificationService singleUserNotificationService) { this.conversationNotificationRepository = conversationNotificationRepository; this.generalInstantNotificationService = generalInstantNotificationService; this.singleUserNotificationRepository = singleUserNotificationRepository; + this.singleUserNotificationService = singleUserNotificationService; } /** @@ -83,7 +87,7 @@ public ConversationNotification createNotification(Post createdMessage, Conversa String[] placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), conversationName, createdMessage.getAuthor().getName(), conversationType); ConversationNotification notification = createConversationMessageNotification(course.getId(), createdMessage, notificationType, notificationText, true, placeholders); - save(notification, mentionedUsers, placeholders); + save(notification, mentionedUsers, placeholders, createdMessage); return notification; } @@ -93,11 +97,12 @@ public static String[] createPlaceholdersNewMessageChannelText(String courseTitl return new String[] { courseTitle, messageContent, messageCreationDate, conversationName, authorName, conversationType }; } - private void save(ConversationNotification notification, Set mentionedUsers, String[] placeHolders) { + private void save(ConversationNotification notification, Set mentionedUsers, String[] placeHolders, Post createdMessage) { conversationNotificationRepository.save(notification); - Set mentionedUserNotifications = mentionedUsers.stream().map(mentionedUser -> SingleUserNotificationFactory - .createNotification(notification.getMessage(), NotificationType.CONVERSATION_USER_MENTIONED, notification.getText(), placeHolders, mentionedUser)) + Set mentionedUserNotifications = singleUserNotificationService + .filterAllowedRecipientsInMentionedUsers(mentionedUsers, createdMessage.getConversation()).map(mentionedUser -> SingleUserNotificationFactory + .createNotification(notification.getMessage(), NotificationType.CONVERSATION_USER_MENTIONED, notification.getText(), placeHolders, mentionedUser)) .collect(Collectors.toSet()); singleUserNotificationRepository.saveAll(mentionedUserNotifications); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java index 4e242f93e0fb..33def5698b9f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java @@ -443,23 +443,34 @@ public void notifyInvolvedUsersAboutNewMessageReply(Post post, SingleUserNotific usersInvolved.add(post.getAuthor()); } - mentionedUsers.stream().filter(user -> { - boolean isChannelAndCourseWide = post.getConversation() instanceof Channel channel && channel.getIsCourseWide(); - boolean isChannelVisibleToStudents = !(post.getConversation() instanceof Channel channel) || conversationService.isChannelVisibleToStudents(channel); - boolean isChannelVisibleToMentionedUser = isChannelVisibleToStudents - || authorizationCheckService.isAtLeastTeachingAssistantInCourse(post.getConversation().getCourse(), user); - - // Only send a notification to the mentioned user if... - // (for course-wide channels) ...the course-wide channel is visible - // (for all other cases) ...the user is a member of the conversation - return (isChannelAndCourseWide && isChannelVisibleToMentionedUser) || conversationService.isMember(post.getConversation().getId(), user.getId()); - }).forEach(mentionedUser -> notifyUserAboutNewMessageReply(savedAnswerMessage, notification, mentionedUser, author, CONVERSATION_USER_MENTIONED)); + filterAllowedRecipientsInMentionedUsers(mentionedUsers, post.getConversation()) + .forEach(mentionedUser -> notifyUserAboutNewMessageReply(savedAnswerMessage, notification, mentionedUser, author, CONVERSATION_USER_MENTIONED)); Conversation conv = conversationService.getConversationById(post.getConversation().getId()); usersInvolved.stream().filter(userInvolved -> !mentionedUsers.contains(userInvolved)) .forEach(userInvolved -> notifyUserAboutNewMessageReply(savedAnswerMessage, notification, userInvolved, author, getAnswerMessageNotificationType(conv))); } + /** + * Filters which of the mentioned users are permitted to receive a notification + * + * @param mentionedUsers users mentioned in the answer message + * @param conversation the conversation of the created post/notification, used for filtering + * @return the stream of mentioned users which are permitted to receive the notification for the given conversation + */ + public Stream filterAllowedRecipientsInMentionedUsers(Set mentionedUsers, Conversation conversation) { + return mentionedUsers.stream().filter(user -> { + boolean isChannelAndCourseWide = conversation instanceof Channel channel && channel.getIsCourseWide(); + boolean isChannelVisibleToStudents = !(conversation instanceof Channel channel) || conversationService.isChannelVisibleToStudents(channel); + boolean isChannelVisibleToMentionedUser = isChannelVisibleToStudents || authorizationCheckService.isAtLeastTeachingAssistantInCourse(conversation.getCourse(), user); + + // Only send a notification to the mentioned user if... + // (for course-wide channels) ...the course-wide channel is visible + // (for all other cases) ...the user is a member of the conversation + return (isChannelAndCourseWide && isChannelVisibleToMentionedUser) || conversationService.isMember(conversation.getId(), user.getId()); + }); + } + /** * Saves the given notification in database and sends it to the client via websocket. * Also creates and sends an instant notification. diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/PostSimilarityComparisonStrategy.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/PostSimilarityComparisonStrategy.java deleted file mode 100644 index 93ca9de0c9f9..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/PostSimilarityComparisonStrategy.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.tum.cit.aet.artemis.communication.service.similarity; - -import de.tum.cit.aet.artemis.communication.domain.Post; - -/** - * This interface offers a method that performs a similarity check on two posts that are compared to each other. - * Every strategy that implements this interface has to provide this method in order to be applicable as post similarity comparison strategy, that can be interchanged easily. - */ -public interface PostSimilarityComparisonStrategy { - - /** - * Method implemented by every strategy; compares two posts using any suitable algorithm to determine similarity - * - * @param post1 first post object that is compared against - * @param post2 second post object that is compared against - * @return the calculated similarity score - */ - Double performSimilarityCheck(Post post1, Post post2); -} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/TitleJaccardSimilarityCompareStrategy.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/TitleJaccardSimilarityCompareStrategy.java deleted file mode 100644 index 53e7285cd41b..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/similarity/TitleJaccardSimilarityCompareStrategy.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.tum.cit.aet.artemis.communication.service.similarity; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; - -import org.apache.commons.text.similarity.JaccardSimilarity; -import org.springframework.context.annotation.Primary; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import de.tum.cit.aet.artemis.communication.domain.Post; - -/** - * Implementation of a PostSimilarityComparisonStrategy to be used when searching for duplicates during post creation. - * Jaccard Similarity is a common proximity measurement used to compute the similarity between two objects, such as two text documents; - * In the context of post comparison, the TitleJaccardSimilarityCompareStrategy determines the similarity between two titles (i.e. document) using the number of terms used in both - * documents. - * We use the JaccardSimilarity implementation provided by the org.apache.commons.text.similarity package. - */ -@Profile(PROFILE_CORE) -@Primary -@Component -public class TitleJaccardSimilarityCompareStrategy implements PostSimilarityComparisonStrategy { - - @Override - public Double performSimilarityCheck(Post post1, Post post2) { - JaccardSimilarity jaccardSimilarity = new JaccardSimilarity(); - Double similarityScore = 0.0; - - // we only compute a similarity score if the title of both posts are defined - if (post1.getTitle() != null && post2.getTitle() != null) { - similarityScore = jaccardSimilarity.apply(post1.getTitle().toLowerCase(), post2.getTitle().toLowerCase()); - } - return similarityScore; - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java index 5031332a8862..bfa04d53cc5f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java @@ -201,34 +201,4 @@ public ResponseEntity updateDisplayPriority(@PathVariable Long courseId, @ Post postWithUpdatedDisplayPriority = conversationMessagingService.changeDisplayPriority(courseId, postId, displayPriority); return ResponseEntity.ok().body(postWithUpdatedDisplayPriority); } - - /** - * POST /courses/{courseId}/messages/similarity-check : trigger a similarity check for post to be created - * - * @param courseId id of the course the post should be published in - * @param post post to create - * @return ResponseEntity with status 200 (OK) - */ - @PostMapping("courses/{courseId}/messages/similarity-check") - @EnforceAtLeastStudent - // TODO: unused, remove - public ResponseEntity> computeSimilarityScoresWitCoursePosts(@PathVariable Long courseId, @RequestBody Post post) { - List similarPosts = conversationMessagingService.getSimilarPosts(courseId, post); - return ResponseEntity.ok().body(similarPosts); - } - - /** - * GET /courses/{courseId}/posts/tags : Get all tags for posts in a certain course - * - * @param courseId id of the course the post belongs to - * @return the ResponseEntity with status 200 (OK) and with body all tags for posts in that course, - * or 400 (Bad Request) if the checks on user or course validity fail - */ - @GetMapping("courses/{courseId}/messages/tags") - // TODO: unused, delete - @EnforceAtLeastStudent - public ResponseEntity> getAllPostTagsForCourse(@PathVariable Long courseId) { - List tags = conversationMessagingService.getAllCourseTags(courseId); - return new ResponseEntity<>(tags, null, HttpStatus.OK); - } } 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/config/WebConfigurer.java b/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java index 646fa942e7e0..e79b1de3939c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java @@ -24,6 +24,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.core.env.Environment; import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @@ -117,7 +118,7 @@ private String resolvePathPrefix() { public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = jHipsterProperties.getCors(); - if (config.getAllowedOrigins() != null && !config.getAllowedOrigins().isEmpty()) { + if (!CollectionUtils.isEmpty(config.getAllowedOrigins()) || !CollectionUtils.isEmpty(config.getAllowedOriginPatterns())) { log.debug("Registering CORS filter"); source.registerCorsConfiguration("/api/**", config); source.registerCorsConfiguration("/management/**", config); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobExecution.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobExecution.java new file mode 100644 index 000000000000..66a3880056a6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobExecution.java @@ -0,0 +1,67 @@ +package de.tum.cit.aet.artemis.core.domain; + +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@Entity +@Table(name = "cleanup_job_execution") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class CleanupJobExecution extends DomainObject { + + @Column(name = "delete_from") + private ZonedDateTime deleteFrom; + + @Column(name = "delete_to") + private ZonedDateTime deleteTo; + + @Column(name = "deleted_at") + @NotNull + private ZonedDateTime deletionTimestamp; + + @Enumerated(EnumType.STRING) + @Column(name = "operation_type") + private CleanupJobType cleanupJobType; + + public CleanupJobExecution() { + } + + public ZonedDateTime getDeleteFrom() { + return deleteFrom; + } + + public void setDeleteFrom(ZonedDateTime deleteFrom) { + this.deleteFrom = deleteFrom; + } + + public ZonedDateTime getDeleteTo() { + return deleteTo; + } + + public void setDeleteTo(ZonedDateTime deleteTo) { + this.deleteTo = deleteTo; + } + + public ZonedDateTime getDeletionTimestamp() { + return deletionTimestamp; + } + + public void setDeletionTimestamp(ZonedDateTime deletionTimestamp) { + this.deletionTimestamp = deletionTimestamp; + } + + public CleanupJobType getCleanupJobType() { + return cleanupJobType; + } + + public void setCleanupJobType(CleanupJobType cleanupJobType) { + this.cleanupJobType = cleanupJobType; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobType.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobType.java new file mode 100644 index 000000000000..206e01c3fff6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/CleanupJobType.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.core.domain; + +public enum CleanupJobType { + + ORPHANS("deleteOrphans"), PLAGIARISM_COMPARISONS("deletePlagiarismComparisons"), NON_RATED_RESULTS("deleteNonRatedResults"), RATED_RESULTS("deleteRatedResults"), + SUBMISSION_VERSIONS("deleteSubmissionVersions"), FEEDBACK("deleteFeedback"); + + private final String label; + + CleanupJobType(String name) { + this.label = name; + } + + public String label() { + return label; + } + + @Override + public String toString() { + return this.name(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CleanupServiceExecutionRecordDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CleanupServiceExecutionRecordDTO.java new file mode 100644 index 000000000000..34936c669c22 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CleanupServiceExecutionRecordDTO.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.core.dto; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.CleanupJobExecution; + +/** + * DTO representing a record of a cleanup service execution. + * This DTO contains information about the execution date and the type of the job that was executed. + * The JSON serialization will include non-empty fields only. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CleanupServiceExecutionRecordDTO(ZonedDateTime executionDate, String jobType) { + + public static CleanupServiceExecutionRecordDTO of(CleanupJobExecution cleanupJobExecution) { + return new CleanupServiceExecutionRecordDTO(cleanupJobExecution.getDeletionTimestamp(), cleanupJobExecution.getCleanupJobType().label()); + } + +} 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/dto/RepositoryExportOptionsDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/RepositoryExportOptionsDTO.java index 5d9bb8eb0847..2ea1300ef635 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/RepositoryExportOptionsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/RepositoryExportOptionsDTO.java @@ -8,96 +8,21 @@ * This is a dto for the repository export options. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -// TODO: convert into a Record -public class RepositoryExportOptionsDTO { +public record RepositoryExportOptionsDTO(boolean exportAllParticipants, boolean filterLateSubmissions, boolean filterLateSubmissionsIndividualDueDate, + ZonedDateTime filterLateSubmissionsDate, boolean excludePracticeSubmissions, boolean addParticipantName, boolean combineStudentCommits, boolean anonymizeRepository, + boolean normalizeCodeStyle) { - private boolean exportAllParticipants; - - private boolean filterLateSubmissions; - - private boolean filterLateSubmissionsIndividualDueDate; - - private ZonedDateTime filterLateSubmissionsDate; - - private boolean excludePracticeSubmissions; - - private boolean addParticipantName; - - private boolean combineStudentCommits; - - private boolean anonymizeRepository; - - private boolean normalizeCodeStyle; - - public boolean isExportAllParticipants() { - return exportAllParticipants; - } - - public void setExportAllParticipants(boolean exportAllParticipants) { - this.exportAllParticipants = exportAllParticipants; - } - - public boolean isFilterLateSubmissions() { - return filterLateSubmissions; - } - - public void setFilterLateSubmissions(boolean filterLateSubmissions) { - this.filterLateSubmissions = filterLateSubmissions; - } - - public boolean isFilterLateSubmissionsIndividualDueDate() { - return filterLateSubmissionsIndividualDueDate; - } - - public void setFilterLateSubmissionsIndividualDueDate(boolean filterLateSubmissionsIndividualDueDate) { - this.filterLateSubmissionsIndividualDueDate = filterLateSubmissionsIndividualDueDate; - } - - public ZonedDateTime getFilterLateSubmissionsDate() { - return filterLateSubmissionsDate; - } - - public void setFilterLateSubmissionsDate(ZonedDateTime filterLateSubmissionsDate) { - this.filterLateSubmissionsDate = filterLateSubmissionsDate; - } - - public boolean isExcludePracticeSubmissions() { - return excludePracticeSubmissions; - } - - public void setExcludePracticeSubmissions(boolean excludePracticeSubmissions) { - this.excludePracticeSubmissions = excludePracticeSubmissions; - } - - public boolean isAddParticipantName() { - return addParticipantName; - } - - public void setAddParticipantName(boolean addParticipantName) { - this.addParticipantName = addParticipantName; - } - - public boolean isCombineStudentCommits() { - return combineStudentCommits; - } - - public void setCombineStudentCommits(boolean combineStudentCommits) { - this.combineStudentCommits = combineStudentCommits; - } - - public boolean isAnonymizeRepository() { - return anonymizeRepository; - } - - public void setAnonymizeRepository(boolean anonymizeRepository) { - this.anonymizeRepository = anonymizeRepository; + public RepositoryExportOptionsDTO() { + this(false, false, false, null, false, false, false, false, false); } - public boolean isNormalizeCodeStyle() { - return normalizeCodeStyle; + public RepositoryExportOptionsDTO copyWith(boolean filterLateSubmissionsIndividualDueDate, ZonedDateTime filterLateSubmissionsDate) { + return new RepositoryExportOptionsDTO(exportAllParticipants, filterLateSubmissions, filterLateSubmissionsIndividualDueDate, filterLateSubmissionsDate, + excludePracticeSubmissions, addParticipantName, combineStudentCommits, anonymizeRepository, normalizeCodeStyle); } - public void setNormalizeCodeStyle(boolean normalizeCodeStyle) { - this.normalizeCodeStyle = normalizeCodeStyle; + public RepositoryExportOptionsDTO copyWithAnonymizeRepository(boolean anonymizeRepository) { + return new RepositoryExportOptionsDTO(exportAllParticipants, filterLateSubmissions, filterLateSubmissionsIndividualDueDate, filterLateSubmissionsDate, + excludePracticeSubmissions, addParticipantName, combineStudentCommits, anonymizeRepository, normalizeCodeStyle); } } 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/repository/cleanup/CleanupJobExecutionRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/cleanup/CleanupJobExecutionRepository.java new file mode 100644 index 000000000000..e67b3c90f9d7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/cleanup/CleanupJobExecutionRepository.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.core.repository.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.domain.CleanupJobExecution; +import de.tum.cit.aet.artemis.core.domain.CleanupJobType; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +/** + * Spring Data JPA repository for the CleanupJobExecution entity. + */ +@Profile(PROFILE_CORE) +@Repository +public interface CleanupJobExecutionRepository extends ArtemisJpaRepository { + + CleanupJobExecution findTopByCleanupJobTypeOrderByDeletionTimestampDesc(CleanupJobType jobType); + +} 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 a286744dbe8a..81aa6a27f6b0 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 @@ -678,6 +678,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/DataExportScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/DataExportScheduleService.java index f9089d6e5cb8..f36dbb8ed243 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/DataExportScheduleService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/DataExportScheduleService.java @@ -72,25 +72,26 @@ public void createDataExportsAndDeleteOldOnes() throws InterruptedException { log.info("Creating data exports and deleting old ones"); Set successfulDataExports = Collections.synchronizedSet(new HashSet<>()); var dataExportsToBeCreated = dataExportRepository.findAllToBeCreated(); - ExecutorService executor = Executors.newFixedThreadPool(10); - dataExportsToBeCreated.forEach(dataExport -> executor.execute(() -> createDataExport(dataExport, successfulDataExports))); - executor.shutdown(); - ZonedDateTime thresholdDate = ZonedDateTime.now().minusDays(7); - var dataExportsToBeDeleted = dataExportRepository.findAllToBeDeleted(thresholdDate); - dataExportsToBeDeleted.forEach(this::deleteDataExport); - Optional admin = userService.findInternalAdminUser(); - if (admin.isEmpty()) { - log.warn("No internal admin user found. Cannot send email to admin about successful creation of data exports."); - return; - } - // This job runs at 4 am by default and the next scheduled job runs at 5 am, so we should allow 60 minutes for the creation. - // If the creation doesn't finish within 60 minutes, all pending exports will be picked up when the job runs the next time. - if (!executor.awaitTermination(60, java.util.concurrent.TimeUnit.MINUTES)) { - log.info("Not all pending data exports could be created within 60 minutes."); - executor.shutdownNow(); - } - if (!successfulDataExports.isEmpty()) { - mailService.sendSuccessfulDataExportsEmailToAdmin(admin.get(), successfulDataExports); + try (ExecutorService executor = Executors.newFixedThreadPool(10)) { + dataExportsToBeCreated.forEach(dataExport -> executor.execute(() -> createDataExport(dataExport, successfulDataExports))); + executor.shutdown(); + ZonedDateTime thresholdDate = ZonedDateTime.now().minusDays(7); + var dataExportsToBeDeleted = dataExportRepository.findAllToBeDeleted(thresholdDate); + dataExportsToBeDeleted.forEach(this::deleteDataExport); + Optional admin = userService.findInternalAdminUser(); + if (admin.isEmpty()) { + log.warn("No internal admin user found. Cannot send email to admin about successful creation of data exports."); + return; + } + // This job runs at 4 am by default and the next scheduled job runs at 5 am, so we should allow 60 minutes for the creation. + // If the creation doesn't finish within 60 minutes, all pending exports will be picked up when the job runs the next time. + if (!executor.awaitTermination(60, java.util.concurrent.TimeUnit.MINUTES)) { + log.info("Not all pending data exports could be created within 60 minutes."); + executor.shutdownNow(); + } + if (!successfulDataExports.isEmpty()) { + mailService.sendSuccessfulDataExportsEmailToAdmin(admin.get(), successfulDataExports); + } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java new file mode 100644 index 000000000000..2ed93c8d8926 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/cleanup/DataCleanupService.java @@ -0,0 +1,236 @@ +package de.tum.cit.aet.artemis.core.service.cleanup; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.assessment.repository.cleanup.FeedbackCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.LongFeedbackTextCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.PlagiarismComparisonCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.RatingCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.ResultCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.StudentScoreCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.TeamScoreCleanupRepository; +import de.tum.cit.aet.artemis.assessment.repository.cleanup.TextBlockCleanupRepository; +import de.tum.cit.aet.artemis.core.domain.CleanupJobExecution; +import de.tum.cit.aet.artemis.core.domain.CleanupJobType; +import de.tum.cit.aet.artemis.core.dto.CleanupServiceExecutionRecordDTO; +import de.tum.cit.aet.artemis.core.repository.cleanup.CleanupJobExecutionRepository; + +@Profile(PROFILE_CORE) +@Service +public class DataCleanupService { + + private static final Logger log = LoggerFactory.getLogger(DataCleanupService.class); + + private final CleanupJobExecutionRepository cleanupJobExecutionRepository; + + private final PlagiarismComparisonCleanupRepository plagiarismComparisonCleanupRepository; + + private final ResultCleanupRepository resultCleanupRepository; + + private final RatingCleanupRepository ratingCleanupRepository; + + private final FeedbackCleanupRepository feedbackCleanupRepository; + + private final TextBlockCleanupRepository textBlockCleanupRepository; + + private final LongFeedbackTextCleanupRepository longFeedbackTextCleanupRepository; + + private final StudentScoreCleanupRepository studentScoreCleanupRepository; + + private final TeamScoreCleanupRepository teamScoreCleanupRepository; + + public DataCleanupService(CleanupJobExecutionRepository cleanupJobExecutionRepository, PlagiarismComparisonCleanupRepository plagiarismComparisonCleanupRepository, + ResultCleanupRepository resultCleanupRepository, RatingCleanupRepository ratingCleanupRepository, FeedbackCleanupRepository feedbackCleanupRepository, + TextBlockCleanupRepository textBlockCleanupRepository, LongFeedbackTextCleanupRepository longFeedbackTextCleanupRepository, + StudentScoreCleanupRepository studentScoreCleanupRepository, TeamScoreCleanupRepository teamScoreCleanupRepository) { + this.resultCleanupRepository = resultCleanupRepository; + this.ratingCleanupRepository = ratingCleanupRepository; + this.feedbackCleanupRepository = feedbackCleanupRepository; + this.textBlockCleanupRepository = textBlockCleanupRepository; + this.longFeedbackTextCleanupRepository = longFeedbackTextCleanupRepository; + this.studentScoreCleanupRepository = studentScoreCleanupRepository; + this.teamScoreCleanupRepository = teamScoreCleanupRepository; + this.cleanupJobExecutionRepository = cleanupJobExecutionRepository; + this.plagiarismComparisonCleanupRepository = plagiarismComparisonCleanupRepository; + } + + // TODO: offer the possibility to delete old submission versions + + /** + * Deletes orphaned entities that are no longer associated with valid results or participations. + * This includes feedback, text blocks, and scores that reference null results, participations, or submissions. + * + * @return a {@link CleanupServiceExecutionRecordDTO} representing the execution record of the cleanup job + */ + public CleanupServiceExecutionRecordDTO deleteOrphans() { + int deletedLongFeedbackTexts = longFeedbackTextCleanupRepository.deleteLongFeedbackTextForOrphanedFeedback(); + log.info("Deleted {} orphaned long feedback texts", deletedLongFeedbackTexts); + + int deletedTextBlocks = textBlockCleanupRepository.deleteTextBlockForEmptyFeedback(); + log.info("Deleted {} text blocks for empty feedback", deletedTextBlocks); + + int deletedOrphanFeedback = feedbackCleanupRepository.deleteOrphanFeedback(); + log.info("Deleted {} orphaned feedback entries", deletedOrphanFeedback); + + int deletedOrphanStudentScores = studentScoreCleanupRepository.deleteOrphanStudentScore(); + log.info("Deleted {} orphaned student scores", deletedOrphanStudentScores); + + int deletedOrphanTeamScores = teamScoreCleanupRepository.deleteOrphanTeamScore(); + log.info("Deleted {} orphaned team scores", deletedOrphanTeamScores); + + int deletedLongFeedbackTextsForOrphanResult = longFeedbackTextCleanupRepository.deleteLongFeedbackTextForOrphanResult(); + log.info("Deleted {} long feedback texts for orphan results", deletedLongFeedbackTextsForOrphanResult); + + int deletedTextBlocksForOrphanResults = textBlockCleanupRepository.deleteTextBlockForOrphanResults(); + log.info("Deleted {} text blocks for orphan results", deletedTextBlocksForOrphanResults); + + int deletedFeedbackForOrphanResults = feedbackCleanupRepository.deleteFeedbackForOrphanResults(); + log.info("Deleted {} feedback entries for orphan results", deletedFeedbackForOrphanResults); + + int deletedOrphanRatings = ratingCleanupRepository.deleteOrphanRating(); + log.info("Deleted {} orphan ratings", deletedOrphanRatings); + + int deletedResultsWithoutParticipation = resultCleanupRepository.deleteResultWithoutParticipationAndSubmission(); + log.info("Deleted {} results without participation and submission", deletedResultsWithoutParticipation); + + return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.ORPHANS, null, null)); + } + + /** + * Deletes plagiarism comparisons with a status of "None" that belong to courses within the specified date range. + * It retrieves the IDs of the plagiarism comparisons matching the criteria, deletes them, and records the execution of the cleanup job. + * + * @param deleteFrom the start date for selecting plagiarism comparisons + * @param deleteTo the end date for selecting plagiarism comparisons + * @return a {@link CleanupServiceExecutionRecordDTO} representing the execution record of the cleanup job + */ + public CleanupServiceExecutionRecordDTO deletePlagiarismComparisons(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) { + var pcIds = plagiarismComparisonCleanupRepository.findPlagiarismComparisonIdWithStatusNoneThatBelongToCourseWithDates(deleteFrom, deleteTo); + log.info("Deleting {} plagiarism comparisons with status 'None' between {} and {}", pcIds.size(), deleteFrom, deleteTo); + + // NOTE: we first need to delete related data to avoid foreign key constraints + // Delete all plagiarism elements that are part of the plagiarism submissions + int deletedPlagiarismElements = plagiarismComparisonCleanupRepository.deletePlagiarismSubmissionElementsByComparisonIdsIn(pcIds); + log.info("Deleted {} plagiarism elements that are part of the plagiarism submissions", deletedPlagiarismElements); + + // NOTE: we need to set submissionA and submissionB to null first to avoid foreign key constraints + int updatedPlagiarismComparisons = plagiarismComparisonCleanupRepository.setPlagiarismSubmissionsToNullInComparisonsWithIds(pcIds); + log.info("Updated {} plagiarism comparisons to set plagiarism submissions to null", updatedPlagiarismComparisons); + + // Delete all plagiarism submissions that reference plagiarism comparisons + int deletedPlagiarismSubmissions = plagiarismComparisonCleanupRepository.deletePlagiarismSubmissionsByComparisonIdsIn(pcIds); + log.info("Deleted {} plagiarism submissions that reference plagiarism comparisons", deletedPlagiarismSubmissions); + + // Delete all plagiarism comparison matches that reference plagiarism comparisons + int deletedPlagiarismComparisonMatches = plagiarismComparisonCleanupRepository.deletePlagiarismComparisonMatchesByComparisonIdsIn(pcIds); + log.info("Deleted {} plagiarism comparison matches that reference plagiarism comparisons", deletedPlagiarismComparisonMatches); + + int deletedPCs = plagiarismComparisonCleanupRepository.deleteByIdsIn(pcIds); + log.info("Deleted {} plagiarism comparisons with status 'None'", deletedPCs); + return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.PLAGIARISM_COMPARISONS, deleteFrom, deleteTo)); + } + + /** + * Deletes non-rated results, excluding the latest non-rated result for each participation(to be able to compute Competencies Scores), within the specified date range, along + * with associated long feedback texts, + * text blocks, feedback items, and participant scores. + * + * @param deleteFrom The start of the date range for deleting non-rated results. + * @param deleteTo The end of the date range for deleting non-rated results. + * @return a {@link CleanupServiceExecutionRecordDTO} representing the execution record of the cleanup job + */ + public CleanupServiceExecutionRecordDTO deleteNonLatestNonRatedResultsFeedback(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) { + int deletedLongFeedbackTexts = longFeedbackTextCleanupRepository.deleteLongFeedbackTextForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} long feedback texts for non-rated results between {} and {}", deletedLongFeedbackTexts, deleteFrom, deleteTo); + + int deletedTextBlocks = textBlockCleanupRepository.deleteTextBlockForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} text blocks for non-rated results between {} and {}", deletedTextBlocks, deleteFrom, deleteTo); + + int deletedFeedback = feedbackCleanupRepository.deleteOldNonRatedFeedbackWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} feedback entries for non-rated results between {} and {}", deletedFeedback, deleteFrom, deleteTo); + + // TODO: old results and participant scores should not be deleted automatically: if at all this could be offered as an option for the admin + // int deletedParticipantScoresForLatest = participantScoreCleanupRepository.deleteParticipantScoresForLatestNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + // log.info("Deleted {} participant scores for latest non-rated results between {} and {}", deletedParticipantScoresForLatest, deleteFrom, deleteTo); + // + // int deletedParticipantScores = participantScoreCleanupRepository.deleteParticipantScoresForNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + // log.info("Deleted {} participant scores for non-rated results between {} and {}", deletedParticipantScores, deleteFrom, deleteTo); + + // int deletedNonLatestNonRatedResults = resultCleanupRepository.deleteNonLatestNonRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + // log.info("Deleted {} non-latest non-rated results between {} and {}", deletedNonLatestNonRatedResults, deleteFrom, deleteTo); + + return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.NON_RATED_RESULTS, deleteFrom, deleteTo)); + } + + /** + * Deletes rated results, excluding the latest rated result for each participation, for courses conducted within the specified date range. + * Also deletes associated long feedback texts, text blocks, feedback items, and participant scores. + * + * @param deleteFrom The start of the date range for deleting rated results. + * @param deleteTo The end of the date range for deleting rated results. + * @return a {@link CleanupServiceExecutionRecordDTO} representing the execution record of the cleanup job + */ + public CleanupServiceExecutionRecordDTO deleteNonLatestRatedResultsFeedback(ZonedDateTime deleteFrom, ZonedDateTime deleteTo) { + int deletedLongFeedbackTexts = longFeedbackTextCleanupRepository.deleteLongFeedbackTextForRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} long feedback texts for rated results between {} and {}", deletedLongFeedbackTexts, deleteFrom, deleteTo); + + int deletedTextBlocks = textBlockCleanupRepository.deleteTextBlockForRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} text blocks for rated results between {} and {}", deletedTextBlocks, deleteFrom, deleteTo); + + int deletedFeedback = feedbackCleanupRepository.deleteOldFeedbackThatAreNotLatestRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + log.info("Deleted {} feedback entries for rated results between {} and {}", deletedFeedback, deleteFrom, deleteTo); + + // TODO: old results and participant scores should not be deleted automatically: if at all this could be offered as an option for the admin + // int deletedParticipantScoresForNonLatest = participantScoreCleanupRepository.deleteParticipantScoresForNonLatestLastResultsWhereCourseDateBetween(deleteFrom, deleteTo); + // log.info("Deleted {} participant scores for non-latest rated results between {} and {}", deletedParticipantScoresForNonLatest, deleteFrom, deleteTo); + // + // int deletedParticipantScoresForNonLatestLast = participantScoreCleanupRepository.deleteParticipantScoresForNonLatestLastRatedResultsWhereCourseDateBetween(deleteFrom, + // deleteTo); + // log.info("Deleted {} participant scores for non-latest last rated results between {} and {}", deletedParticipantScoresForNonLatestLast, deleteFrom, deleteTo); + + // int deletedNonLatestRatedResults = resultCleanupRepository.deleteNonLatestRatedResultsWhereCourseDateBetween(deleteFrom, deleteTo); + // log.info("Deleted {} non-latest rated results between {} and {}", deletedNonLatestRatedResults, deleteFrom, deleteTo); + + return CleanupServiceExecutionRecordDTO.of(createCleanupJobExecution(CleanupJobType.RATED_RESULTS, deleteFrom, deleteTo)); + } + + /** + * Retrieves the last execution record for each cleanup job type. + * This method returns the most recent execution of each cleanup job type by querying + * the {@link CleanupJobExecutionRepository} for the latest execution based on the + * deletion timestamp. If no execution is found for a job type, a default + * {@link CleanupServiceExecutionRecordDTO} with a {@code null} execution and the job + * type's label is returned. + * + * @return a list of {@link CleanupServiceExecutionRecordDTO} objects representing + * the last execution record for each cleanup job type + */ + public List getLastExecutions() { + return Arrays.stream(CleanupJobType.values()).map(jobType -> { + CleanupJobExecution lastExecution = cleanupJobExecutionRepository.findTopByCleanupJobTypeOrderByDeletionTimestampDesc(jobType); + return lastExecution != null ? CleanupServiceExecutionRecordDTO.of(lastExecution) : new CleanupServiceExecutionRecordDTO(null, jobType.label()); + }).toList(); + } + + private CleanupJobExecution createCleanupJobExecution(CleanupJobType cleanupJobType, ZonedDateTime deleteFrom, ZonedDateTime deleteTo) { + var entry = new CleanupJobExecution(); + entry.setCleanupJobType(cleanupJobType); + if (deleteFrom != null) { + entry.setDeleteFrom(deleteFrom); + } + if (deleteTo != null) { + entry.setDeleteTo(deleteTo); + } + entry.setDeletionTimestamp(ZonedDateTime.now()); + return cleanupJobExecutionRepository.save(entry); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/export/DataExportExerciseCreationService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/export/DataExportExerciseCreationService.java index 83921300956c..ce1d32cb50c8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/export/DataExportExerciseCreationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/export/DataExportExerciseCreationService.java @@ -155,13 +155,6 @@ public void createProgrammingExerciseExport(ProgrammingExercise programmingExerc } createSubmissionsResultsExport(programmingExercise, exerciseDir, user); RepositoryExportOptionsDTO repositoryExportOptions = new RepositoryExportOptionsDTO(); - repositoryExportOptions.setExportAllParticipants(false); - repositoryExportOptions.setAnonymizeRepository(false); - repositoryExportOptions.setFilterLateSubmissions(false); - repositoryExportOptions.setCombineStudentCommits(false); - repositoryExportOptions.setFilterLateSubmissionsIndividualDueDate(false); - repositoryExportOptions.setExcludePracticeSubmissions(false); - repositoryExportOptions.setNormalizeCodeStyle(false); var listOfProgrammingExerciseParticipations = programmingExercise.getStudentParticipations().stream() .filter(studentParticipation -> studentParticipation instanceof ProgrammingExerciseStudentParticipation) .map(studentParticipation -> (ProgrammingExerciseStudentParticipation) studentParticipation).toList(); 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 c883f774dcad..d06ecfec87af 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 @@ -832,7 +832,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 b1d3aaf5c20e..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 @@ -74,7 +74,17 @@ public enum ColumnMapping { FEEDBACK_ANALYSIS(Map.of( "count", "COUNT(f.id)", "detailText", "f.detailText", - "testCaseName", "f.testCase.testName" + "testCaseName", "f.testCase.testName", + "taskName", """ + COALESCE(( + SELECT MAX(t.taskName) + FROM ProgrammingExerciseTask t + 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 37a9e088f5f6..a33d12d0ea38 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; @@ -61,9 +60,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; @@ -280,7 +276,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 16b7dd554842..ba364d0c4fb5 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; @@ -99,6 +98,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; @@ -117,7 +117,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; @@ -255,16 +254,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); } @@ -367,48 +360,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; } /** @@ -479,17 +438,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; } /** @@ -536,8 +498,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())); @@ -645,11 +608,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); @@ -748,16 +711,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); @@ -790,7 +752,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(); @@ -822,7 +784,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); @@ -1069,20 +1031,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) { @@ -1097,6 +1048,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 5b73b836b9ad..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,11 +416,12 @@ 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); - sanitizeFilenameElseThrow(filename); + log.debug("REST request to get lecture attachment : {}", filename); + String fileNameWithoutSpaces = filename.replaceAll(" ", "_"); + sanitizeFilenameElseThrow(fileNameWithoutSpaces); List lectureAttachments = attachmentRepository.findAllByLectureId(lectureId); - Attachment attachment = lectureAttachments.stream().filter(lectureAttachment -> filename.equals(Path.of(lectureAttachment.getLink()).getFileName().toString())).findAny() + Attachment attachment = lectureAttachments.stream().filter(lectureAttachment -> lectureAttachment.getLink().endsWith(fileNameWithoutSpaces)).findAny() .orElseThrow(() -> new EntityNotFoundException("Attachment", filename)); // get the course for a lecture attachment @@ -430,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())); } /** @@ -487,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 @@ -494,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 @@ -503,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 @@ -518,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(); @@ -527,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 * @@ -564,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); @@ -608,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/AdminCleanupResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java new file mode 100644 index 000000000000..60d865abdb0d --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCleanupResource.java @@ -0,0 +1,112 @@ +package de.tum.cit.aet.artemis.core.web.admin; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.core.dto.CleanupServiceExecutionRecordDTO; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAdmin; +import de.tum.cit.aet.artemis.core.service.cleanup.DataCleanupService; + +/** + * REST controller for managing old data cleanup operations in Artemis. + * Provides endpoints for administrators to clean up old or orphaned data in the database. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/admin/cleanup/") +@EnforceAdmin +public class AdminCleanupResource { + + private static final Logger log = LoggerFactory.getLogger(AdminCleanupResource.class); + + private final DataCleanupService dataCleanupService; + + public AdminCleanupResource(DataCleanupService dataCleanupService) { + this.dataCleanupService = dataCleanupService; + } + + /** + * DELETE admin/cleanup/orphans + * Deletes orphaned data in the Artemis database. + * + * @return a {@link ResponseEntity} containing the result of the cleanup operation + */ + @DeleteMapping("orphans") + public ResponseEntity deleteOrphans() { + log.info("REST request to delete orphaned data in Artemis database"); + CleanupServiceExecutionRecordDTO result = dataCleanupService.deleteOrphans(); + return ResponseEntity.ok().body(result); + } + + /** + * DELETE admin/cleanup/plagiarism-comparisons + * Deletes plagiarism comparisons within the specified date range. + * + * @param deleteFrom the start date of the deletion range + * @param deleteTo the end date of the deletion range + * @return a {@link ResponseEntity} containing the result of the cleanup operation + */ + @DeleteMapping("plagiarism-comparisons") + public ResponseEntity deletePlagiarismComparisons(@RequestParam("deleteFrom") ZonedDateTime deleteFrom, + @RequestParam("deleteTo") ZonedDateTime deleteTo) { + log.info("REST request to delete plagiarism comparisons between {} and {}", deleteFrom, deleteTo); + CleanupServiceExecutionRecordDTO result = dataCleanupService.deletePlagiarismComparisons(deleteFrom, deleteTo); + return ResponseEntity.ok().body(result); + } + + /** + * DELETE admin/cleanup/non-rated-results + * Deletes non-rated results within the specified date range. + * + * @param deleteFrom the start date of the deletion range + * @param deleteTo the end date of the deletion range + * @return a {@link ResponseEntity} containing the result of the cleanup operation + */ + @DeleteMapping("non-rated-results") + public ResponseEntity deleteNonRatedResults(@RequestParam("deleteFrom") ZonedDateTime deleteFrom, + @RequestParam("deleteTo") ZonedDateTime deleteTo) { + log.info("REST request to delete non-rated results between {} and {}", deleteFrom, deleteTo); + CleanupServiceExecutionRecordDTO result = dataCleanupService.deleteNonLatestNonRatedResultsFeedback(deleteFrom, deleteTo); + return ResponseEntity.ok().body(result); + } + + /** + * DELETE admin/cleanup/old-rated-results + * Deletes old rated results within the specified date range. + * + * @param deleteFrom the start date of the deletion range + * @param deleteTo the end date of the deletion range + * @return a {@link ResponseEntity} containing the result of the cleanup operation + */ + @DeleteMapping("old-rated-results") + public ResponseEntity deleteOldRatedResults(@RequestParam("deleteFrom") ZonedDateTime deleteFrom, + @RequestParam("deleteTo") ZonedDateTime deleteTo) { + log.info("REST request to delete old rated results between {} and {}", deleteFrom, deleteTo); + CleanupServiceExecutionRecordDTO result = dataCleanupService.deleteNonLatestRatedResultsFeedback(deleteFrom, deleteTo); + return ResponseEntity.ok().body(result); + } + + /** + * GET admin/cleanup/last-executions + * Retrieves the last execution records of the data cleanup operations. + * + * @return a {@link ResponseEntity} containing a list of execution records + */ + @GetMapping("last-executions") + public ResponseEntity> getLastExecutions() { + List result = dataCleanupService.getLastExecutions(); + return ResponseEntity.ok().body(result); + } +} 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/exam/repository/ExamLiveEventRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamLiveEventRepository.java index 18f4f98a3c0f..ccf79d0bbf0f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamLiveEventRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamLiveEventRepository.java @@ -41,6 +41,6 @@ public interface ExamLiveEventRepository extends ArtemisJpaRepository getAllIndividualExamEndDates(Long examId) { * @param exam the exam * @return a set of all end dates. May return an empty set, if the exam has no start/end date or student exams cannot be found. */ + @Nullable public Set getAllIndividualExamEndDates(Exam exam) { if (exam.getStartDate() == null) { return null; @@ -184,13 +186,24 @@ public Set getAllIndividualExamEndDates(Exam exam) { return workingTimes.stream().map(timeInSeconds -> exam.getStartDate().plusSeconds(timeInSeconds)).collect(Collectors.toSet()); } + /** + * Returns the unlock date for the exam programming exercise. + *

+ * The unlock date is the exam start date minus a certain amount of time to ensure that the exam is unlocked before the start date. + * + * @param exercise the programming exercise + * @return the unlock date or null if the exercise is not an exam exercise + */ + @Nullable public static ZonedDateTime getExamProgrammingExerciseUnlockDate(ProgrammingExercise exercise) { + // TODO: can we guarantee that this is an exam exercise to avoid the null check and return? if (!exercise.isExamExercise()) { return null; } return getExamProgrammingExerciseUnlockDate(exercise.getExerciseGroup().getExam()); } + @NotNull public static ZonedDateTime getExamProgrammingExerciseUnlockDate(Exam exam) { // using start date minus 5 minutes here because unlocking will take some time. return exam.getStartDate().minusMinutes(EXAM_START_WAIT_TIME_MINUTES); diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamLiveEventsService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamLiveEventsService.java index fa48e0c1a09c..31ee3dbf9e80 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamLiveEventsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamLiveEventsService.java @@ -8,7 +8,6 @@ import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.User; -import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exam.domain.StudentExam; import de.tum.cit.aet.artemis.exam.domain.event.ExamAttendanceCheckEvent; @@ -55,14 +54,11 @@ public class ExamLiveEventsService { private final StudentExamRepository studentExamRepository; - private final UserRepository userRepository; - - public ExamLiveEventsService(WebsocketMessagingService websocketMessagingService, ExamLiveEventRepository examLiveEventRepository, StudentExamRepository studentExamRepository, - UserRepository userRepository) { + public ExamLiveEventsService(WebsocketMessagingService websocketMessagingService, ExamLiveEventRepository examLiveEventRepository, + StudentExamRepository studentExamRepository) { this.websocketMessagingService = websocketMessagingService; this.examLiveEventRepository = examLiveEventRepository; this.studentExamRepository = studentExamRepository; - this.userRepository = userRepository; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java index a2898a9df5ff..41757da8eddd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java @@ -283,46 +283,50 @@ private void saveSubmission(User currentUser, List existin submissionFromClient.setParticipation(studentParticipationFromClient); submissionFromClient.submissionDate(ZonedDateTime.now()); submissionFromClient.submitted(true); - if (exercise instanceof QuizExercise) { - // recreate pointers back to submission in each submitted answer - for (SubmittedAnswer submittedAnswer : ((QuizSubmission) submissionFromClient).getSubmittedAnswers()) { - submittedAnswer.setSubmission(((QuizSubmission) submissionFromClient)); - if (submittedAnswer instanceof DragAndDropSubmittedAnswer) { - ((DragAndDropSubmittedAnswer) submittedAnswer).getMappings() - .forEach(dragAndDropMapping -> dragAndDropMapping.setSubmittedAnswer(((DragAndDropSubmittedAnswer) submittedAnswer))); + switch (exercise) { + case QuizExercise ignored -> { + // recreate pointers back to submission in each submitted answer + for (SubmittedAnswer submittedAnswer : ((QuizSubmission) submissionFromClient).getSubmittedAnswers()) { + submittedAnswer.setSubmission(((QuizSubmission) submissionFromClient)); + if (submittedAnswer instanceof DragAndDropSubmittedAnswer) { + ((DragAndDropSubmittedAnswer) submittedAnswer).getMappings() + .forEach(dragAndDropMapping -> dragAndDropMapping.setSubmittedAnswer(((DragAndDropSubmittedAnswer) submittedAnswer))); + } + else if (submittedAnswer instanceof ShortAnswerSubmittedAnswer) { + ((ShortAnswerSubmittedAnswer) submittedAnswer).getSubmittedTexts() + .forEach(submittedText -> submittedText.setSubmittedAnswer(((ShortAnswerSubmittedAnswer) submittedAnswer))); + } } - else if (submittedAnswer instanceof ShortAnswerSubmittedAnswer) { - ((ShortAnswerSubmittedAnswer) submittedAnswer).getSubmittedTexts() - .forEach(submittedText -> submittedText.setSubmittedAnswer(((ShortAnswerSubmittedAnswer) submittedAnswer))); - } - } - // load quiz submissions for existing participation to be able to compare them in saveSubmission - // 5. DB Call: read - submittedAnswerRepository.loadQuizSubmissionsSubmittedAnswers(List.of(existingParticipationInDatabase)); + // load quiz submissions for existing participation to be able to compare them in saveSubmission + // 5. DB Call: read + submittedAnswerRepository.loadQuizSubmissionsSubmittedAnswers(List.of(existingParticipationInDatabase)); - QuizSubmission existingSubmissionInDatabase = (QuizSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); - QuizSubmission quizSubmissionFromClient = (QuizSubmission) submissionFromClient; + QuizSubmission existingSubmissionInDatabase = (QuizSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); + QuizSubmission quizSubmissionFromClient = (QuizSubmission) submissionFromClient; - if (!isContentEqualTo(existingSubmissionInDatabase, quizSubmissionFromClient)) { - quizSubmissionRepository.save(quizSubmissionFromClient); - saveSubmissionVersion(currentUser, submissionFromClient); + if (!isContentEqualTo(existingSubmissionInDatabase, quizSubmissionFromClient)) { + quizSubmissionRepository.save(quizSubmissionFromClient); + saveSubmissionVersion(currentUser, submissionFromClient); + } } - } - else if (exercise instanceof TextExercise) { - TextSubmission existingSubmissionInDatabase = (TextSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); - TextSubmission textSubmissionFromClient = (TextSubmission) submissionFromClient; - if (!isContentEqualTo(existingSubmissionInDatabase, textSubmissionFromClient)) { - textSubmissionRepository.save(textSubmissionFromClient); - saveSubmissionVersion(currentUser, submissionFromClient); + case TextExercise ignored -> { + TextSubmission existingSubmissionInDatabase = (TextSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); + TextSubmission textSubmissionFromClient = (TextSubmission) submissionFromClient; + if (!isContentEqualTo(existingSubmissionInDatabase, textSubmissionFromClient)) { + textSubmissionRepository.save(textSubmissionFromClient); + saveSubmissionVersion(currentUser, submissionFromClient); + } } - } - else if (exercise instanceof ModelingExercise) { - ModelingSubmission existingSubmissionInDatabase = (ModelingSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); - ModelingSubmission modelingSubmissionFromClient = (ModelingSubmission) submissionFromClient; - if (!isContentEqualTo(existingSubmissionInDatabase, modelingSubmissionFromClient)) { - modelingSubmissionRepository.save(modelingSubmissionFromClient); - saveSubmissionVersion(currentUser, submissionFromClient); + case ModelingExercise ignored -> { + ModelingSubmission existingSubmissionInDatabase = (ModelingSubmission) existingParticipationInDatabase.findLatestSubmission().orElse(null); + ModelingSubmission modelingSubmissionFromClient = (ModelingSubmission) submissionFromClient; + if (!isContentEqualTo(existingSubmissionInDatabase, modelingSubmissionFromClient)) { + modelingSubmissionRepository.save(modelingSubmissionFromClient); + saveSubmissionVersion(currentUser, submissionFromClient); + } + } + default -> { } } } @@ -726,24 +730,25 @@ public CompletableFuture startExercises(Long examId) { var lock = new ReentrantLock(); sendAndCacheExercisePreparationStatus(examId, 0, 0, studentExams.size(), 0, startedAt, lock); - var threadPool = Executors.newFixedThreadPool(10); - var futures = studentExams.stream() - .map(studentExam -> CompletableFuture.runAsync(() -> setUpExerciseParticipationsAndSubmissions(studentExam, generatedParticipations), threadPool) - .thenRun(() -> sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.incrementAndGet(), failedExamsCounter.get(), studentExams.size(), - generatedParticipations.size(), startedAt, lock)) - .exceptionally(throwable -> { - log.error("Exception while preparing exercises for student exam {}", studentExam.getId(), throwable); - sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.get(), failedExamsCounter.incrementAndGet(), studentExams.size(), - generatedParticipations.size(), startedAt, lock); - return null; - })) - .toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures).thenApply((emtpy) -> { - threadPool.shutdown(); - sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.get(), failedExamsCounter.get(), studentExams.size(), generatedParticipations.size(), startedAt, - lock); - return generatedParticipations.size(); - }); + try (var threadPool = Executors.newFixedThreadPool(10)) { + var futures = studentExams.stream() + .map(studentExam -> CompletableFuture.runAsync(() -> setUpExerciseParticipationsAndSubmissions(studentExam, generatedParticipations), threadPool) + .thenRun(() -> sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.incrementAndGet(), failedExamsCounter.get(), studentExams.size(), + generatedParticipations.size(), startedAt, lock)) + .exceptionally(throwable -> { + log.error("Exception while preparing exercises for student exam {}", studentExam.getId(), throwable); + sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.get(), failedExamsCounter.incrementAndGet(), studentExams.size(), + generatedParticipations.size(), startedAt, lock); + return null; + })) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures).thenApply((emtpy) -> { + threadPool.shutdown(); + sendAndCacheExercisePreparationStatus(examId, finishedExamsCounter.get(), failedExamsCounter.get(), studentExams.size(), generatedParticipations.size(), startedAt, + lock); + return generatedParticipations.size(); + }); + } } private void sendAndCacheExercisePreparationStatus(Long examId, int finished, int failed, int overall, int participations, ZonedDateTime startTime, ReentrantLock lock) { 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 499818ace8a2..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)); @@ -1199,68 +1200,86 @@ SELECT COALESCE(AVG(p.presentationScore), 0) double getAvgPresentationScoreByCourseId(@Param("courseId") long courseId); /** - * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text, test case name, and task. + * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text, + * test case name, task, and error category. *
* The query calculates: * - The number of occurrences of each feedback detail (COUNT). * - The relative count as a percentage of the total distinct results. * - The corresponding task name for each feedback item by checking if the feedback test case name is associated with a task. + * If a feedback item is not assigned to a task, it is labeled as "Not assigned to task." + * - The error category for each feedback item, classified as one of "Student Error", "Ares Error", or "AST Error". *
* It supports filtering by: * - Search term: Case-insensitive filtering on feedback detail text. - * - Test case names: Filters feedback based on specific test case names. + * - Test case names: Filters feedback based on specific test case names (optional). * - Task names: Filters feedback based on specific task names by mapping them to their associated test cases. - * - Occurrence range: Filters feedback based on the count of occurrences between the specified minimum and maximum values (inclusive). + * If "Not assigned to task" is specified, only feedback items without an associated task will be included. + * - 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 and test case name. 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). - * @param filterTestCases List of test case names to filter the feedback results (optional). - * @param filterTaskNames List of task names to filter feedback results based on the associated test cases (optional). - * @param minOccurrence The minimum number of occurrences to include in the results. - * @param maxOccurrence The maximum number of occurrences to include in the results. - * @param pageable Pagination information to apply. + * @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). + * @param filterTestCases List of active test case names to filter the feedback results (optional). + * @param filterTaskNames List of task names to filter feedback results based on the associated test cases (optional). + * If "Not assigned to task" is specified, only feedback entries without an associated task will be returned. + * @param minOccurrence The minimum number of occurrences to include in the results. + * @param maxOccurrence The maximum number of occurrences to include in the results. + * @param filterErrorCategories List of error categories to filter the feedback results. Supported categories include "Student Error", + * "Ares Error", and "AST Error". + * @param pageable Pagination information to apply. * @return A page of {@link FeedbackDetailDTO} objects representing the aggregated feedback details. */ @Query(""" - SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO( - COUNT(f.id), - 0, - f.detailText, - f.testCase.testName, - COALESCE(( - SELECT t.taskName - FROM ProgrammingExerciseTask t - JOIN t.testCases tct - WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName - ), ''), - '' - ) - FROM StudentParticipation p - 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 - WHERE p.exercise.id = :exerciseId - AND p.testRun = FALSE - AND f.positive = FALSE - AND (:searchTerm = '' OR LOWER(f.detailText) LIKE LOWER(CONCAT('%', REPLACE(REPLACE(:searchTerm, '%', '\\%'), '_', '\\_'), '%')) ESCAPE '\\') - AND (:#{#filterTestCases != NULL && #filterTestCases.size() < 1} = TRUE OR f.testCase.testName IN (:filterTestCases)) - AND (:#{#filterTaskNames != NULL && #filterTaskNames.size() < 1} = TRUE OR f.testCase.testName IN ( - SELECT tct.testName - FROM ProgrammingExerciseTask t - JOIN t.testCases tct - WHERE t.taskName IN (:filterTaskNames) - )) - GROUP BY f.detailText, f.testCase.testName - HAVING COUNT(f.id) BETWEEN :minOccurrence AND :maxOccurrence + 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, + f.testCase.testName, + COALESCE(( + SELECT MAX(t.taskName) + FROM ProgrammingExerciseTask t + LEFT JOIN t.testCases tct + WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName + ), 'Not assigned to task'), + CASE + WHEN f.detailText LIKE 'ARES Security Error%' THEN 'Ares Error' + WHEN f.detailText LIKE 'Unwanted Statement found%' THEN 'AST Error' + ELSE 'Student Error' + END + ) + FROM StudentParticipation p + LEFT JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) + LEFT JOIN r.feedbacks f + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + AND f.positive = FALSE + AND (:searchTerm = '' OR LOWER(f.detailText) LIKE LOWER(CONCAT('%', REPLACE(REPLACE(:searchTerm, '%', '\\%'), '_', '\\_'), '%')) ESCAPE '\\') + AND (:#{#filterTestCases != NULL && #filterTestCases.size() < 1} = TRUE OR f.testCase.testName IN (:filterTestCases)) + AND (:#{#filterTaskNames != NULL && #filterTaskNames.size() < 1} = TRUE OR f.testCase.testName NOT IN ( + SELECT tct.testName + FROM ProgrammingExerciseTask t + LEFT JOIN t.testCases tct + WHERE t.taskName IN (:filterTaskNames) + )) + AND (:#{#filterErrorCategories != NULL && #filterErrorCategories.size() < 1} = TRUE OR CASE + WHEN f.detailText LIKE 'ARES Security Error%' THEN 'Ares Error' + WHEN f.detailText LIKE 'Unwanted Statement found%' THEN 'AST Error' + ELSE 'Student Error' + END IN (:filterErrorCategories)) + GROUP BY f.detailText, f.testCase.testName + HAVING COUNT(f.id) BETWEEN :minOccurrence AND :maxOccurrence """) Page findFilteredFeedbackByExerciseId(@Param("exerciseId") long exerciseId, @Param("searchTerm") String searchTerm, @Param("filterTestCases") List filterTestCases, @Param("filterTaskNames") List filterTaskNames, @Param("minOccurrence") long minOccurrence, - @Param("maxOccurrence") long maxOccurrence, Pageable pageable); + @Param("maxOccurrence") long maxOccurrence, @Param("filterErrorCategories") List filterErrorCategories, Pageable pageable); /** * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. @@ -1273,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 @@ -1294,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 @@ -1312,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/ExerciseDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java index 24da1af374d9..36b7e99fedd0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java @@ -17,7 +17,6 @@ import de.tum.cit.aet.artemis.assessment.repository.TutorParticipationRepository; import de.tum.cit.aet.artemis.assessment.service.ExampleSubmissionService; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; @@ -81,8 +80,6 @@ public class ExerciseDeletionService { private final CompetencyProgressService competencyProgressService; - private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; - private final Optional irisSettingsService; public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUnitRepository exerciseUnitRepository, ParticipationService participationService, @@ -90,7 +87,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn TutorParticipationRepository tutorParticipationRepository, ExampleSubmissionService exampleSubmissionService, StudentExamRepository studentExamRepository, LectureUnitService lectureUnitService, PlagiarismResultRepository plagiarismResultRepository, TextExerciseService textExerciseService, ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService, - CompetencyExerciseLinkRepository competencyExerciseLinkRepository, Optional irisSettingsService) { + Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -106,7 +103,6 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.channelRepository = channelRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; - this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; this.irisSettingsService = irisSettingsService; } @@ -125,21 +121,22 @@ public void cleanup(Long exerciseId, boolean deleteRepositories) { } // Cleanup in parallel to speedup the process - var threadPool = Executors.newFixedThreadPool(10); - var futures = exercise.getStudentParticipations().stream().map(participation -> CompletableFuture.runAsync(() -> { - try { - participationService.cleanupBuildPlan((ProgrammingExerciseStudentParticipation) participation); - if (!deleteRepositories) { - return; // in this case, we are done with the participation + try (var threadPool = Executors.newFixedThreadPool(10)) { + var futures = exercise.getStudentParticipations().stream().map(participation -> CompletableFuture.runAsync(() -> { + try { + participationService.cleanupBuildPlan((ProgrammingExerciseStudentParticipation) participation); + if (!deleteRepositories) { + return; // in this case, we are done with the participation + } + participationService.cleanupRepository((ProgrammingExerciseStudentParticipation) participation); } - participationService.cleanupRepository((ProgrammingExerciseStudentParticipation) participation); - } - catch (Exception exception) { - log.error("Failed to clean the student participation {} for programming exercise {}", participation.getId(), exerciseId); - } - }, threadPool).toCompletableFuture()).toArray(CompletableFuture[]::new); - // wait until all operations finish before returning - CompletableFuture.allOf(futures).thenRun(threadPool::shutdown).join(); + catch (Exception exception) { + log.error("Failed to clean the student participation {} for programming exercise {}", participation.getId(), exerciseId); + } + }, threadPool).toCompletableFuture()).toArray(CompletableFuture[]::new); + // wait until all operations finish before returning + CompletableFuture.allOf(futures).thenRun(threadPool::shutdown).join(); + } } /** 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/IrisExerciseChatSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java index 9e736ce8c358..3f2a0bdb6ab7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java @@ -101,7 +101,7 @@ public ResponseEntity getCurrentSessionOrCreateIfNotExi @EnforceAtLeastStudentInExercise public ResponseEntity> getAllSessions(@PathVariable Long exerciseId) { var exercise = exerciseRepository.findByIdElseThrow(exerciseId); - ProgrammingExercise programmingExercise = validateExercise(exercise); + validateExercise(exercise); irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.CHAT, exercise); var user = userRepository.getUserWithGroupsAndAuthorities(); 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/AttachmentResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java index 3b5e44b6ac2f..6c280a0ce0be 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/AttachmentResource.java @@ -92,7 +92,7 @@ public ResponseEntity createAttachment(@RequestPart Attachment attac attachment.setId(null); Path basePath = FilePathService.getLectureAttachmentFilePath().resolve(attachment.getLecture().getId().toString()); - Path savePath = fileService.saveFile(file, basePath, false); + Path savePath = fileService.saveFile(file, basePath, true); attachment.setLink(FilePathService.publicPathForActualPath(savePath, attachment.getLecture().getId()).toString()); Attachment result = attachmentRepository.save(attachment); @@ -123,7 +123,7 @@ public ResponseEntity updateAttachment(@PathVariable Long attachment if (file != null) { Path basePath = FilePathService.getLectureAttachmentFilePath().resolve(originalAttachment.getLecture().getId().toString()); - Path savePath = fileService.saveFile(file, basePath, false); + Path savePath = fileService.saveFile(file, basePath, true); attachment.setLink(FilePathService.publicPathForActualPath(savePath, originalAttachment.getLecture().getId()).toString()); // Delete the old file URI oldPath = URI.create(originalAttachment.getLink()); 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/plagiarism/repository/PlagiarismComparisonRepository.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/repository/PlagiarismComparisonRepository.java index 8ad0058fa678..079b71276e72 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/repository/PlagiarismComparisonRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/repository/PlagiarismComparisonRepository.java @@ -74,7 +74,7 @@ default PlagiarismComparison findByIdWithSubmissionsStudentsAndElementsBElseT Optional>> findBySubmissionA_SubmissionIdOrSubmissionB_SubmissionId(long submissionA_submissionId, long submissionB_submissionId); @Modifying - @Transactional // ok because of modifying query + @Transactional // ok because of delete void deletePlagiarismComparisonsByPlagiarismResultIdAndStatus(Long plagiarismResultId, PlagiarismStatus plagiarismStatus); // we can't simply call save() on plagiarismComparisons because the plagiarismComparisonMatches have no id @@ -90,4 +90,5 @@ default PlagiarismComparison findByIdWithSubmissionsStudentsAndElementsBElseT void updatePlagiarismComparisonStatus(@Param("plagiarismComparisonId") Long plagiarismComparisonId, @Param("status") PlagiarismStatus status); Set> findAllByPlagiarismResultExerciseId(long exerciseId); + } 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/dto/ProgrammingExerciseGradingStatisticsDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseGradingStatisticsDTO.java index b08a53caf6ba..36da5c64d2ee 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseGradingStatisticsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseGradingStatisticsDTO.java @@ -2,101 +2,29 @@ import java.util.HashMap; import java.util.Map; -import java.util.Objects; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.assessment.domain.Feedback; - /** * This is a dto for providing statistics for the programming exercise test cases & sca categories. + * + * @param numParticipations number of the participations with a result + * @param testCaseStatsMap statistics for each test case + * @param categoryIssuesMap statistics for each category */ -// TODO: convert to Record @JsonInclude(JsonInclude.Include.NON_EMPTY) -public class ProgrammingExerciseGradingStatisticsDTO { - - // number of the participations with a result - private Integer numParticipations; - - // statistics for each test case - private Map testCaseStatsMap = new HashMap<>(); - - // statistics for each category - private Map> categoryIssuesMap = new HashMap<>(); - - public void setNumParticipations(Integer numParticipations) { - this.numParticipations = numParticipations; - } - - public Integer getNumParticipations() { - return numParticipations; - } - - public void setTestCaseStatsMap(Map testCaseStatsMap) { - this.testCaseStatsMap = testCaseStatsMap; - } - - public Map getTestCaseStatsMap() { - return testCaseStatsMap; - } - - public void setCategoryIssuesMap(Map> categoryIssuesMap) { - this.categoryIssuesMap = categoryIssuesMap; - } - - public Map> getCategoryIssuesMap() { - return categoryIssuesMap; - } - - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public static class TestCaseStats { - - private Integer numPassed; +public record ProgrammingExerciseGradingStatisticsDTO(int numParticipations, Map testCaseStatsMap, Map> categoryIssuesMap) { - private Integer numFailed; - - public TestCaseStats(Integer passed, Integer failed) { - this.numPassed = passed; - this.numFailed = failed; - } - - public Integer getNumPassed() { - return numPassed; + public ProgrammingExerciseGradingStatisticsDTO { + if (testCaseStatsMap == null) { + testCaseStatsMap = new HashMap<>(); } - - public Integer getNumFailed() { - return numFailed; - } - - /** - * Updates the statistics accordingly for a positive or negative feedback. - * - * @param feedback that should be considered in the statistics. - */ - public void updateWithFeedback(final Feedback feedback) { - if (Boolean.TRUE.equals(feedback.isPositive())) { - numPassed++; - } - else { - numFailed++; - } - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - TestCaseStats that = (TestCaseStats) obj; - return Objects.equals(numPassed, that.numPassed) && Objects.equals(numFailed, that.numFailed); + if (categoryIssuesMap == null) { + categoryIssuesMap = new HashMap<>(); } + } - @Override - public int hashCode() { - return Objects.hash(numPassed, numFailed); - } + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record TestCaseStats(int numPassed, int numFailed) { } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseTestCaseDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseTestCaseDTO.java index 95e6bae99dd0..c5b963061986 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseTestCaseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ProgrammingExerciseTestCaseDTO.java @@ -5,61 +5,10 @@ import de.tum.cit.aet.artemis.assessment.domain.Visibility; /** - * This is a dto for updating a programming exercise test case. + * This is a DTO for updating a programming exercise test case. * It is only allowed to alter the weight, bonus multiplier, bonus points and visibility flag of a test case from an * endpoint, the other attributes are generated automatically. */ -// TODO: convert to Record @JsonInclude(JsonInclude.Include.NON_EMPTY) -public class ProgrammingExerciseTestCaseDTO { - - private Long id; - - private Double weight; - - private Double bonusMultiplier; - - private Double bonusPoints; - - private Visibility visibility; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Double getWeight() { - return weight; - } - - public void setWeight(Double weight) { - this.weight = weight; - } - - public Visibility getVisibility() { - return visibility; - } - - public void setVisibility(Visibility visibility) { - this.visibility = visibility; - } - - public Double getBonusMultiplier() { - return bonusMultiplier; - } - - public void setBonusMultiplier(Double bonusMultiplier) { - this.bonusMultiplier = bonusMultiplier; - } - - public Double getBonusPoints() { - return bonusPoints; - } - - public void setBonusPoints(Double bonusPoints) { - this.bonusPoints = bonusPoints; - } +public record ProgrammingExerciseTestCaseDTO(Long id, Double weight, Double bonusMultiplier, Double bonusPoints, Visibility visibility) { } 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/BuildPlanRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java index 9e9c996a8e37..949ad5435b80 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildPlanRepository.java @@ -41,7 +41,7 @@ public interface BuildPlanRepository extends ArtemisJpaRepository findByProgrammingExercises_Id(@Param("exerciseId") long exerciseId); default BuildPlan findByProgrammingExercises_IdWithProgrammingExercisesElseThrow(final long exerciseId) { 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..bbcdc1d5624b 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,7 +8,10 @@ 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.Team; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; +import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; import de.tum.cit.aet.artemis.programming.domain.ParticipationVCSAccessToken; import de.tum.cit.aet.artemis.programming.repository.ParticipationVCSAccessTokenRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; @@ -24,10 +27,13 @@ public class ParticipationVcsAccessTokenService { private final ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository; + private final TeamRepository teamRepository; + public ParticipationVcsAccessTokenService(ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository, - ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository) { + ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, TeamRepository teamRepository) { this.participationVcsAccessTokenRepository = participationVCSAccessTokenRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; + this.teamRepository = teamRepository; } /** @@ -46,18 +52,25 @@ 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); + loadTeamStudentsForTeamExercise(participation); + 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 +79,26 @@ 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); + loadTeamStudentsForTeamExercise(participation); + if (participation.isOwnedBy(user)) { + return createParticipationVCSAccessToken(user, participation); + } + else { + throw new AccessForbiddenException("Participation not owned by user"); + } + } + + /** + * Loads the team students of a participation's team, if it has a team + * + * @param participation the participation which team's students are not loaded yet + */ + private void loadTeamStudentsForTeamExercise(StudentParticipation participation) { + if (participation.getTeam().isPresent()) { + Team team = participation.getTeam().get(); + Team teamWithStudents = teamRepository.findWithStudentsByIdElseThrow(team.getId()); + participation.getTeam().get().setStudents(teamWithStudents.getStudents()); + } } /** 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 c8a4ae521e55..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); } @@ -261,9 +262,7 @@ public List exportProgrammingExerciseRepositories(ProgrammingExercise exer // Lazy load student participation, sort by id, and set the export options var studentParticipations = studentParticipationRepository.findByExerciseId(exercise.getId()).stream() .map(studentParticipation -> (ProgrammingExerciseStudentParticipation) studentParticipation).sorted(Comparator.comparing(DomainObject::getId)).toList(); - var exportOptions = new RepositoryExportOptionsDTO(); - exportOptions.setAnonymizeRepository(false); - exportOptions.setExportAllParticipants(true); + var exportOptions = new RepositoryExportOptionsDTO(true, false, false, null, false, false, false, false, false); // Export student repositories and add them to list var exportedStudentRepositoryFiles = exportStudentRepositories(exercise, studentParticipations, exportOptions, workingDir, outputDir, exportErrors).stream() @@ -603,7 +602,7 @@ public File exportStudentRepositoriesToZipFile(long programmingExerciseId, @NotN public List exportStudentRepositories(ProgrammingExercise programmingExercise, @NotNull List participations, RepositoryExportOptionsDTO repositoryExportOptions, Path workingDir, Path outputDir, List exportErrors) { var programmingExerciseId = programmingExercise.getId(); - if (repositoryExportOptions.isExportAllParticipants()) { + if (repositoryExportOptions.exportAllParticipants()) { log.info("Request to export all {} student or team repositories of programming exercise {} with title '{}'", participations.size(), programmingExerciseId, programmingExercise.getTitle()); } @@ -617,23 +616,24 @@ public List exportStudentRepositories(ProgrammingExercise programmingExerc List exportedStudentRepositories = Collections.synchronizedList(new ArrayList<>()); log.info("export student repositories for programming exercise {} in parallel", programmingExercise.getId()); - var threadPool = Executors.newFixedThreadPool(10); - var futures = participations.stream().map(participation -> CompletableFuture.runAsync(() -> { - try { - log.debug("invoke createZipForRepositoryWithParticipation for participation {}", participation.getId()); - Path dir = getRepositoryWithParticipation(programmingExercise, participation, repositoryExportOptions, workingDir, outputDir, false); - if (dir != null) { - exportedStudentRepositories.add(dir); + try (var threadPool = Executors.newFixedThreadPool(10)) { + var futures = participations.stream().map(participation -> CompletableFuture.runAsync(() -> { + try { + log.debug("invoke createZipForRepositoryWithParticipation for participation {}", participation.getId()); + Path dir = getRepositoryWithParticipation(programmingExercise, participation, repositoryExportOptions, workingDir, outputDir, false); + if (dir != null) { + exportedStudentRepositories.add(dir); + } } - } - catch (Exception exception) { - var error = "Failed to export the student repository with participation: " + participation.getId() + " for programming exercise '" + programmingExercise.getTitle() - + "' (id: " + programmingExercise.getId() + ") because the repository couldn't be downloaded. "; - exportErrors.add(error); - } - }, threadPool).toCompletableFuture()).toArray(CompletableFuture[]::new); - // wait until all operations finish - CompletableFuture.allOf(futures).thenRun(threadPool::shutdown).join(); + catch (Exception exception) { + var error = "Failed to export the student repository with participation: " + participation.getId() + " for programming exercise '" + + programmingExercise.getTitle() + "' (id: " + programmingExercise.getId() + ") because the repository couldn't be downloaded. "; + exportErrors.add(error); + } + }, threadPool).toCompletableFuture()).toArray(CompletableFuture[]::new); + // wait until all operations finish + CompletableFuture.allOf(futures).thenRun(threadPool::shutdown).join(); + } return exportedStudentRepositories; } @@ -705,7 +705,7 @@ public Path getRepositoryWithParticipation(final ProgrammingExercise programming return null; } - if (repositoryExportOptions.isExcludePracticeSubmissions() && participation.isPracticeMode()) { + if (repositoryExportOptions.excludePracticeSubmissions() && participation.isPracticeMode()) { log.debug("Ignoring practice participation {}", participation); return null; } @@ -722,21 +722,21 @@ public Path getRepositoryWithParticipation(final ProgrammingExercise programming // TODO: this operation is only necessary if the repo was not newly cloned gitService.resetToOriginHead(repository); - if (repositoryExportOptions.isFilterLateSubmissions()) { + if (repositoryExportOptions.filterLateSubmissions()) { filterLateSubmissions(repositoryExportOptions, participation, repository); } - if (repositoryExportOptions.isAddParticipantName()) { + if (repositoryExportOptions.addParticipantName()) { log.debug("Adding student or team name to participation {}", participation); addParticipantIdentifierToProjectName(repository, programmingExercise, participation); } - if (repositoryExportOptions.isCombineStudentCommits()) { + if (repositoryExportOptions.combineStudentCommits()) { log.debug("Combining commits for participation {}", participation); - gitService.combineAllStudentCommits(repository, programmingExercise, repositoryExportOptions.isAnonymizeRepository()); + gitService.combineAllStudentCommits(repository, programmingExercise, repositoryExportOptions.anonymizeRepository()); } - if (repositoryExportOptions.isAnonymizeRepository()) { + if (repositoryExportOptions.anonymizeRepository()) { log.debug("Anonymizing commits for participation {}", participation); gitService.anonymizeStudentCommits(repository, programmingExercise); } @@ -744,7 +744,7 @@ public Path getRepositoryWithParticipation(final ProgrammingExercise programming gitService.removeRemotesFromRepository(repository); } - if (repositoryExportOptions.isNormalizeCodeStyle()) { + if (repositoryExportOptions.normalizeCodeStyle()) { try { log.debug("Normalizing code style for participation {}", participation); fileService.normalizeLineEndingsDirectory(repository.getLocalPath()); @@ -756,7 +756,7 @@ public Path getRepositoryWithParticipation(final ProgrammingExercise programming } log.debug("Create temporary directory for repository {}", repository.getLocalPath().toString()); - return gitService.getRepositoryWithParticipation(repository, outputDir.toString(), repositoryExportOptions.isAnonymizeRepository(), zipOutput); + return gitService.getRepositoryWithParticipation(repository, outputDir.toString(), repositoryExportOptions.anonymizeRepository(), zipOutput); } catch (GitAPIException | GitException ex) { log.error("Failed to create zip for participation id {} with exercise id {} because of the following exception ", participation.getId(), @@ -793,11 +793,11 @@ public void deleteReposDownloadProjectRootDirectory(ProgrammingExercise programm private void filterLateSubmissions(RepositoryExportOptionsDTO repositoryExportOptions, ProgrammingExerciseStudentParticipation participation, Repository repo) { log.debug("Filter late submissions for participation {}", participation.toString()); final Optional latestAllowedDate; - if (repositoryExportOptions.isFilterLateSubmissionsIndividualDueDate()) { + if (repositoryExportOptions.filterLateSubmissionsIndividualDueDate()) { latestAllowedDate = ExerciseDateService.getDueDate(participation); } else { - latestAllowedDate = Optional.of(repositoryExportOptions.getFilterLateSubmissionsDate()); + latestAllowedDate = Optional.of(repositoryExportOptions.filterLateSubmissionsDate()); } if (latestAllowedDate.isPresent()) { 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 3ac67af4b28f..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 @@ -986,20 +986,35 @@ private void removeAllTestCaseFeedbackAndSetScoreToZero(Result result, List + * This method compiles various statistics for a programming exercise identified by the provided exercise ID. + * It gathers data on the number of passed and failed tests per test case, as well as the number of issues + * detected in static code analysis per category. The results are encapsulated in a + * {@link ProgrammingExerciseGradingStatisticsDTO} object which includes: + * - The number of results processed + * - A map of test case names to their respective pass/fail statistics + * - A map of static code analysis category names to the number of students per issue count + *

+ * The method performs the following steps: + * 1. Initializes statistics for the number of passed and failed tests per test case. + * 2. Initializes statistics for the number of students per amount of detected issues per category. + * 3. Fetches the latest automatic results for the exercise along with their feedback. + * 4. Processes each result to update test case statistics and detect issues per category. + * 5. Merges individual result statistics into overall statistics. * - * @param exerciseId The current exercise - * @return The statistics object + * @param exerciseId the ID of the exercise + * @return a {@link ProgrammingExerciseGradingStatisticsDTO} object containing the compiled grading statistics */ public ProgrammingExerciseGradingStatisticsDTO generateGradingStatistics(Long exerciseId) { - // number of passed and failed tests per test case + // Initialize statistics for the number of passed and failed tests per test case final var testCases = testCaseRepository.findByExerciseId(exerciseId); final var testCaseStatsMap = new HashMap(); for (ProgrammingExerciseTestCase testCase : testCases) { testCaseStatsMap.put(testCase.getTestName(), new ProgrammingExerciseGradingStatisticsDTO.TestCaseStats(0, 0)); } - // number of students per amount of detected issues per category + // Initialize statistics for the number of students per amount of detected issues per category final Set categories = staticCodeAnalysisCategoryRepository.findByExerciseId(exerciseId); final var categoryIssuesStudentsMap = new HashMap>(); for (StaticCodeAnalysisCategory category : categories) { @@ -1008,63 +1023,123 @@ public ProgrammingExerciseGradingStatisticsDTO generateGradingStatistics(Long ex final var results = resultRepository.findLatestAutomaticResultsWithEagerFeedbacksTestCasesForExercise(exerciseId); for (Result result : results) { - // number of detected issues per category for this result - final var categoryIssuesMap = new HashMap(); - for (var feedback : result.getFeedbacks()) { - addFeedbackToStatistics(categoryIssuesMap, testCaseStatsMap, feedback); - } + // Count the number of detected issues per category for the current result + final var categoryIssuesMap = categorizeStaticCodeAnalysisIssues(result); + // Update the statistics for each test case based on the feedback + updateTestCaseMapBasedOnResultFeedback(result, testCaseStatsMap); + + // Merge the current result's category issues map into the overall map mergeCategoryIssuesMap(categoryIssuesStudentsMap, categoryIssuesMap); } - final var statistics = new ProgrammingExerciseGradingStatisticsDTO(); - statistics.setNumParticipations(results.size()); - statistics.setTestCaseStatsMap(testCaseStatsMap); - statistics.setCategoryIssuesMap(categoryIssuesStudentsMap); - - return statistics; + return new ProgrammingExerciseGradingStatisticsDTO(results.size(), testCaseStatsMap, categoryIssuesStudentsMap); } /** - * Merges the result map of a single student with the overall issues map + * Categorizes static code analysis issues from a given result. + *

+ * This method processes the feedbacks associated with the provided result to identify + * and count the occurrences of static code analysis issues for each category. The result + * is a map where the key is the category name and the value is the count of issues detected + * in that category. * - * @param issuesAllStudents The overall issues map for all students - * @param issuesSingleStudent The issues map for one student + * @param result The {@link Result} object containing feedbacks to be analyzed + * @return A map where the key is the static code analysis category name and the value is the count of occurrences of issues in that category */ - private void mergeCategoryIssuesMap(final Map> issuesAllStudents, final Map issuesSingleStudent) { - for (var entry : issuesSingleStudent.entrySet()) { - final String category = entry.getKey(); - final Integer issueCount = entry.getValue(); - - issuesAllStudents.putIfAbsent(category, new HashMap<>()); + private static Map categorizeStaticCodeAnalysisIssues(Result result) { + return result.getFeedbacks().stream() + // Filter the feedbacks to include only those that are related to static code analysis + .filter(Feedback::isStaticCodeAnalysisFeedback) + // Map each filtered feedback to its static code analysis category name + .map(Feedback::getStaticCodeAnalysisCategory) + // Filter out any empty category names to avoid counting them + .filter(categoryName -> !categoryName.isEmpty()) + // Collect the results into a map where the key is the category name and the value is the count of occurrences + .collect(Collectors.toMap( + // The key in the resulting map is the category name + categoryName -> categoryName, + // The initial value for each key is 1, representing the first occurrence + categoryName -> 1, + // If the key already exists, sum the existing value with the new value (i.e., increment the count) + Integer::sum)); + } - var issuesStudentsMap = issuesAllStudents.get(category); - issuesStudentsMap.putIfAbsent(issueCount, 0); - // add 1 to the number of students for the category & issues - issuesStudentsMap.merge(issueCount, 1, Integer::sum); - } + /** + * Updates the test case statistics map based on the feedback from a given result. + *

+ * This method processes the feedbacks associated with the provided result to update + * the test case statistics map. It counts the number of positive and non-positive feedbacks + * for each test case and updates the corresponding entries in the provided test case statistics map. + * + * @param result The {@link Result} object containing feedbacks to be analyzed + * @param testCaseStatsMap The map of test case names to their respective statistics (passed and failed counts) + */ + 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 -> { + 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 + feedback -> feedback.getTestCase().getTestName(), + // Partition each group into positive and non-positive feedbacks, and count the occurrences + Collectors.partitioningBy(Feedback::isPositive, Collectors.counting()) + // Process each entry in the resulting map + )).forEach((testName, partitionedFeedbacks) -> { + // Get the count of positive feedbacks for the test case, defaulting to 0 if none exist + long numPassed = partitionedFeedbacks.getOrDefault(true, 0L); + // Get the count of non-positive feedbacks for the test case, defaulting to 0 if none exist + long numFailed = partitionedFeedbacks.getOrDefault(false, 0L); + // Ensure there is an entry for the test case in the testCaseStatsMap, initializing with zero passed and failed if absent + testCaseStatsMap.putIfAbsent(testName, new ProgrammingExerciseGradingStatisticsDTO.TestCaseStats(0, 0)); + // Update the entry for the test case in the testCaseStatsMap, incrementing the passed and failed counts + testCaseStatsMap.computeIfPresent(testName, + // Create a new TestCaseStats object with the updated counts and replace the existing entry + (key, stats) -> new ProgrammingExerciseGradingStatisticsDTO.TestCaseStats(stats.numPassed() + (int) numPassed, stats.numFailed() + (int) numFailed)); + }); } /** - * Analyses the feedback and updates the statistics maps + * Merges the result map of a single student with the overall issues map. + *

+ * This method updates the overall issues map for all students by incorporating the issues + * detected for a single student. Each category of issues is updated with the count of issues + * detected for that category and the number of students who had that count of issues. * - * @param categoryIssuesMap The issues map for sca statistics - * @param testCaseStatsMap The map for test case statistics - * @param feedback The given feedback object + * @param issuesAllStudents The overall issues map for all students. The key is the category name, + * and the value is a map where the key is the issue count and the value + * is the number of students with that issue count. + * @param issuesSingleStudent The issues map for one student. The key is the category name, and the value + * is the number of issues detected in that category for this student. */ - private void addFeedbackToStatistics(final Map categoryIssuesMap, final Map testCaseStatsMap, - final Feedback feedback) { - if (feedback.isStaticCodeAnalysisFeedback()) { - String categoryName = feedback.getStaticCodeAnalysisCategory(); - if (categoryName.isEmpty()) { - return; - } - categoryIssuesMap.compute(categoryName, (category, count) -> count == null ? 1 : count + 1); - } - else if (FeedbackType.AUTOMATIC.equals(feedback.getType()) && feedback.getTestCase() != null) { - String testName = feedback.getTestCase().getTestName(); - testCaseStatsMap.putIfAbsent(testName, new ProgrammingExerciseGradingStatisticsDTO.TestCaseStats(0, 0)); - testCaseStatsMap.get(testName).updateWithFeedback(feedback); - } + private static void mergeCategoryIssuesMap(final Map> issuesAllStudents, final Map issuesSingleStudent) { + // Iterate over each entry in the issues map for a single student + issuesSingleStudent.forEach((category, issueCount) -> { + // Ensure the overall issues map has an entry for the current category + issuesAllStudents.putIfAbsent(category, new HashMap<>()); + // Ensure the category map has an entry for the current issue count with an initial value of 0 + issuesAllStudents.get(category).putIfAbsent(issueCount, 0); + // Increment the number of students who had the current issue count for the current category by 1 + issuesAllStudents.get(category).merge(issueCount, 1, Integer::sum); + }); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportBasicService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportBasicService.java index f0a2191d0e44..8f5c1b427390 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportBasicService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportBasicService.java @@ -125,7 +125,7 @@ public ProgrammingExerciseImportBasicService(ExerciseHintService exerciseHintSer * @param newProgrammingExercise The new exercise already containing values which should not get copied, i.e. overwritten * @return The newly created exercise */ - @Transactional // TODO: apply the transaction on a smaller scope + @Transactional // TODO: NOT OK --> apply the transaction on a smaller scope // IMPORTANT: the transactional context only works if you invoke this method from another class public ProgrammingExercise importProgrammingExerciseBasis(final ProgrammingExercise originalProgrammingExercise, final ProgrammingExercise newProgrammingExercise) { prepareBasicExerciseInformation(originalProgrammingExercise, newProgrammingExercise); 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/ProgrammingExerciseScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseScheduleService.java index 7fb35b6b1d66..16eebcaca693 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseScheduleService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseScheduleService.java @@ -961,45 +961,46 @@ private CompletableFuture> invokeO // TODO: we should think about executing those operations again in batches to avoid issues on the vcs server // Create a threadpool to execute the operation with a fixed amount of threads - ExecutorService threadPool = Executors.newFixedThreadPool(10); - var participations = programmingExercise.getStudentParticipations(); - List> futures = new ArrayList<>(); - for (StudentParticipation studentParticipation : participations) { - Supplier action = () -> { - // We need to set the authorization object for every thread - SecurityUtils.setAuthorizationObject(); - var programmingExerciseStudentParticipation = (ProgrammingExerciseStudentParticipation) studentParticipation; - - if (condition.test(programmingExerciseStudentParticipation)) { - operation.accept(programmingExercise, programmingExerciseStudentParticipation); - } + try (ExecutorService threadPool = Executors.newFixedThreadPool(10)) { + var participations = programmingExercise.getStudentParticipations(); + List> futures = new ArrayList<>(); + for (StudentParticipation studentParticipation : participations) { + Supplier action = () -> { + // We need to set the authorization object for every thread + SecurityUtils.setAuthorizationObject(); + var programmingExerciseStudentParticipation = (ProgrammingExerciseStudentParticipation) studentParticipation; + + if (condition.test(programmingExerciseStudentParticipation)) { + operation.accept(programmingExercise, programmingExerciseStudentParticipation); + } + + return programmingExerciseStudentParticipation; + }; - return programmingExerciseStudentParticipation; - }; + CompletableFuture future = CompletableFuture.supplyAsync(action, threadPool); + futures.add(future); + } - CompletableFuture future = CompletableFuture.supplyAsync(action, threadPool); - futures.add(future); - } + for (var future : futures) { + future.whenComplete((participation, exception) -> { + if (exception != null) { + log.error(String.format("'%s' failed for programming exercise with id %d for student repository with participation id %d", operationName, + programmingExercise.getId(), participation.getId()), exception); + failedOperations.add(participation); + } + }); + } - for (var future : futures) { - future.whenComplete((participation, exception) -> { - if (exception != null) { - log.error(String.format("'%s' failed for programming exercise with id %d for student repository with participation id %d", operationName, - programmingExercise.getId(), participation.getId()), exception); - failedOperations.add(participation); + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).thenApply(ignore -> { + threadPool.shutdown(); + log.info("Finished executing (scheduled) task '{}' for programming exercise with id {}.", operationName, programmingExercise.getId()); + if (!failedOperations.isEmpty()) { + var failedIds = failedOperations.stream().map(participation -> participation.getId().toString()).collect(Collectors.joining(",")); + log.warn("The (scheduled) task '{}' for programming exercise {} failed for these {} participations: {}", operation, programmingExercise.getId(), + failedOperations.size(), failedIds); } + return failedOperations; }); } - - return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).thenApply(ignore -> { - threadPool.shutdown(); - log.info("Finished executing (scheduled) task '{}' for programming exercise with id {}.", operationName, programmingExercise.getId()); - if (!failedOperations.isEmpty()) { - var failedIds = failedOperations.stream().map(participation -> participation.getId().toString()).collect(Collectors.joining(",")); - log.warn("The (scheduled) task '{}' for programming exercise {} failed for these {} participations: {}", operation, programmingExercise.getId(), - failedOperations.size(), failedIds); - } - return failedOperations; - }); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseTestCaseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseTestCaseService.java index f0d9f6f426e5..f3770b417e5b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseTestCaseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseTestCaseService.java @@ -69,17 +69,17 @@ public Set update(Long exerciseId, Set existingTestCases = programmingExercise.getTestCases(); Set updatedTests = new HashSet<>(); for (ProgrammingExerciseTestCaseDTO programmingExerciseTestCaseDTO : testCaseProgrammingExerciseTestCaseDTOS) { - Optional matchingTestCaseOpt = existingTestCases.stream() - .filter(testCase -> testCase.getId().equals(programmingExerciseTestCaseDTO.getId())).findFirst(); + Optional matchingTestCaseOpt = existingTestCases.stream().filter(testCase -> testCase.getId().equals(programmingExerciseTestCaseDTO.id())) + .findFirst(); if (matchingTestCaseOpt.isEmpty()) { continue; } ProgrammingExerciseTestCase matchingTestCase = matchingTestCaseOpt.get(); - matchingTestCase.setWeight(programmingExerciseTestCaseDTO.getWeight()); - matchingTestCase.setVisibility(programmingExerciseTestCaseDTO.getVisibility()); - matchingTestCase.setBonusMultiplier(programmingExerciseTestCaseDTO.getBonusMultiplier()); - matchingTestCase.setBonusPoints(programmingExerciseTestCaseDTO.getBonusPoints()); + matchingTestCase.setWeight(programmingExerciseTestCaseDTO.weight()); + matchingTestCase.setVisibility(programmingExerciseTestCaseDTO.visibility()); + matchingTestCase.setBonusMultiplier(programmingExerciseTestCaseDTO.bonusMultiplier()); + matchingTestCase.setBonusPoints(programmingExerciseTestCaseDTO.bonusPoints()); validateTestCase(matchingTestCase); updatedTests.add(matchingTestCase); 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/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index a55c44871619..1659956ac1f8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -424,32 +424,37 @@ public ResponseEntity exportSubmissionsByStudentLogins(@PathVariable l var programmingExercise = programmingExerciseRepository.findByIdWithStudentParticipationsAndLegalSubmissionsElseThrow(exerciseId); User user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, programmingExercise, user); - if (repositoryExportOptions.isExportAllParticipants()) { + if (repositoryExportOptions.exportAllParticipants()) { // only instructors are allowed to download all repos authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, programmingExercise, user); } - if (repositoryExportOptions.getFilterLateSubmissionsDate() == null) { - repositoryExportOptions.setFilterLateSubmissionsIndividualDueDate(true); - repositoryExportOptions.setFilterLateSubmissionsDate(programmingExercise.getDueDate()); + if (repositoryExportOptions.filterLateSubmissionsDate() == null) { + repositoryExportOptions = repositoryExportOptions.copyWith(true, programmingExercise.getDueDate()); } Set participantIdentifierList = new HashSet<>(); - if (!repositoryExportOptions.isExportAllParticipants()) { + if (!repositoryExportOptions.exportAllParticipants()) { participantIdentifiers = participantIdentifiers.replaceAll("\\s+", ""); participantIdentifierList.addAll(List.of(participantIdentifiers.split(","))); } // Select the participations that should be exported + final var exportedStudentParticipations = getExportedStudentParticipations(repositoryExportOptions, programmingExercise, participantIdentifierList); + return provideZipForParticipations(exportedStudentParticipations, programmingExercise, repositoryExportOptions); + } + + private static List getExportedStudentParticipations(RepositoryExportOptionsDTO repositoryExportOptions, + ProgrammingExercise programmingExercise, Set participantIdentifierList) { List exportedStudentParticipations = new ArrayList<>(); for (StudentParticipation studentParticipation : programmingExercise.getStudentParticipations()) { ProgrammingExerciseStudentParticipation programmingStudentParticipation = (ProgrammingExerciseStudentParticipation) studentParticipation; - if (repositoryExportOptions.isExportAllParticipants() || (programmingStudentParticipation.getRepositoryUri() != null && studentParticipation.getParticipant() != null + if (repositoryExportOptions.exportAllParticipants() || (programmingStudentParticipation.getRepositoryUri() != null && studentParticipation.getParticipant() != null && participantIdentifierList.contains(studentParticipation.getParticipantIdentifier()))) { exportedStudentParticipations.add(programmingStudentParticipation); } } - return provideZipForParticipations(exportedStudentParticipations, programmingExercise, repositoryExportOptions); + return exportedStudentParticipations; } /** @@ -471,12 +476,11 @@ public ResponseEntity exportSubmissionsByParticipationIds(@PathVariabl // Only instructors or higher may override the anonymization setting if (!authCheckService.isAtLeastInstructorForExercise(programmingExercise, null)) { - repositoryExportOptions.setAnonymizeRepository(true); + repositoryExportOptions = repositoryExportOptions.copyWithAnonymizeRepository(true); } - if (repositoryExportOptions.getFilterLateSubmissionsDate() == null) { - repositoryExportOptions.setFilterLateSubmissionsIndividualDueDate(true); - repositoryExportOptions.setFilterLateSubmissionsDate(programmingExercise.getDueDate()); + if (repositoryExportOptions.filterLateSubmissionsDate() == null) { + repositoryExportOptions = repositoryExportOptions.copyWith(true, programmingExercise.getDueDate()); } var participationIdSet = new ArrayList<>(Arrays.asList(participationIds.split(","))).stream().map(String::trim).map(Long::parseLong).collect(Collectors.toSet()); 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/quiz/service/QuizExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseService.java index 274718a9b382..955212be3450 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseService.java @@ -33,7 +33,6 @@ import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; -import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; @@ -87,12 +86,10 @@ public class QuizExerciseService extends QuizService { private final ExerciseService exerciseService; - private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; - public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, ResultRepository resultRepository, QuizSubmissionRepository quizSubmissionRepository, InstanceMessageSendService instanceMessageSendService, QuizStatisticService quizStatisticService, QuizBatchService quizBatchService, ExerciseSpecificationService exerciseSpecificationService, FileService fileService, DragAndDropMappingRepository dragAndDropMappingRepository, - ShortAnswerMappingRepository shortAnswerMappingRepository, ExerciseService exerciseService, CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { + ShortAnswerMappingRepository shortAnswerMappingRepository, ExerciseService exerciseService) { super(dragAndDropMappingRepository, shortAnswerMappingRepository); this.quizExerciseRepository = quizExerciseRepository; this.resultRepository = resultRepository; @@ -103,7 +100,6 @@ public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, Result this.exerciseSpecificationService = exerciseSpecificationService; this.fileService = fileService; this.exerciseService = exerciseService; - this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizParticipationResource.java index fc2b4d3b3c94..da732bcdb3ff 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizParticipationResource.java @@ -28,7 +28,6 @@ import de.tum.cit.aet.artemis.quiz.domain.QuizExercise; import de.tum.cit.aet.artemis.quiz.repository.QuizExerciseRepository; import de.tum.cit.aet.artemis.quiz.repository.QuizSubmissionRepository; -import de.tum.cit.aet.artemis.quiz.repository.SubmittedAnswerRepository; import de.tum.cit.aet.artemis.quiz.service.QuizBatchService; /** @@ -49,20 +48,16 @@ public class QuizParticipationResource { private final ResultRepository resultRepository; - private final SubmittedAnswerRepository submittedAnswerRepository; - private final QuizSubmissionRepository quizSubmissionRepository; private final QuizBatchService quizBatchService; public QuizParticipationResource(QuizExerciseRepository quizExerciseRepository, ParticipationService participationService, UserRepository userRepository, - ResultRepository resultRepository, SubmittedAnswerRepository submittedAnswerRepository, QuizSubmissionRepository quizSubmissionRepository, - QuizBatchService quizBatchService) { + ResultRepository resultRepository, QuizSubmissionRepository quizSubmissionRepository, QuizBatchService quizBatchService) { this.quizExerciseRepository = quizExerciseRepository; this.participationService = participationService; this.userRepository = userRepository; this.resultRepository = resultRepository; - this.submittedAnswerRepository = submittedAnswerRepository; this.quizSubmissionRepository = quizSubmissionRepository; this.quizBatchService = quizBatchService; } 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/application.yml b/src/main/resources/config/application.yml index f2a2506dc1f5..78ae88017f7f 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -71,20 +71,20 @@ artemis: empty: default: "ubuntu:24.04" python: - default: "ls1tum/artemis-python-docker:latest" + default: "ls1tum/artemis-python-docker:v1.0.0" c: # possible overrides: gcc, fact - default: "ls1tum/artemis-c-docker:latest" - fact: "sharingcodeability/fact:latest" + default: "ls1tum/artemis-c-docker:v1.0.0" + fact: "sharingcodeability/fact:v0.0.5" haskell: default: "ghcr.io/uni-passau-artemis/artemis-haskell:v22.37.0" vhdl: - default: "tizianleonhardt/era-artemis-vhdl:latest" + default: "ghcr.io/ls1intum/artemis-vhdl-docker:v1.0.0" assembler: - default: "tizianleonhardt/era-artemis-assembler:latest" + default: "ghcr.io/ls1intum/artemis-assembler-docker:v1.0.0" swift: # possible overrides: xcode - default: "ls1tum/artemis-swift-swiftlint-docker:latest" + default: "ls1tum/artemis-swift-swiftlint-docker:swift5.9.2" ocaml: default: "ls1tum/artemis-ocaml-docker:v1" rust: @@ -98,7 +98,7 @@ artemis: c_sharp: default: "ghcr.io/ls1intum/artemis-csharp-docker:v1.0.0" typescript: - default: "ghcr.io/ls1intum/artemis-javascript-docker:v1.0.0" + default: "ghcr.io/ls1intum/artemis-javascript-docker:v1.0.0" # The following properties are used to configure the Artemis build agent. # The build agent is responsible for executing the buildJob to test student submissions. diff --git a/src/main/resources/config/liquibase/changelog/20240905161624_changelog.xml b/src/main/resources/config/liquibase/changelog/20240905161624_changelog.xml new file mode 100644 index 000000000000..43fb2528a950 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240905161624_changelog.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + 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 eac4d911931a..1e695e25fd46 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -25,6 +25,7 @@ + @@ -33,6 +34,7 @@ + diff --git a/src/main/resources/templates/haskell/test/test/Test.hs b/src/main/resources/templates/haskell/test/test/Test.hs index 024ec6008dfd..3f1ec81468e8 100644 --- a/src/main/resources/templates/haskell/test/test/Test.hs +++ b/src/main/resources/templates/haskell/test/test/Test.hs @@ -5,7 +5,9 @@ import qualified Interface as Sub import qualified Solution as Sol import Test.Tasty -import Test.Tasty.Runners.AntXML +import Test.Tasty.Ingredients (composeReporters) +import Test.Tasty.Ingredients.Basic (consoleTestReporter) +import Test.Tasty.Runners.AntXML import Test.SmallCheck.Series as SCS import Test.Tasty.SmallCheck as SC import Test.Tasty.QuickCheck as QC @@ -69,12 +71,12 @@ main = do testRunner $ localOption timeoutOption tests where resultsPath = "test-reports/results.xml" -#ifdef PROD - -- on the server (production mode), run tests with xml output - testRunner = defaultMainWithIngredients [antXMLRunner] +#ifdef PROD + -- on the server (production mode), run tests with additional xml output + testRunner = defaultMainWithIngredients [composeReporters antXMLRunner consoleTestReporter] #else -- locally, run tests with terminal output testRunner = defaultMain -#endif +#endif -- by default, run for 1 second timeoutOption = mkTimeout (1 * 10^6) diff --git a/src/main/webapp/app/admin/admin.route.ts b/src/main/webapp/app/admin/admin.route.ts index 81c3a096f66f..992b20b48c2b 100644 --- a/src/main/webapp/app/admin/admin.route.ts +++ b/src/main/webapp/app/admin/admin.route.ts @@ -20,6 +20,7 @@ import { BuildAgentSummaryComponent } from 'app/localci/build-agents/build-agent import { StandardizedCompetencyManagementComponent } from 'app/admin/standardized-competencies/standardized-competency-management.component'; import { BuildAgentDetailsComponent } from 'app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component'; import { AdminImportStandardizedCompetenciesComponent } from 'app/admin/standardized-competencies/import/admin-import-standardized-competencies.component'; +import { CleanupServiceComponent } from 'app/admin/cleanup-service/cleanup-service.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; export const adminState: Routes = [ @@ -150,6 +151,13 @@ export const adminState: Routes = [ (module) => module.IrisGlobalSettingsUpdateRoutingModule, ), }, + { + path: 'cleanup-service', + component: CleanupServiceComponent, + data: { + pageTitle: 'cleanupService.title', + }, + }, ...organizationMgmtRoute, ...userManagementRoute, ...systemNotificationManagementRoute, diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-operation.model.ts b/src/main/webapp/app/admin/cleanup-service/cleanup-operation.model.ts new file mode 100644 index 000000000000..920efd8ead02 --- /dev/null +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-operation.model.ts @@ -0,0 +1,18 @@ +import { WritableSignal } from '@angular/core'; +import dayjs from 'dayjs/esm'; + +export type OperationName = + | 'deleteOrphans' + | 'deletePlagiarismComparisons' + | 'deleteNonRatedResults' + | 'deleteOldRatedResults' + | 'deleteOldSubmissionVersions' + | 'deleteOldFeedback'; + +export class CleanupOperation { + name: OperationName; + deleteFrom: dayjs.Dayjs; + deleteTo: dayjs.Dayjs; + lastExecuted: dayjs.Dayjs | undefined; + datesValid: WritableSignal; +} diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html new file mode 100644 index 000000000000..aa1e021f066e --- /dev/null +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html @@ -0,0 +1,80 @@ +

+

+ +

+
+ + + + + + + + + + + + @for (operation of cleanupOperations; track operation) { + + + + + + + + } + +
+ + + @if (operation.name !== 'deleteOrphans') { +
+ +
+ } +
+ @if (operation.name !== 'deleteOrphans') { +
+ +
+ @if (!operation.datesValid()) { + + } + } +
+ @if (!operation.lastExecuted) { + {{ 'cleanupService.notRunYet' | artemisTranslate }} + } @else { + {{ operation.lastExecuted | artemisDate }} + } + + +
+
+
diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts new file mode 100644 index 000000000000..fab6e37e6217 --- /dev/null +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts @@ -0,0 +1,114 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import dayjs from 'dayjs/esm'; +import { CleanupOperation } from 'app/admin/cleanup-service/cleanup-operation.model'; +import { convertDateFromServer } from 'app/utils/date.utils'; +import { Subject } from 'rxjs'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { CleanupServiceExecutionRecordDTO, DataCleanupService } from 'app/admin/cleanup-service/data-cleanup.service'; +import { Observer } from 'rxjs'; +import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +@Component({ + selector: 'jhi-cleanup-service', + templateUrl: './cleanup-service.component.html', + standalone: true, + imports: [FormDateTimePickerModule, ArtemisSharedModule], +}) +export class CleanupServiceComponent implements OnInit { + private dialogErrorSource = new Subject(); + dialogError = this.dialogErrorSource.asObservable(); + + private dataCleanupService: DataCleanupService = inject(DataCleanupService); + + cleanupOperations: CleanupOperation[] = [ + { + name: 'deleteOrphans', + deleteFrom: dayjs().subtract(12, 'months'), + deleteTo: dayjs().subtract(6, 'months'), + lastExecuted: undefined, + datesValid: signal(true), + }, + { + name: 'deletePlagiarismComparisons', + deleteFrom: dayjs().subtract(12, 'months'), + deleteTo: dayjs().subtract(6, 'months'), + lastExecuted: undefined, + datesValid: signal(true), + }, + { + name: 'deleteNonRatedResults', + deleteFrom: dayjs().subtract(12, 'months'), + deleteTo: dayjs().subtract(6, 'months'), + lastExecuted: undefined, + datesValid: signal(true), + }, + { + name: 'deleteOldRatedResults', + deleteFrom: dayjs().subtract(12, 'months'), + deleteTo: dayjs().subtract(6, 'months'), + lastExecuted: undefined, + datesValid: signal(true), + }, + ]; + + ngOnInit(): void { + this.loadLastExecutions(); + } + + loadLastExecutions(): void { + this.dataCleanupService.getLastExecutions().subscribe((executionRecordsBody: HttpResponse) => { + const executionRecords = executionRecordsBody.body!; + if (executionRecords && executionRecords.length > 0) { + this.cleanupOperations.forEach((operation, index) => { + const executionRecord = executionRecords[index]; + if (executionRecord && executionRecord.executionDate) { + operation.lastExecuted = convertDateFromServer(executionRecord.executionDate); + } + }); + } + }); + } + + executeCleanupOperation(operation: CleanupOperation): void { + const subscriptionHandler = this.handleResponse(operation); + + switch (operation.name) { + case 'deleteOrphans': + this.dataCleanupService.deleteOrphans().subscribe(subscriptionHandler); + break; + case 'deletePlagiarismComparisons': + this.dataCleanupService.deletePlagiarismComparisons(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); + break; + case 'deleteNonRatedResults': + this.dataCleanupService.deleteNonRatedResults(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); + break; + case 'deleteOldRatedResults': + this.dataCleanupService.deleteOldRatedResults(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); + break; + } + } + + private handleResponse(operation: CleanupOperation): Observer> { + return { + next: (response: HttpResponse) => { + this.dialogErrorSource.next(''); + const executionDateFromServer = convertDateFromServer(response.body!.executionDate); + operation.lastExecuted = executionDateFromServer; + }, + error: (error: any) => { + if (error instanceof HttpErrorResponse) { + this.dialogErrorSource.next(error.message); + } else { + this.dialogErrorSource.next('An unexpected error occurred.'); + } + }, + complete: () => {}, + }; + } + + validateDates(operation: CleanupOperation): void { + const datesValid = operation.deleteFrom && operation.deleteTo && dayjs(operation.deleteTo).isAfter(dayjs(operation.deleteFrom)); + operation.datesValid.set(datesValid); + } +} diff --git a/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts b/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts new file mode 100644 index 000000000000..4782b5055674 --- /dev/null +++ b/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts @@ -0,0 +1,78 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import dayjs from 'dayjs/esm'; +import { convertDateFromClient } from 'app/utils/date.utils'; + +export interface CleanupServiceExecutionRecordDTO { + executionDate: dayjs.Dayjs; + jobType: string; +} + +@Injectable({ providedIn: 'root' }) +export class DataCleanupService { + private readonly adminResourceUrl = 'api/admin/cleanup'; + private http = inject(HttpClient); + + /** + * Send DELETE request to delete orphaned data. + * @returns An observable of type HttpResponse. + */ + deleteOrphans(): Observable> { + return this.http.delete(`${this.adminResourceUrl}/orphans`, { + observe: 'response', + }); + } + + /** + * Send DELETE request to delete plagiarism comparisons within a specific date range. + * @param deleteFrom the start date from which plagiarism comparisons should be deleted + * @param deleteTo the end date until which plagiarism comparisons should be deleted + */ + deletePlagiarismComparisons(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.delete(`${this.adminResourceUrl}/plagiarism-comparisons`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send DELETE request to delete non-rated results within a specific date range. + * @param deleteFrom the start date from which non-rated results should be deleted + * @param deleteTo the end date until which non-rated results should be deleted + */ + deleteNonRatedResults(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.delete(`${this.adminResourceUrl}/non-rated-results`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send DELETE request to delete old rated results within a specific date range. + * @param deleteFrom the start date from which old rated results should be deleted + * @param deleteTo the end date until which old rated results should be deleted + */ + deleteOldRatedResults(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.delete(`${this.adminResourceUrl}/old-rated-results`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send GET request to get the last executions. + * @returns An observable of type HttpResponse. + */ + getLastExecutions(): Observable> { + return this.http.get(`${this.adminResourceUrl}/last-executions`, { + observe: 'response', + }); + } +} diff --git a/src/main/webapp/app/app-routing.module.ts b/src/main/webapp/app/app-routing.module.ts index ee02e755f3a2..6ab77a7e46d0 100644 --- a/src/main/webapp/app/app-routing.module.ts +++ b/src/main/webapp/app/app-routing.module.ts @@ -129,6 +129,11 @@ const LAYOUT_ROUTES: Routes = [navbarRoute, ...errorRoute]; path: 'courses', loadChildren: () => import('./overview/courses.module').then((m) => m.ArtemisCoursesModule), }, + { + path: 'course-management/:courseId/lectures/:lectureId/attachments/:attachmentId', + pathMatch: 'full', + loadComponent: () => import('./lecture/pdf-preview/pdf-preview.component').then((m) => m.PdfPreviewComponent), + }, // ===== GRADING SYSTEM ===== { path: 'courses/:courseId/grading-system', 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,17 +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/competency.service.ts b/src/main/webapp/app/course/competencies/competency.service.ts index 1cba4d5410c4..31fd10c6abb1 100644 --- a/src/main/webapp/app/course/competencies/competency.service.ts +++ b/src/main/webapp/app/course/competencies/competency.service.ts @@ -71,7 +71,7 @@ export class CompetencyService extends CourseCompetencyService { importAll(courseId: number, sourceCourseId: number, importRelations: boolean) { return this.httpClient.post>( - `${this.resourceURL}/courses/${courseId}/competencies/import-all/${sourceCourseId}`, + `${this.resourceURL}/courses/${courseId}/competencies/import-all`, { importExercises: false, importRelations: importRelations, 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/detail-overview-list/detail-overview-list.component.html b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html index 11e5ee4c828f..ba2349c564d3 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html @@ -1,7 +1,7 @@ @if (headlines?.length && headlines.length > 1) { } -@for (section of sections; track section) { +@for (section of sections(); track section) {

{{ section.headline | artemisTranslate }}

@for (detail of section.details; track $index) { diff --git a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts index 25a5a6ca72e6..27eeca162243 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts @@ -1,7 +1,7 @@ -import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewEncapsulation, inject, input } from '@angular/core'; import { isEmpty } from 'lodash-es'; import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; -import { ButtonSize, TooltipPlacement } from 'app/shared/components/button.component'; +import { ButtonSize } from 'app/shared/components/button.component'; import { IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-settings.model'; import { ModelingExerciseService } from 'app/exercises/modeling/manage/modeling-exercise.service'; import { AlertService } from 'app/core/util/alert.service'; @@ -50,11 +50,13 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { protected readonly FeatureToggle = FeatureToggle; protected readonly ButtonSize = ButtonSize; protected readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; + protected readonly CHAT = IrisSubSettingsType.CHAT; - readonly CHAT = IrisSubSettingsType.CHAT; + private readonly modelingExerciseService = inject(ModelingExerciseService); + private readonly alertService = inject(AlertService); + private readonly profileService = inject(ProfileService); - @Input() - sections: DetailOverviewSection[]; + sections = input.required(); // headline list for navigation bar headlines: { id: string; translationKey: string }[]; @@ -64,14 +66,8 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { profileSubscription: Subscription; isLocalVC = false; - constructor( - private modelingExerciseService: ModelingExerciseService, - private alertService: AlertService, - private profileService: ProfileService, - ) {} - ngOnInit() { - this.headlines = this.sections.map((section) => { + this.headlines = this.sections().map((section) => { return { id: section.headline.replaceAll('.', '-'), translationKey: section.headline, @@ -98,6 +94,4 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { ngOnDestroy() { this.profileSubscription?.unsubscribe(); } - - protected readonly TooltipPlacement = TooltipPlacement; } diff --git a/src/main/webapp/app/entities/competency.model.ts b/src/main/webapp/app/entities/competency.model.ts index a48748c22c07..a1e01a1afb71 100644 --- a/src/main/webapp/app/entities/competency.model.ts +++ b/src/main/webapp/app/entities/competency.model.ts @@ -80,6 +80,7 @@ export abstract class CourseCompetency extends BaseCompetency { protected constructor(type: CourseCompetencyType) { super(); this.type = type; + this.masteryThreshold = DEFAULT_MASTERY_THRESHOLD; } } diff --git a/src/main/webapp/app/entities/metis/conversation/one-to-one-chat.model.ts b/src/main/webapp/app/entities/metis/conversation/one-to-one-chat.model.ts index 76f59620f516..d6c8d128596d 100644 --- a/src/main/webapp/app/entities/metis/conversation/one-to-one-chat.model.ts +++ b/src/main/webapp/app/entities/metis/conversation/one-to-one-chat.model.ts @@ -15,3 +15,10 @@ export class OneToOneChatDTO extends ConversationDTO { export function isOneToOneChatDTO(conversation: ConversationDTO): conversation is OneToOneChatDTO { return conversation.type === ConversationType.ONE_TO_ONE; } + +export function getAsOneToOneChatDTO(conversation: ConversationDTO | undefined): OneToOneChatDTO | undefined { + if (!conversation) { + return undefined; + } + return isOneToOneChatDTO(conversation) ? conversation : undefined; +} diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/programming-exam-diff/programming-exercise-exam-diff.component.html b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/programming-exam-diff/programming-exercise-exam-diff.component.html index 7f8623e91b52..cdde46506c49 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/programming-exam-diff/programming-exercise-exam-diff.component.html +++ b/src/main/webapp/app/exam/manage/student-exams/student-exam-timeline/programming-exam-diff/programming-exercise-exam-diff.component.html @@ -1,7 +1,7 @@

{{ exercise.title }} [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}] + >[{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.exercisePoints' | artemisTranslate }}] @if (exercise.includedInOverallScore !== IncludedInOverallScore.INCLUDED_COMPLETELY) { } diff --git a/src/main/webapp/app/exam/participate/exam-cover/exam-participation-cover.component.html b/src/main/webapp/app/exam/participate/exam-cover/exam-participation-cover.component.html index 9d56b746c3c9..9e626d906094 100644 --- a/src/main/webapp/app/exam/participate/exam-cover/exam-participation-cover.component.html +++ b/src/main/webapp/app/exam/participate/exam-cover/exam-participation-cover.component.html @@ -14,9 +14,11 @@

-
- -
+
+
+ +
+
}
} -
+
@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) { +
@for (informationBoxData of examInformationBoxData; track informationBoxData) { -
- - - @if (informationBoxData.contentComponent === 'formatedDate') { - - {{ informationBoxData.content | artemisDate: 'long-date' }} - - - {{ informationBoxData.content | artemisDate: 'time' }} - - } @else if (informationBoxData.contentComponent === 'workingTime') { - - } - - -
+ + + @if (informationBoxData.content.type === 'dateTime') { + {{ informationBoxData.content.value | artemisDate }} + } + @if (informationBoxData.content.type === 'workingTime') { + + } + + }
- +
diff --git a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts index 33237b989106..3f02da6bab2b 100644 --- a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts +++ b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { InformationBox, InformationBoxComponent } from 'app/shared/information-box/information-box.component'; +import { InformationBox, InformationBoxComponent, InformationBoxContent } from 'app/shared/information-box/information-box.component'; import { Exam } from 'app/entities/exam/exam.model'; import { StudentExam } from 'app/entities/student-exam.model'; import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module'; @@ -45,45 +45,78 @@ export class ExamStartInformationComponent implements OnInit { this.prepareInformationBoxData(); } - buildInformationBox(boxTitle: string, boxContent: string | number, boxContentComponent?: string): InformationBox { + buildInformationBox(boxTitle: string, boxContent: InformationBoxContent, isContentComponent = false): InformationBox { const examInformationBoxData: InformationBox = { title: boxTitle ?? '', - content: boxContent ?? '', - contentComponent: boxContentComponent, + content: boxContent, + isContentComponent: isContentComponent, }; return examInformationBoxData; } prepareInformationBoxData(): void { if (this.moduleNumber) { - const informationBoxModuleNumber = this.buildInformationBox('artemisApp.exam.moduleNumber', this.moduleNumber!); + const boxContentModuleNumber: InformationBoxContent = { + type: 'string', + value: this.moduleNumber, + }; + const informationBoxModuleNumber = this.buildInformationBox('artemisApp.exam.moduleNumber', boxContentModuleNumber); this.examInformationBoxData.push(informationBoxModuleNumber); } if (this.courseName) { - const informationBoxCourseName = this.buildInformationBox('artemisApp.exam.course', this.courseName!); + const boxContentCourseName: InformationBoxContent = { + type: 'string', + value: this.courseName, + }; + const informationBoxCourseName = this.buildInformationBox('artemisApp.exam.course', boxContentCourseName); this.examInformationBoxData.push(informationBoxCourseName); } if (this.examiner) { - const informationBoxExaminer = this.buildInformationBox('artemisApp.examManagement.examiner', this.examiner!); + const boxContentExaminer: InformationBoxContent = { + type: 'string', + value: this.examiner, + }; + const informationBoxExaminer = this.buildInformationBox('artemisApp.examManagement.examiner', boxContentExaminer); this.examInformationBoxData.push(informationBoxExaminer); } if (this.examinedStudent) { - const informationBoxExaminedStudent = this.buildInformationBox('artemisApp.exam.examinedStudent', this.examinedStudent!); + const boxContentExaminedStudent: InformationBoxContent = { + type: 'string', + value: this.examinedStudent, + }; + const informationBoxExaminedStudent = this.buildInformationBox('artemisApp.exam.examinedStudent', boxContentExaminedStudent); this.examInformationBoxData.push(informationBoxExaminedStudent); } if (this.startDate) { - const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.date', this.startDate.toString(), 'formatedDate'); + const boxContentStartDate: InformationBoxContent = { + type: 'dateTime', + value: this.startDate, + }; + const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.date', boxContentStartDate, true); this.examInformationBoxData.push(informationBoxStartDate); } - const informationBoxTotalWorkingTime = this.buildInformationBox('artemisApp.exam.workingTime', this.exam.workingTime!, 'workingTime'); + const boxContentExamWorkingTime: InformationBoxContent = { + type: 'workingTime', + value: this.studentExam, + }; + + const informationBoxTotalWorkingTime = this.buildInformationBox('artemisApp.exam.workingTime', boxContentExamWorkingTime, true); this.examInformationBoxData.push(informationBoxTotalWorkingTime); + const boxContentTotalPoints: InformationBoxContent = { + type: 'string', + value: this.totalPoints?.toString() ?? '', + }; - const informationBoxTotalPoints = this.buildInformationBox('artemisApp.exam.points', this.totalPoints!.toString()); + const informationBoxTotalPoints = this.buildInformationBox('artemisApp.exam.points', boxContentTotalPoints); this.examInformationBoxData.push(informationBoxTotalPoints); if (this.numberOfExercisesInExam) { - const informationBoxNumberOfExercises = this.buildInformationBox('artemisApp.exam.exercises', this.numberOfExercisesInExam!.toString()); + const boxContent: InformationBoxContent = { + type: 'string', + value: this.numberOfExercisesInExam?.toString(), + }; + const informationBoxNumberOfExercises = this.buildInformationBox('artemisApp.exam.exercises', boxContent); this.examInformationBoxData.push(informationBoxNumberOfExercises); } } diff --git a/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts b/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts index 0611ec06032b..72fb6667e806 100644 --- a/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts +++ b/src/main/webapp/app/exam/participate/exercises/exam-submission-components.module.ts @@ -18,6 +18,7 @@ import { ArtemisCodeEditorModule } from 'app/exercises/programming/shared/code-e import { ArtemisFullscreenModule } from 'app/shared/fullscreen/fullscreen.module'; import { ArtemisModelingEditorModule } from 'app/exercises/modeling/shared/modeling-editor.module'; import { ArtemisProgrammingSubmissionPolicyStatusModule } from 'app/exercises/programming/participate/programming-submission-policy-status.module'; +import { ExerciseSaveButtonComponent } from './exercise-save-button/exercise-save-button.component'; @NgModule({ declarations: [ @@ -42,6 +43,7 @@ import { ArtemisProgrammingSubmissionPolicyStatusModule } from 'app/exercises/pr ArtemisModelingEditorModule, ArtemisProgrammingSubmissionPolicyStatusModule, ExamExerciseUpdateHighlighterModule, + ExerciseSaveButtonComponent, ], exports: [FileUploadExamSubmissionComponent, QuizExamSubmissionComponent, ProgrammingExamSubmissionComponent, TextExamSubmissionComponent, ModelingExamSubmissionComponent], }) diff --git a/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.html b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.html new file mode 100644 index 000000000000..8ec143ed83ee --- /dev/null +++ b/src/main/webapp/app/exam/participate/exercises/exercise-save-button/exercise-save-button.component.html @@ -0,0 +1,8 @@ + 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/file-upload/manage/file-upload-exercise.component.ts b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise.component.ts index 01fe874b2afd..0874dea0faf6 100644 --- a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise.component.ts +++ b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise.component.ts @@ -1,28 +1,30 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; import { filter } from 'rxjs/operators'; import { FileUploadExercise } from 'app/entities/file-upload-exercise.model'; import { FileUploadExerciseService } from './file-upload-exercise.service'; import { ExerciseComponent } from 'app/exercises/shared/exercise/exercise.component'; import { onError } from 'app/shared/util/global.utils'; import { AccountService } from 'app/core/auth/account.service'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; import { SortService } from 'app/shared/service/sort.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { AlertService } from 'app/core/util/alert.service'; -import { EventManager } from 'app/core/util/event-manager.service'; import { faBook, faPlus, faSort, faTable, faTrash, faUsers, faWrench } from '@fortawesome/free-solid-svg-icons'; import { faListAlt } from '@fortawesome/free-regular-svg-icons'; import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'jhi-file-upload-exercise', templateUrl: './file-upload-exercise.component.html', }) export class FileUploadExerciseComponent extends ExerciseComponent { + protected exerciseService = inject(ExerciseService); + protected fileUploadExerciseService = inject(FileUploadExerciseService); + private courseExerciseService = inject(CourseExerciseService); + private alertService = inject(AlertService); + private accountService = inject(AccountService); + private sortService = inject(SortService); + @Input() fileUploadExercises: FileUploadExercise[] = []; filteredFileUploadExercises: FileUploadExercise[] = []; @@ -40,23 +42,6 @@ export class FileUploadExerciseComponent extends ExerciseComponent { return this.fileUploadExercises; } - constructor( - public exerciseService: ExerciseService, - public fileUploadExerciseService: FileUploadExerciseService, - private courseExerciseService: CourseExerciseService, - private alertService: AlertService, - private accountService: AccountService, - private modalService: NgbModal, - private router: Router, - private sortService: SortService, - courseService: CourseManagementService, - translateService: TranslateService, - eventManager: EventManager, - route: ActivatedRoute, - ) { - super(courseService, translateService, route, eventManager); - } - protected loadExercises(): void { this.courseExerciseService .findAllFileUploadExercisesForCourse(this.courseId) 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/manage/modeling-exercise.component.ts b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise.component.ts index 74dff3c4b9fc..e1ea83ce136e 100644 --- a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise.component.ts @@ -1,18 +1,13 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; import { ModelingExerciseService } from './modeling-exercise.service'; import { AccountService } from 'app/core/auth/account.service'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute, Router } from '@angular/router'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ExerciseComponent } from 'app/exercises/shared/exercise/exercise.component'; -import { TranslateService } from '@ngx-translate/core'; import { onError } from 'app/shared/util/global.utils'; import { SortService } from 'app/shared/service/sort.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { AlertService } from 'app/core/util/alert.service'; -import { EventManager } from 'app/core/util/event-manager.service'; import { faBook, faPlus, faSort, faTable, faTimes, faTrash, faUsers, faWrench } from '@fortawesome/free-solid-svg-icons'; import { faListAlt } from '@fortawesome/free-regular-svg-icons'; import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; @@ -22,7 +17,14 @@ import { CourseExerciseService } from 'app/exercises/shared/course-exercises/cou templateUrl: './modeling-exercise.component.html', }) export class ModelingExerciseComponent extends ExerciseComponent { - @Input() modelingExercises: ModelingExercise[]; + protected exerciseService = inject(ExerciseService); + protected modelingExerciseService = inject(ModelingExerciseService); + private courseExerciseService = inject(CourseExerciseService); + private alertService = inject(AlertService); + private accountService = inject(AccountService); + private sortService = inject(SortService); + + @Input() modelingExercises: ModelingExercise[] = []; filteredModelingExercises: ModelingExercise[]; // Icons faPlus = faPlus; @@ -39,24 +41,6 @@ export class ModelingExerciseComponent extends ExerciseComponent { return this.modelingExercises; } - constructor( - public exerciseService: ExerciseService, - public modelingExerciseService: ModelingExerciseService, - private courseExerciseService: CourseExerciseService, - private alertService: AlertService, - private accountService: AccountService, - private sortService: SortService, - private modalService: NgbModal, - private router: Router, - courseService: CourseManagementService, - translateService: TranslateService, - eventManager: EventManager, - route: ActivatedRoute, - ) { - super(courseService, translateService, route, eventManager); - this.modelingExercises = []; - } - protected loadExercises(): void { this.courseExerciseService.findAllModelingExercisesForCourse(this.courseId).subscribe({ next: (res: HttpResponse) => { 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 @@