diff --git a/README.md b/README.md index 89fa054c8626..9b8299f8b7f7 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ The following members of the project management team are responsible for specifi | Exercises | Stephan Krusche ([@krusche](https://github.com/krusche)) | | Programming exercises | Stephan Krusche ([@krusche](https://github.com/krusche)) | | Build agents | Robert Jandow ([@robertjndw](https://github.com/robertjndw)) | -| Quiz exercises | Felix Dietrich ([@FelixTJDietrich](https://github.com/FelixTJDietrich)) | +| Quiz exercises | Timor Morrien ([@Hialus](https://github.com/Hialus)) | | Modeling exercises | Felix Dietrich ([@FelixTJDietrich](https://github.com/FelixTJDietrich)) | -| Text exercises | Maximilian Sölch ([@maximiliansoelch](https://github.com/maximiliansoelch)) | +| Text exercises | Felix Dietrich ([@FelixTJDietrich](https://github.com/FelixTJDietrich)) | | File upload exercises | Elisabeth Friesinger ([@easy-lisi](https://github.com/easy-lisi)) | | Exam mode | Stephan Krusche ([@krusche](https://github.com/krusche)) | | Assessment | Maximilian Sölch ([@maximiliansoelch](https://github.com/maximiliansoelch)) | @@ -193,7 +193,7 @@ Refer to [Using JHipster in production](http://www.jhipster.tech/production) for The following command can automate the deployment to a server. The example shows the deployment to the main Artemis test server (which runs a virtual machine): ```shell -./artemis-server-cli deploy username@artemistest.ase.in.tum.de -w build/libs/Artemis-7.6.2.war +./artemis-server-cli deploy username@artemistest.ase.in.tum.de -w build/libs/Artemis-7.6.5.war ``` ## Architecture diff --git a/angular.json b/angular.json index 008ac75d13bf..815c38a3f705 100644 --- a/angular.json +++ b/angular.json @@ -20,39 +20,40 @@ "build": { "builder": "@angular-devkit/build-angular:application", "options": { - "allowedCommonJsDependencies": [ - "clone-deep", - "crypto-js", - "crypto", - "dagre", - "dayjs/locale/de", - "dompurify", - "export-to-csv", - "hoist-non-react-statics", - "interactjs", - "is-mobile", - "js-video-url-parser", - "jszip", - "localforage", - "mobile-drag-drop", - "papaparse", - "pepjs", - "prop-types", - "react", - "react-dom", - "react-dom/client", - "react-is", - "rfdc", - "shallowequal", - "showdown-highlight", - "showdown-katex", - "showdown", - "smoothscroll-polyfill", - "sockjs-client", - "use-sync-external-store/shim", - "use-sync-external-store/shim/with-selector", - "webcola", - "webstomp-client" + "allowedCommonJsDependencies": [ + "@vscode/markdown-it-katex", + "clone-deep", + "crypto-js", + "crypto", + "dagre", + "dayjs/locale/de", + "dompurify", + "emoji-js", + "export-to-csv", + "hoist-non-react-statics", + "interactjs", + "is-mobile", + "js-video-url-parser", + "jszip", + "localforage", + "markdown-it-highlightjs", + "mobile-drag-drop", + "papaparse", + "pepjs", + "prop-types", + "react", + "react-dom", + "react-dom/client", + "react-is", + "rfdc", + "shallowequal", + "markdown-it-class", + "smoothscroll-polyfill", + "sockjs-client", + "use-sync-external-store/shim", + "use-sync-external-store/shim/with-selector", + "webcola", + "webstomp-client" ], "outputPath": { "base": "build/resources/main/static/", diff --git a/build.gradle b/build.gradle index 30502ab85ec7..5f9283d1f9ea 100644 --- a/build.gradle +++ b/build.gradle @@ -13,19 +13,19 @@ plugins { id "jacoco" id "org.springframework.boot" version "${spring_boot_version}" id "io.spring.dependency-management" version "1.1.6" - id "com.google.cloud.tools.jib" version "3.4.3" + id "com.google.cloud.tools.jib" version "3.4.4" id "com.github.node-gradle.node" version "${gradle_node_plugin_version}" id "com.diffplug.spotless" version "6.25.0" // this allows us to find outdated dependencies via ./gradlew dependencyUpdates id "com.github.ben-manes.versions" version "0.51.0" id "com.github.andygoossens.modernizer" version "${modernizer_plugin_version}" id "com.gorylenko.gradle-git-properties" version "2.4.2" - id "org.owasp.dependencycheck" version "10.0.4" + id "org.owasp.dependencycheck" version "11.0.0" id "com.adarshr.test-logger" version "4.0.0" } group = "de.tum.cit.aet.artemis" -version = "7.6.2" +version = "7.6.5" description = "Interactive Learning with Individual Feedback" java { @@ -264,7 +264,7 @@ dependencies { implementation "org.apache.lucene:lucene-queryparser:${lucene_version}" implementation "org.apache.lucene:lucene-core:${lucene_version}" implementation "org.apache.lucene:lucene-analyzers-common:${lucene_version}" - implementation "com.google.protobuf:protobuf-java:4.28.2" + implementation "com.google.protobuf:protobuf-java:4.28.3" // we have to override those values to use the latest version implementation "org.slf4j:jcl-over-slf4j:${slf4j_version}" @@ -413,7 +413,7 @@ dependencies { implementation "io.netty:netty-all:4.1.114.Final" implementation "io.projectreactor.netty:reactor-netty:1.1.23" implementation "org.springframework:spring-messaging:6.1.14" - implementation "org.springframework.retry:spring-retry:2.0.9" + implementation "org.springframework.retry:spring-retry:2.0.10" implementation "org.springframework.security:spring-security-config:${spring_security_version}" implementation "org.springframework.security:spring-security-data:${spring_security_version}" @@ -452,7 +452,7 @@ dependencies { implementation "org.apache.maven:maven-model:3.9.9" implementation "org.apache.pdfbox:pdfbox:3.0.3" implementation "org.apache.commons:commons-csv:1.12.0" - implementation "org.commonmark:commonmark:0.23.0" + implementation "org.commonmark:commonmark:0.24.0" implementation "commons-fileupload:commons-fileupload:1.5" implementation "net.lingala.zip4j:zip4j:2.11.5" @@ -468,7 +468,7 @@ dependencies { implementation "com.google.code.gson:gson:2.11.0" - implementation "com.google.errorprone:error_prone_annotations:2.33.0" + implementation "com.google.errorprone:error_prone_annotations:2.34.0" // NOTE: we want to keep the same unique version for all configurations, implementation and annotationProcessor implementation("net.bytebuddy:byte-buddy") { diff --git a/docs/admin/setup.rst b/docs/admin/setup.rst index d14cd48a0003..6c2f551d4afb 100644 --- a/docs/admin/setup.rst +++ b/docs/admin/setup.rst @@ -8,6 +8,10 @@ This section describes some additional steps that are of interest for production For information on how to set up extension services to activate additional functionality in your Artemis instance, see :ref:`their respective documentation `. +We recommend using the `Artemis Ansible Collection `_ for +setting up Artemis in production. The collection provides a set of Ansible roles that automate the setup of Artemis, +including the required external system with sane configuration defaults. + .. toctree:: :includehidden: :maxdepth: 2 diff --git a/docs/admin/setup/distributed.rst b/docs/admin/setup/distributed.rst index 1fa74024dc2d..b2d1a12822d3 100644 --- a/docs/admin/setup/distributed.rst +++ b/docs/admin/setup/distributed.rst @@ -617,8 +617,13 @@ These credentials are used to clone repositories via HTTPS. You must also add th container-cleanup: expiry-minutes: 5 # Time after a hanging container will automatically be removed cleanup-schedule-minutes: 60 # Schedule for container cleanup + build-agent: + short-name: "artemis-build-agent-X" # Short name of the build agent. This should be unique for each build agent. Only lowercase letters, numbers and hyphens are allowed. + display-name: "Artemis Build Agent X" # This value is optional. If omitted, the short name will be used as display name. Display name of the build agent. This is shown in the Artemis UI. +Please note that ``artemis.continuous-integration.build-agent.short-name`` must be provided. Otherwise, the build agent will not start. + Build agents run as `Hazelcast Lite Members `__ and require a full member, in our case a core node, to be running. Thus, before starting a build agent make sure that at least the primary node is running. You can then add and remove build agents to the cluster as desired. diff --git a/docs/admin/setup/security.rst b/docs/admin/setup/security.rst index 4a7ef89c2aad..693cb211bfc7 100644 --- a/docs/admin/setup/security.rst +++ b/docs/admin/setup/security.rst @@ -126,45 +126,41 @@ For Artemis to find the key set `artemis.version-control.ssh-host-key-path` to t Adapting Nginx to Enable SSH Routing """""""""""""""""""""""""""""""""""" -To enable SSH routing through Nginx, you can set up an SSH proxy. However, Nginx by itself does -not support SSH, but you can use Nginx to reverse proxy an SSH service (e.g., using sslh to multiplex SSH and HTTPS). +To enable SSH routing through Nginx, you can set up an SSH proxy. -Configure sslh to listen on port 443 (to handle both HTTPS and SSH), by editing the sslh configuration -file (e.g., /etc/default/sslh): - -.. code-block:: text - - RUN=yes - DAEMON=/usr/sbin/sslh - DAEMON_OPTS="--user sslh --listen 0.0.0.0:443 --ssh 127.0.0.1:22 --ssl 127.0.0.1:8443" - - - -Configure Nginx to proxy HTTPS traffic, by adapting the configuration file to listen on port 8443 for HTTPS: +Configure Nginx to proxy HTTPS traffic on port 443 and SSH traffic on port 7921. .. code-block:: nginx - server { - listen 8443 ssl; - server_name yourdomain.com; - - ssl_certificate /etc/nginx/ssl/nginx.crt; - ssl_certificate_key /etc/nginx/ssl/nginx.key; + http { + server { + listen 443 ssl; + server_name yourdomain.com; + + ssl_certificate /etc/nginx/ssl/nginx.crt; + ssl_certificate_key /etc/nginx/ssl/nginx.key; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + } - location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + stream { + server { + listen 7921; + proxy_pass 127.0.0.1:7921; } } -Restart sslh and Nginx: +Restart Nginx: .. code-block:: bash - sudo systemctl restart sslh sudo systemctl restart nginx By following these steps, you ensure that your key pairs are properly generated and distributed across all diff --git a/docs/user/markdown-support.rst b/docs/user/markdown-support.rst index c28d50fa2786..9ac5b6f5a56f 100644 --- a/docs/user/markdown-support.rst +++ b/docs/user/markdown-support.rst @@ -9,7 +9,7 @@ Markdown Support `Markdown `__ is an easy-to-read, easy-to-write syntax for formatting plain text. -A markdown playground can be found `here `__. +A markdown playground can be found `here `__. Artemis extends the basic `Markdown `__ syntax to support Artemis-specific features. This Artemis flavored Markdown is used to format text content across the platform using an integrated markdown editor. @@ -52,9 +52,9 @@ Markdown is also supported in the context of :ref:`communicating` Supported Syntax ^^^^^^^^^^^^^^^^ -The integrated markdown editor uses `Showdown `__. A quick description of the supported syntax can be found `here `__. +The integrated markdown editor uses `MarkdownIt `__. A quick description of the supported syntax can be found `here `__. -The following Showdown extensions are activated: +The following Plugins are activated: -- `Showdown Katex `__ to render LaTeX math and AsciiMath using KaTeX. -- `Showdown Highlight `__ for syntax highlighting in code blocks. +- `MarkdownIt Katex `__ to render LaTeX math and AsciiMath using KaTeX. +- `MarkdownIt HighlightJS `__ for syntax highlighting in code blocks. diff --git a/gradle.properties b/gradle.properties index 02b6e1e550ce..0fbab37898a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ npm_version=10.8.0 # Dependency versions jhipster_dependencies_version=8.7.1 -spring_boot_version=3.3.4 -spring_security_version=6.3.3 +spring_boot_version=3.3.5 +spring_security_version=6.3.4 # 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,17 +25,17 @@ 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.15.0 +sentry_version=7.16.0 liquibase_version=4.29.2 docker_java_version=3.4.0 logback_version=1.5.11 java_parser_version=3.26.2 -byte_buddy_version=1.15.5 +byte_buddy_version=1.15.7 # testing # make sure both versions are compatible junit_version=5.11.0 -junit_platform_version=1.11.2 +junit_platform_version=1.11.3 mockito_version=5.14.2 diff --git a/jest.config.js b/jest.config.js index 9855e511a99a..79e40bdb3162 100644 --- a/jest.config.js +++ b/jest.config.js @@ -102,10 +102,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 87.39, - branches: 73.60, - functions: 81.97, - lines: 87.45, + statements: 87.52, + branches: 73.62, + functions: 82.12, + lines: 87.57, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], diff --git a/package-lock.json b/package-lock.json index e932b0297d3b..d2e155397135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "artemis", - "version": "7.6.2", + "version": "7.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "7.6.2", + "version": "7.6.5", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.9", - "@angular/common": "18.2.8", - "@angular/compiler": "18.2.8", - "@angular/core": "18.2.8", - "@angular/forms": "18.2.8", - "@angular/localize": "18.2.8", - "@angular/material": "18.2.9", - "@angular/platform-browser": "18.2.8", - "@angular/platform-browser-dynamic": "18.2.8", - "@angular/router": "18.2.8", - "@angular/service-worker": "18.2.8", + "@angular/animations": "18.2.9", + "@angular/cdk": "18.2.10", + "@angular/common": "18.2.9", + "@angular/compiler": "18.2.9", + "@angular/core": "18.2.9", + "@angular/forms": "18.2.9", + "@angular/localize": "18.2.9", + "@angular/material": "18.2.10", + "@angular/platform-browser": "18.2.9", + "@angular/platform-browser-dynamic": "18.2.9", + "@angular/router": "18.2.9", + "@angular/service-worker": "18.2.9", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -29,15 +29,16 @@ "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", - "@ls1intum/apollon": "3.3.14", + "@ls1intum/apollon": "3.3.15", "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.34.0", + "@sentry/angular": "8.35.0", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", + "@vscode/markdown-it-katex": "1.1.0", "bootstrap": "5.3.3", "compare-versions": "6.1.1", "core-js": "3.38.1", @@ -45,6 +46,7 @@ "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", "dompurify": "3.1.7", + "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", "franc-min": "6.2.0", @@ -54,23 +56,24 @@ "js-video-url-parser": "0.5.1", "jszip": "3.10.1", "lodash-es": "4.17.21", + "markdown-it": "14.1.0", + "markdown-it-class": "1.0.0", + "markdown-it-highlightjs": "4.2.0", "mobile-drag-drop": "3.0.0-rc.0", "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.174.2", + "posthog-js": "1.176.0", "rxjs": "7.8.1", - "showdown": "2.1.0", - "showdown-highlight": "3.1.0", - "showdown-katex": "0.6.0", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", "tslib": "2.8.0", + "turndown": "7.2.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -78,30 +81,32 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.9", - "@angular-eslint/builder": "18.3.1", - "@angular-eslint/eslint-plugin": "18.3.1", - "@angular-eslint/eslint-plugin-template": "18.3.1", - "@angular-eslint/schematics": "18.3.1", - "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.9", - "@angular/compiler-cli": "18.2.8", - "@angular/language-service": "18.2.8", - "@sentry/types": "8.34.0", + "@angular-devkit/build-angular": "18.2.10", + "@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.10", + "@angular/compiler-cli": "18.2.9", + "@angular/language-service": "18.2.9", + "@sentry/types": "8.35.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", - "@types/jest": "29.5.13", + "@types/emoji-js": "3.5.2", + "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.6", - "@types/papaparse": "5.3.14", - "@types/showdown": "2.0.6", + "@types/markdown-it": "14.1.2", + "@types/node": "22.7.9", + "@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.10.0", - "@typescript-eslint/parser": "8.10.0", - "eslint": "9.12.0", + "@typescript-eslint/eslint-plugin": "8.11.0", + "@typescript-eslint/parser": "8.11.0", + "eslint": "9.13.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.8.3", @@ -121,7 +126,7 @@ "ngxtension": "4.0.0", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.80.2", + "sass": "1.80.4", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -212,13 +217,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.9.tgz", - "integrity": "sha512-fubJf4WC/t3ITy+tyjI4/CKKwUP4XJTmV+Y0nyPcrkcthVyUcIpZB74NlUOvg6WECiPQuIc+CtoAaA9X5+RQ5Q==", + "version": "0.1802.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.10.tgz", + "integrity": "sha512-/xudcHK2s4J/GcL6qyobmGaWMHQcYLSMqCaWMT+nK6I6tu9VEAj/p3R83Tzx8B/eKi31Pz499uHw9pmqdtbafg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.9", + "@angular-devkit/core": "18.2.10", "rxjs": "7.8.1" }, "engines": { @@ -228,17 +233,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.9.tgz", - "integrity": "sha512-d4W6t9vBozFUmOP2VvihMcSg/zgr3AvJY6/b7OPuATlK+W3P6tmsqxGIQ6eKc1TxXeu3lWhi14mV2pPykfrwfA==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.10.tgz", + "integrity": "sha512-47XgJ5fdIqlZUFWAo/XtNsh3y597DtLZWvfsnwShw6/TgyiV0rbL1Z24Rn2TCV1D/b3VhLutAIIZ/i5O5BirxQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.9", - "@angular-devkit/build-webpack": "0.1802.9", - "@angular-devkit/core": "18.2.9", - "@angular/build": "18.2.9", + "@angular-devkit/architect": "0.1802.10", + "@angular-devkit/build-webpack": "0.1802.10", + "@angular-devkit/core": "18.2.10", + "@angular/build": "18.2.10", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -249,7 +254,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.9", + "@ngtools/webpack": "18.2.10", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -260,7 +265,7 @@ "css-loader": "7.1.2", "esbuild-wasm": "0.23.0", "fast-glob": "3.3.2", - "http-proxy-middleware": "3.0.0", + "http-proxy-middleware": "3.0.3", "https-proxy-agent": "7.0.5", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", @@ -382,13 +387,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.9.tgz", - "integrity": "sha512-p7xNGo5ZTV/Z0Rk+q2/E68QQLw9VT33kauDh6s010jIeBLrOwMo74JpzXMSFttQo5O4bLKP8IORzIM+0q7Uzjg==", + "version": "0.1802.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.10.tgz", + "integrity": "sha512-WRftK/RJ9rBDDmkx5IAtIpyNo0DJiMfgGUTuZNpNUaJfSfGeaSZYgC7o1++axMchID8pncmI3Hr8L8gaP94WQg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/architect": "0.1802.10", "rxjs": "7.8.1" }, "engines": { @@ -402,9 +407,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.9.tgz", - "integrity": "sha512-bsVt//5E0ua7FZfO0dCF/qGGY6KQD34/bNGyRu5B6HedimpdU2/0PGDptksU5v3yKEc9gNw0xC6mT0UsY/R9pA==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.10.tgz", + "integrity": "sha512-LFqiNdraBujg8e1lhuB0bkFVAoIbVbeXXwfoeROKH60OPbP8tHdgV6sFTqU7UGBKA+b+bYye70KFTG2Ys8QzKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -430,13 +435,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.9.tgz", - "integrity": "sha512-aIY5/IomDOINGCtFYi77uo0acDpdQNNCighfBBUGEBNMQ1eE3oGNGpLAH/qWeuxJndgmxrdKsvws9DdT46kLig==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.10.tgz", + "integrity": "sha512-EIm/yCYg3ZYPsPYJxXRX5F6PofJCbNQ5rZEuQEY09vy+ZRTqGezH0qoUP5WxlYeJrjiRLYqADI9WtVNzDyaD4w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.9", + "@angular-devkit/core": "18.2.10", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -449,9 +454,9 @@ } }, "node_modules/@angular-eslint/builder": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-18.3.1.tgz", - "integrity": "sha512-cPc7Ye9zDs5M4i+feL6vob+mh7yX5vxvOS5KQIhneUrp5e9D+IGuNFMmBLlOPpmklSc9XJBtuvI5Zjuh4z1ETw==", + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-18.4.0.tgz", + "integrity": "sha512-FOzGHX/nHSV1wSduSsabsx3aqC1nfde0opEpEDSOJhxExDxKCwoS1XPy1aERGyKip4ZVA6phC3dLtoBH3QMkVQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -460,21 +465,21 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-18.3.1.tgz", - "integrity": "sha512-sikmkjfsXPpPTku1aQkQ1MNNEKGBgGGRvUN/WeNS9dhCJ4dxU3O7dZctt1aQWj+W3nbuUtDiimAWF5fZHGFE2Q==", + "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==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-18.3.1.tgz", - "integrity": "sha512-MP4Nm+SHboF8KdnN0KpPEGAaTTzDLPm3+S/4W3Mg8onqWCyadyd4mActh9mK/pvCj8TVlb/SW1zeTtdMYhwonw==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.3.1", - "@angular-eslint/utils": "18.3.1" + "@angular-eslint/bundled-angular-compiler": "18.4.0", + "@angular-eslint/utils": "18.4.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -483,32 +488,33 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-18.3.1.tgz", - "integrity": "sha512-hBJ3+f7VSidvrtYaXH7Vp0sWvblA9jLK2c6uQzhYGWdEDUcTg7g7VI9ThW39WvMbHqkyzNE4PPOynK69cBEDGg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.3.1", - "@angular-eslint/utils": "18.3.1", - "aria-query": "5.3.0", + "@angular-eslint/bundled-angular-compiler": "18.4.0", + "@angular-eslint/utils": "18.4.0", + "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": "*" } }, "node_modules/@angular-eslint/schematics": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-18.3.1.tgz", - "integrity": "sha512-BTsQHDu7LjvXannJTb5BqMPCFIHRNN94eRyb60VfjJxB/ZFtsbAQDFFOi5lEZsRsd4mBeUMuL9mW4IMcPtUQ9Q==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/eslint-plugin": "18.3.1", - "@angular-eslint/eslint-plugin-template": "18.3.1", + "@angular-eslint/eslint-plugin": "18.4.0", + "@angular-eslint/eslint-plugin-template": "18.4.0", "ignore": "5.3.2", "semver": "7.6.3", "strip-json-comments": "3.1.1" @@ -519,13 +525,13 @@ } }, "node_modules/@angular-eslint/template-parser": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-18.3.1.tgz", - "integrity": "sha512-JUUkfWH1G+u/Uk85ZYvJSt/qwN/Ko+jlXFtzBEcknJZsTWTwBcp36v77gPZe5FmKSziJZpyPUd+7Kiy6tuSCTw==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.3.1", + "@angular-eslint/bundled-angular-compiler": "18.4.0", "eslint-scope": "^8.0.2" }, "peerDependencies": { @@ -534,13 +540,13 @@ } }, "node_modules/@angular-eslint/utils": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.3.1.tgz", - "integrity": "sha512-sd9niZI7h9H2FQ7OLiQsLFBhjhRQTASh+Q0+4+hyjv9idbSHBJli8Gsi2fqj9zhtMKpAZFTrWzuLUpubJ9UYbA==", + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-18.4.0.tgz", + "integrity": "sha512-At1yS8GRviGBoaupiQwEOL4/IcZJCE/+2vpXdItMWPGB1HWetxlKAUZTMmIBX/r5Z7CoXxl+LbqpGhrhyzIQAg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "18.3.1" + "@angular-eslint/bundled-angular-compiler": "18.4.0" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -549,9 +555,9 @@ } }, "node_modules/@angular/animations": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.8.tgz", - "integrity": "sha512-dMSn2hg70siv3lhP+vqhMbgc923xw6XBUvnpCPEzhZqFHvPXfh/LubmsD5RtqHmjWebXtgVcgS+zg3Gq3jB2lg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.9.tgz", + "integrity": "sha512-GAsTKENoTRVKgXX4ACBMMTp8SW4rW8u637uLag+ttJV2XBzC3YJlw5m6b/W4cdrmqZjztoEwUjR6CUTjBqMujQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -560,18 +566,18 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.8" + "@angular/core": "18.2.9" } }, "node_modules/@angular/build": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.9.tgz", - "integrity": "sha512-o1hOEM2e6ARy+ck2Pohl0d/RFgbbXTw6/hTLAj3CBKjtqAGStRaVF2UlJjhi+xOxlfsOPuJJc9IpzLBteku+Ag==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.10.tgz", + "integrity": "sha512-YFBKvAyC5sH17yRYcx7VHCtJ4KUg7xCjCQ4Pe16kiTvW6vuYsgU6Btyti0Qgewd7XaWpTM8hk8N6hE4Z0hpflw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/architect": "0.1802.10", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -651,9 +657,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.9.tgz", - "integrity": "sha512-hV2dXpvy2TLwCsRtI/ZXkb2EoaJiellRr+kbcnKwO15LFoz3mTAOhKtsvu7yOyURkaPiI605qiIZrPP4zLL1qw==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.10.tgz", + "integrity": "sha512-Weh0slrfWNp5N6UO4m3tXzs2QBFexNsnJf1dq0oaLDBgfkuqUmxdCkurSv5+lWZRkTPLYmd/hQeJpvrhxMCleg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -668,18 +674,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.9.tgz", - "integrity": "sha512-ejTIqwvPABwK7MtVmI2qWbEaMhhbHNsq0NPzl1hwLtkrLbjdDrEVv0Wy+gN0xqrT9NyCPl4AmNLz/xuYTzgU5g==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.10.tgz", + "integrity": "sha512-qW/F3XVZMzzenFzbn+7FGpw8GOt9qW8UxBtYya7gUNdWlcsgGUk+ZaGC2OLbfI5gX6pchW4TOPMsDSMeaCEI2Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.9", - "@angular-devkit/core": "18.2.9", - "@angular-devkit/schematics": "18.2.9", + "@angular-devkit/architect": "0.1802.10", + "@angular-devkit/core": "18.2.10", + "@angular-devkit/schematics": "18.2.10", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.9", + "@schematics/angular": "18.2.10", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -702,9 +708,9 @@ } }, "node_modules/@angular/common": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.8.tgz", - "integrity": "sha512-TYsKtE5nVaIScWSLGSO34Skc+s3hB/BujSddnfQHoNFvPT/WR0dfmdlpVCTeLj+f50htFoMhW11tW99PbK+whQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.9.tgz", + "integrity": "sha512-Opi6DVaU0aGyJqLk5jPmeYx559fp3afj4wuxM5aDzV4KEVGDVbNCpO0hMuwHZ6rtCjHhv1fQthgS48qoiQ6LKw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -713,14 +719,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.8", + "@angular/core": "18.2.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.8.tgz", - "integrity": "sha512-JRedHNfK1CCPVyeGQB5w3WBYqMA6X8Q240CkvjlGfn0pVXihf9DWk3nkSQJVgYxpvpHfxdgjaYZ5IpMzlkmkhw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.9.tgz", + "integrity": "sha512-fchbcbsyTOd/qHGy+yPEmE1p10OTNEjGrWHQzUbf3xdlm23EvxHTitHh8i6EBdwYnM5zz0IIBhltP8tt89oeYw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -729,7 +735,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.8" + "@angular/core": "18.2.9" }, "peerDependenciesMeta": { "@angular/core": { @@ -738,9 +744,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.8.tgz", - "integrity": "sha512-OksDE4LWQUCcIvMjtZF7eiDCdIMrcMMpC1+Q0PIYi7KmnqXFGs4/Y0NdJvtn/LrQznzz5WaKM3ZDVNZTRX4wmw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", + "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -761,7 +767,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.8", + "@angular/compiler": "18.2.9", "typescript": ">=5.4 <5.6" } }, @@ -794,9 +800,9 @@ } }, "node_modules/@angular/core": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.8.tgz", - "integrity": "sha512-NwIuX/Iby1jT6Iv1/s6S3wOFf8xfuQR3MPGvKhGgNtjXLbHG+TXceK9+QPZC0s9/Z8JR/hz+li34B79GrIKgUg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.9.tgz", + "integrity": "sha512-h9/Bzo/7LTPzzh9I/1Gk8TWOXPGeHt3jLlnYrCh2KbrWbTErNtW0V3ad5I3Zv+K2Z7RSl9Z3D3Y6ILH796N4ZA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -810,9 +816,9 @@ } }, "node_modules/@angular/forms": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.8.tgz", - "integrity": "sha512-JCLki7KC6D5vF6dE6yGlBmW33khIgpHs8N9SzuiJtkQqNDTIQA8cPsGV6qpLpxflxASynQOX5lDkWYdQyfm77Q==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.9.tgz", + "integrity": "sha512-yyN5dG60CXH6MRte8rv4aGUTeNOMz/pUV7rVxittpjN7tPHfGEL9Xz89Or90Aa1QiHuBmHFk+9A39s03aO1rDQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -821,16 +827,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.8", - "@angular/core": "18.2.8", - "@angular/platform-browser": "18.2.8", + "@angular/common": "18.2.9", + "@angular/core": "18.2.9", + "@angular/platform-browser": "18.2.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.8.tgz", - "integrity": "sha512-IueQ57CPP0Dt0z2n8B1A6JTwTq6m/AJVObZzrkSfXlzY1rY2qRuTJmAbZpTJ3iAxVzNYoaGh+NFHmJL8fRiXKQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.9.tgz", + "integrity": "sha512-vC9la5VpvfX27ept36rlc42nGxDak7YfbWtSoZUageyZJUWyIEAvW8rNNPEvoO86RLi011/HmyyIr2GSQLKvxA==", "dev": true, "license": "MIT", "engines": { @@ -838,9 +844,9 @@ } }, "node_modules/@angular/localize": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.8.tgz", - "integrity": "sha512-1T7aXEdgVyeYnHOfQUuIDO8Lsamg1ZLrJrA5zUv61asPJp6HCcMjXy9vDQ1XvHm5+CdDjKk/rczlN4lSMZ0QRw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.9.tgz", + "integrity": "sha512-CcqyVqV/GyyBe6Cndm2WRM5dyJwjDQ0F7QRGwO3jYWFSYF0h/f0ZjZVH4ra1IX+AwEEicOXW1ig3FBbeOqHPug==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -857,21 +863,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.8", - "@angular/compiler-cli": "18.2.8" + "@angular/compiler": "18.2.9", + "@angular/compiler-cli": "18.2.9" } }, "node_modules/@angular/material": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.9.tgz", - "integrity": "sha512-M2oCgPPIMMd6BLgEJCD+FvdC7gRDeCjj9yktNn3ctHmkKUWRvpJ3xRBH/WjVXb+9fPCCW1iNwZI7+bN1fHE7cA==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.10.tgz", + "integrity": "sha512-XZISsICpTOzq2qR9yUaWrAz9WZCAh/B457gq/ftkkiiafLwFCvbKur19FFUJO5GX+uVdo074133L85xreOkFFw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.9", + "@angular/cdk": "18.2.10", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -880,9 +886,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.8.tgz", - "integrity": "sha512-EPai4ZPqSq3ilLJUC85kPi9wo5j5suQovwtgRyjM/75D9Qy4TV19g8hkVM5Co/zrltO8a2G6vDscCNI5BeGw2A==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.9.tgz", + "integrity": "sha512-UNu6XjK0SV35FFe55yd1yefZI8tzflVKzev/RzC31XngrczhlH0+WCbae4rG1XJULzJwJ1R1p7gqq4+ktEczRQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -891,9 +897,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.8", - "@angular/common": "18.2.8", - "@angular/core": "18.2.8" + "@angular/animations": "18.2.9", + "@angular/common": "18.2.9", + "@angular/core": "18.2.9" }, "peerDependenciesMeta": { "@angular/animations": { @@ -902,9 +908,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.8.tgz", - "integrity": "sha512-poZoapDqyN/rxGKQ3C6esdPiPLMkSpP2v12hoEa12KHgfPk7T1e+a+NMyJjV8HeOY3WyvL7tGRhW0NPTajTkhw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.9.tgz", + "integrity": "sha512-cUTB8Jc3I/fu2UKv/PJmNGQGvKyyTo8ln4GUX3EJ4wUHzgkrU0s4x7DNok0Ql8FZKs5dLR8C0xVbG7Dv/ViPdw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -913,16 +919,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.8", - "@angular/compiler": "18.2.8", - "@angular/core": "18.2.8", - "@angular/platform-browser": "18.2.8" + "@angular/common": "18.2.9", + "@angular/compiler": "18.2.9", + "@angular/core": "18.2.9", + "@angular/platform-browser": "18.2.9" } }, "node_modules/@angular/router": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.8.tgz", - "integrity": "sha512-L+olYgxIiBq+tbfayVI0cv1yOuymsw33msnGC2l/vpc9sSVfqGzESFnB4yMVU3vHtE9v6v2Y6O+iV44/b79W/g==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.9.tgz", + "integrity": "sha512-D0rSrMf/sbhr5yQgz+LNBxdv1BR3S4pYDj1Exq6yVRKX8HSbjc5hxe/44VaOEKBh8StJ6GRiNOMoIcDt73Jang==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -931,16 +937,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.8", - "@angular/core": "18.2.8", - "@angular/platform-browser": "18.2.8", + "@angular/common": "18.2.9", + "@angular/core": "18.2.9", + "@angular/platform-browser": "18.2.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.8.tgz", - "integrity": "sha512-LQktgS2Hn845ASWNyjde18V+CHkkPeCzORfh0ChYKiOmXYFtj/myEik5o/QI/G13Kaymy+vcuwQKiUuZjZiD1w==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.9.tgz", + "integrity": "sha512-AIXp5D1zcRjUxZjJhWRjQFP5ZkCCjqOe53diiOuI0gHu8cwdGUUKeY2fwGb3XWOOgglwH0zKIk1Pqq/8dKAylQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -952,17 +958,17 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.8", - "@angular/core": "18.2.8" + "@angular/common": "18.2.9", + "@angular/core": "18.2.9" } }, "node_modules/@babel/code-frame": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", - "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.9.tgz", + "integrity": "sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.25.7", + "@babel/highlight": "^7.25.9", "picocolors": "^1.0.0" }, "engines": { @@ -970,9 +976,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", - "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.9.tgz", + "integrity": "sha512-yD+hEuJ/+wAJ4Ox2/rpNv5HIuPG82x3ZlQvYVn8iYCprdxzE7P1udpGF1jyjQVBU4dgznN+k2h103vxZ7NdPyw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1042,27 +1048,27 @@ } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.7.tgz", - "integrity": "sha512-12xfNeKNH7jubQNm7PAkzlLwEmCs1tfuX3UjIw6vP6QXi+leKh6+LyC/+Ed4EIQermwd58wsyh070yjDHFlNGg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", - "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1072,18 +1078,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.7.tgz", - "integrity": "sha512-bD4WQhbkx80mAyj/WCm4ZHcF4rDxkoLFO6ph8/5/mQ3z4vAzltQXAmbc7GvVJx5H+lk5Mi5EmbTeox5nMGCsbw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-member-expression-to-functions": "^7.25.7", - "@babel/helper-optimise-call-expression": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", - "@babel/traverse": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", "semver": "^6.3.1" }, "engines": { @@ -1094,26 +1100,26 @@ } }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz", - "integrity": "sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.25.9", "regexpu-core": "^6.1.1", "semver": "^6.3.1" }, @@ -1125,13 +1131,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1155,42 +1161,42 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.7.tgz", - "integrity": "sha512-O31Ssjd5K6lPbTX9AAYpSKrZmLeagt9uwschJd+Ixo6QiRyfpvgtVQp8qrDR9UNFjZ8+DO34ZkdrN+BnPXemeA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", - "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", - "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.9.tgz", + "integrity": "sha512-TvLZY/F3+GvdRYFZFyxMvnsKi+4oJdgZzU3BoGN9Uc2d9C6zfNwJcKKhjqLAhK8i46mv93jsO74fDh3ih6rpHA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1200,37 +1206,37 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.7.tgz", - "integrity": "sha512-VAwcwuYhv/AT+Vfr28c9y6SHzTan1ryqrydSTFGjU0uDJHw3uZ+PduI8plCLkRsDnqK2DMEDmwrOQRsK/Ykjng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", - "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.7.tgz", - "integrity": "sha512-kRGE89hLnPfcz6fTrlNU+uhgcwv0mBE4Gv3P9Ke9kLVJYpi4AMVVEElXvB5CabrPZW4nCM8P8UyyjrzCM0O2sw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-wrap-function": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1240,28 +1246,28 @@ } }, "node_modules/@babel/helper-remap-async-to-generator/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.7.tgz", - "integrity": "sha512-iy8JhqlUW9PtZkd4pHM96v6BdJ66Ba9yWSE4z0W4TvSZwLBPkyDsiIU3ENe4SmrzRBs76F7rQXTy1lYC49n6Lw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.7", - "@babel/helper-optimise-call-expression": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1271,27 +1277,27 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", - "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.7.tgz", - "integrity": "sha512-pPbNbchZBkPMD50K0p3JGcFMNLVUCuU/ABybm/PGNj4JiHrpmNyqqCphBk4i19xXtNV0JhldQJJtbSW5aUvbyA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1311,67 +1317,67 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", - "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", - "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.7.tgz", - "integrity": "sha512-MA0roW3JF2bD1ptAaJnvcabsVlNQShUaThyJbCDD4bCp8NEgiFvpoqRI2YS22hHlc2thjO/fTg2ShLMC3jygAg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", - "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.9.tgz", + "integrity": "sha512-oKWp3+usOJSzDZOucZUAMayhPz/xVjzymyDzUN8dk0Wd3RWMlGLXi07UCQ/CgQVb8LvXx3XBajJH4XGgkt7H7g==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", - "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.9", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -1381,12 +1387,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", - "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz", + "integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.8" + "@babel/types": "^7.25.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -1396,14 +1402,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.7.tgz", - "integrity": "sha512-UV9Lg53zyebzD1DwQoT9mzkEKa922LNUp5YkTJ6Uta0RbyXaQNUgcvSt7qIu1PpPzVb6rd10OVNTzkyBGeVmxQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1413,13 +1419,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.7.tgz", - "integrity": "sha512-GDDWeVLNxRIkQTnJn2pDOM1pkCgYdSqPeT1a9vh9yIqu2uzzgw1zcqEb+IJOhy+dTBMlNdThrDIksr2o09qrrQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1429,13 +1435,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.7.tgz", - "integrity": "sha512-wxyWg2RYaSUYgmd9MR0FyRGyeOMQE/Uzr1wzd/g5cf5bwi9A4v6HFdDm7y1MgDtod/fLOSTZY6jDgV0xU9d5bA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1445,15 +1451,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.7.tgz", - "integrity": "sha512-Xwg6tZpLxc4iQjorYsyGMyfJE7nP5MV8t/Ka58BgiA7Jw0fRqQNcANlLfdJ/yvBt9z9LD2We+BEkT7vLqZRWng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", - "@babel/plugin-transform-optional-chaining": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1463,14 +1469,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.7.tgz", - "integrity": "sha512-UVATLMidXrnH+GMUIuxq55nejlj02HP7F5ETyBONzP6G87fPBogG4CH6kxrSrdIuAjdwNO9VzyaYsrZPscWUrw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1574,13 +1580,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.7.tgz", - "integrity": "sha512-ZvZQRmME0zfJnDQnVBKYzHxXT7lYBB3Revz1GuS7oLXWMgqUPX4G+DDbT30ICClht9WKV34QVrZhSw6WdklwZQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.9.tgz", + "integrity": "sha512-4GHX5uzr5QMOOuzV0an9MFju4hKlm0OyePl/lHhcsTVae5t/IKVHnb8W67Vr6FuLlk5lPqLB7n7O+K5R46emYg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1632,12 +1638,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.7.tgz", - "integrity": "sha512-ruZOnKO+ajVL/MVx+PwNBPOkrnXTXoWMtte1MBpegfCArhqOe3Bj52avVj1huLLxNKYKXYaSxZ2F+woK1ekXfw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1757,13 +1763,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.7.tgz", - "integrity": "sha512-rR+5FDjpCHqqZN2bzZm18bVYGaejGq5ZkpVCJLXor/+zlSrSoc4KWcHI0URVWjl/68Dyr1uwZUz/1njycEAv9g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1790,13 +1796,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.7.tgz", - "integrity": "sha512-EJN2mKxDwfOUCPxMO6MUI58RN3ganiRAG/MS/S3HfB6QFNjroAMelQo/gybyYq97WerCBAZoyrAoW8Tzdq2jWg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1843,13 +1849,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.7.tgz", - "integrity": "sha512-xHttvIM9fvqW+0a3tZlYcZYSBpSWzGBFIt/sYG3tcdSzBB8ZeVgz2gBP7Df+sM0N1850jrviYSSeUuc+135dmQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1859,13 +1865,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.7.tgz", - "integrity": "sha512-ZEPJSkVZaeTFG/m2PARwLZQ+OG0vFIhPlKHK/JdIMy8DbRJ/htz6LRrTFtdzxi9EHmcwbNPAKDnadpNSIW+Aow==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1875,14 +1881,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.7.tgz", - "integrity": "sha512-mhyfEW4gufjIqYFo9krXHJ3ElbFLIze5IDp+wQTxoPd+mwFb1NxatNAwmv8Q8Iuxv7Zc+q8EkiMQwc9IhyGf4g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1892,14 +1898,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.8.tgz", - "integrity": "sha512-e82gl3TCorath6YLf9xUwFehVvjvfqFhdOo4+0iVIVju+6XOi5XHkqB3P2AXnSwoeTX0HBoXq5gJFtvotJzFnQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.9.tgz", + "integrity": "sha512-UIf+72C7YJ+PJ685/PpATbCz00XqiFEzHX5iysRwfvNT0Ko+FaXSvRgLytFSp8xUItrG9pFM/KoBBZDrY/cYyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1909,17 +1915,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.7.tgz", - "integrity": "sha512-9j9rnl+YCQY0IGoeipXvnk3niWicIB6kCsWRGLwX241qSXpbA4MKxtp/EdvFxsc4zI5vqfLxzOd0twIJ7I99zg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7", - "@babel/traverse": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", "globals": "^11.1.0" }, "engines": { @@ -1930,27 +1936,27 @@ } }, "node_modules/@babel/plugin-transform-classes/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.7.tgz", - "integrity": "sha512-QIv+imtM+EtNxg/XBKL3hiWjgdLjMOmZ+XzQwSgmBfKbfxUjBzGgVPklUuE55eq5/uVoh8gg3dqlrwR/jw3ZeA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/template": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1960,13 +1966,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.7.tgz", - "integrity": "sha512-xKcfLTlJYUczdaM1+epcdh1UGewJqr9zATgrNHcLBcV2QmfvPPEixo/sK/syql9cEmbr7ulu5HMFG5vbbt/sEA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1976,14 +1982,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.7.tgz", - "integrity": "sha512-kXzXMMRzAtJdDEgQBLF4oaiT6ZCU3oWHgpARnTKDAqPkDJ+bs3NrZb310YYevR5QlRo3Kn7dzzIdHbZm1VzJdQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1993,13 +1999,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.7.tgz", - "integrity": "sha512-by+v2CjoL3aMnWDOyCIg+yxU9KXSRa9tN6MbqggH5xvymmr9p4AMjYkNlQy4brMceBnUyHZ9G8RnpvT8wP7Cfg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2009,14 +2015,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.7.tgz", - "integrity": "sha512-HvS6JF66xSS5rNKXLqkk7L9c/jZ/cdIVIcoPVrnl8IsVpLggTjXs8OWekbLHs/VtYDDh5WXnQyeE3PPUGm22MA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2026,13 +2032,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.8.tgz", - "integrity": "sha512-gznWY+mr4ZQL/EWPcbBQUP3BXS5FwZp8RUOw06BaRn8tQLzN4XLIxXejpHN9Qo8x8jjBmAAKp6FoS51AgkSA/A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2042,14 +2048,14 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.7.tgz", - "integrity": "sha512-yjqtpstPfZ0h/y40fAXRv2snciYr0OAoMXY/0ClC7tm4C/nG5NJKmIItlaYlLbIVAWNfrYuy9dq1bE0SbX0PEg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2059,13 +2065,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.8.tgz", - "integrity": "sha512-sPtYrduWINTQTW7FtOy99VCTWp4H23UX7vYcut7S4CIMEXU+54zKX9uCoGkLsWXteyaMXzVHgzWbLfQ1w4GZgw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2075,14 +2081,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.7.tgz", - "integrity": "sha512-n/TaiBGJxYFWvpJDfsxSj9lEEE44BFM1EPGz4KEiTipTgkoFVVcCmzAL3qA7fdQU96dpo4gGf5HBx/KnDvqiHw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2092,15 +2098,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.7.tgz", - "integrity": "sha512-5MCTNcjCMxQ63Tdu9rxyN6cAWurqfrDZ76qvVPrGYdBxIj+EawuuxTu/+dgJlhK5eRz3v1gLwp6XwS8XaX2NiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2110,13 +2116,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.8.tgz", - "integrity": "sha512-4OMNv7eHTmJ2YXs3tvxAfa/I43di+VcF+M4Wt66c88EAED1RoGaf1D64cL5FkRpNL+Vx9Hds84lksWvd/wMIdA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2126,13 +2132,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.7.tgz", - "integrity": "sha512-fwzkLrSu2fESR/cm4t6vqd7ebNIopz2QHGtjoU+dswQo/P6lwAG04Q98lliE3jkz/XqnbGFLnUcE0q0CVUf92w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2142,13 +2148,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.8.tgz", - "integrity": "sha512-f5W0AhSbbI+yY6VakT04jmxdxz+WsID0neG7+kQZbCOjuyJNdL5Nn4WIBm4hRpKnUcO9lP0eipUhFN12JpoH8g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2158,13 +2164,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.7.tgz", - "integrity": "sha512-Std3kXwpXfRV0QtQy5JJcRpkqP8/wG4XL7hSKZmGlxPlDqmpXtEPRmhF7ztnlTCtUN3eXRUJp+sBEZjaIBVYaw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2174,14 +2180,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.7.tgz", - "integrity": "sha512-CgselSGCGzjQvKzghCvDTxKHP3iooenLpJDO842ehn5D2G5fJB222ptnDwQho0WjEvg7zyoxb9P+wiYxiJX5yA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2191,15 +2197,15 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.7.tgz", - "integrity": "sha512-L9Gcahi0kKFYXvweO6n0wc3ZG1ChpSFdgG+eV1WYZ3/dGbJK7vvk91FgGgak8YwRgrCuihF8tE/Xg07EkL5COg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2209,16 +2215,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.7.tgz", - "integrity": "sha512-t9jZIvBmOXJsiuyOwhrIGs8dVcD6jDyg2icw1VL4A/g+FnWyJKwUfSSU2nwJuMV2Zqui856El9u+ElB+j9fV1g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2228,14 +2234,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.7.tgz", - "integrity": "sha512-p88Jg6QqsaPh+EB7I9GJrIqi1Zt4ZBHUQtjw3z1bzEXcLh6GfPqzZJ6G+G1HBGKUNukT58MnKG7EN7zXQBCODw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2245,14 +2251,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.7.tgz", - "integrity": "sha512-BtAT9LzCISKG3Dsdw5uso4oV1+v2NlVXIIomKJgQybotJY3OwCwJmkongjHgwGKoZXd0qG5UZ12JUlDQ07W6Ow==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2262,13 +2268,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.7.tgz", - "integrity": "sha512-CfCS2jDsbcZaVYxRFo2qtavW8SpdzmBXC2LOI4oO0rP+JSRDxxF3inF4GcPsLgfb5FjkhXG5/yR/lxuRs2pySA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2278,13 +2284,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.8.tgz", - "integrity": "sha512-Z7WJJWdQc8yCWgAmjI3hyC+5PXIubH9yRKzkl9ZEG647O9szl9zvmKLzpbItlijBnVhTUf1cpyWBsZ3+2wjWPQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2294,13 +2300,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.8.tgz", - "integrity": "sha512-rm9a5iEFPS4iMIy+/A/PiS0QN0UyjPIeVvbU5EMZFKJZHt8vQnasbpo3T3EFcxzCeYO0BHfc4RqooCZc51J86Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2310,15 +2316,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.8.tgz", - "integrity": "sha512-LkUu0O2hnUKHKE7/zYOIjByMa4VRaV2CD/cdGz0AxU9we+VA3kDDggKEzI0Oz1IroG+6gUP6UmWEHBMWZU316g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/plugin-transform-parameters": "^7.25.7" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2328,14 +2334,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.7.tgz", - "integrity": "sha512-pWT6UXCEW3u1t2tcAGtE15ornCBvopHj9Bps9D2DsH15APgNVOTwwczGckX+WkAvBmuoYKRCFa4DK+jM8vh5AA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2345,13 +2351,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.8.tgz", - "integrity": "sha512-EbQYweoMAHOn7iJ9GgZo14ghhb9tTjgOc88xFgYngifx7Z9u580cENCV159M4xDh3q/irbhSjZVpuhpC2gKBbg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2361,14 +2367,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.8.tgz", - "integrity": "sha512-q05Bk7gXOxpTHoQ8RSzGSh/LHVB9JEIkKnk3myAWwZHnYiTGYtbdrYkIsS8Xyh4ltKf7GNUSgzs/6P2bJtBAQg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2378,13 +2384,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.7.tgz", - "integrity": "sha512-FYiTvku63me9+1Nz7TOx4YMtW3tWXzfANZtrzHhUZrz4d47EEtMQhzFoZWESfXuAMMT5mwzD4+y1N8ONAX6lMQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2394,14 +2400,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.7.tgz", - "integrity": "sha512-KY0hh2FluNxMLwOCHbxVOKfdB5sjWG4M183885FmaqWWiGMhRZq4DQRKH6mHdEucbJnyDyYiZNwNG424RymJjA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2411,15 +2417,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.8.tgz", - "integrity": "sha512-8Uh966svuB4V8RHHg0QJOB32QK287NBksJOByoKmHMp1TAobNniNalIkI2i5IPj5+S9NYCG4VIjbEuiSN8r+ow==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2429,26 +2435,26 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object/node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.7.tgz", - "integrity": "sha512-lQEeetGKfFi0wHbt8ClQrUSUMfEeI3MMm74Z73T9/kuz990yYVtfofjf3NuA42Jy3auFOpbjDyCSiIkTs1VIYw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2458,13 +2464,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.7.tgz", - "integrity": "sha512-mgDoQCRjrY3XK95UuV60tZlFCQGXEtMg8H+IsW72ldw1ih1jZhzYXbJvghmAEpg5UVhhnCeia1CkGttUvCkiMQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.9", "regenerator-transform": "^0.15.2" }, "engines": { @@ -2475,13 +2481,13 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.7.tgz", - "integrity": "sha512-3OfyfRRqiGeOvIWSagcwUTVk2hXBsr/ww7bLn6TRTuXnexA+Udov2icFOxFX9abaj4l96ooYkcNN1qi2Zvqwng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2512,13 +2518,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.7.tgz", - "integrity": "sha512-uBbxNwimHi5Bv3hUccmOFlUy3ATO6WagTApenHz9KzoIdn0XeACdB12ZJ4cjhuB2WSi80Ez2FWzJnarccriJeA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2528,14 +2534,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.7.tgz", - "integrity": "sha512-Mm6aeymI0PBh44xNIv/qvo8nmbkpZze1KvR8MkEqbIREDxoiWTi18Zr2jryfRMwDfVZF9foKh060fWgni44luw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2545,13 +2551,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.7.tgz", - "integrity": "sha512-ZFAeNkpGuLnAQ/NCsXJ6xik7Id+tHuS+NT+ue/2+rn/31zcdnupCdmunOizEaP0JsUmTFSTOPoQY7PkK2pttXw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2561,13 +2567,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.7.tgz", - "integrity": "sha512-SI274k0nUsFFmyQupiO7+wKATAmMFf8iFgq2O+vVFXZ0SV9lNfT1NGzBEhjquFmD8I9sqHLguH+gZVN3vww2AA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2577,13 +2583,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.7.tgz", - "integrity": "sha512-OmWmQtTHnO8RSUbL0NTdtpbZHeNTnm68Gj5pA4Y2blFNh+V4iZR68V1qL9cI37J21ZN7AaCnkfdHtLExQPf2uA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2593,13 +2599,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.7.tgz", - "integrity": "sha512-BN87D7KpbdiABA+t3HbVqHzKWUDN3dymLaTnPFAMyc8lV+KN3+YzNhVRNdinaCPA4AUqx7ubXbQ9shRjYBl3SQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2609,14 +2615,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.7.tgz", - "integrity": "sha512-IWfR89zcEPQGB/iB408uGtSPlQd3Jpq11Im86vUgcmSTcoWAiQMCTOa2K2yNNqFJEBVICKhayctee65Ka8OB0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2626,14 +2632,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.7.tgz", - "integrity": "sha512-8JKfg/hiuA3qXnlLx8qtv5HWRbgyFx2hMMtpDDuU2rTckpKkGu4ycK5yYHwuEa16/quXfoxHBIApEsNyMWnt0g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2643,14 +2649,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.7.tgz", - "integrity": "sha512-YRW8o9vzImwmh4Q3Rffd09bH5/hvY0pxg+1H1i0f7APoUeg12G7+HhLj9ZFNIrYkgBXhIijPJ+IXypN0hLTIbw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2785,30 +2791,30 @@ } }, "node_modules/@babel/template": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", - "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", - "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7", + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2817,12 +2823,12 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", - "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.9.tgz", + "integrity": "sha512-omlUGkr5EaoIJrhLf9CJ0TvjBRpd9+AXRG//0GEQ9THSo8wPiTlbpy1/Ow8ZTrbXpjd9FHXfbFQx32I04ht0FA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7", + "@babel/types": "^7.25.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -2844,14 +2850,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", - "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz", + "integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -3456,9 +3461,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", - "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3551,9 +3556,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.12.0.tgz", - "integrity": "sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", "dev": true, "license": "MIT", "engines": { @@ -3571,9 +3576,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", - "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.1.tgz", + "integrity": "sha512-HFZ4Mp26nbWk9d/BpvP0YNL6W4UoZF0VFcTw/aPPA8RpOxeFQgK+ClABGgAUXs9Y/RGX/l1vOmrqz1MQt9MNuw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5131,9 +5136,9 @@ ] }, "node_modules/@ls1intum/apollon": { - "version": "3.3.14", - "resolved": "https://registry.npmjs.org/@ls1intum/apollon/-/apollon-3.3.14.tgz", - "integrity": "sha512-XN6M72Oeuw7Dv1ZLkU6wZVFcCuYIZXWKNH5ZG9+QraCdeaihbBADhaX7AY89LUAnjNMq0WmO5evb54RcfobxAw==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/@ls1intum/apollon/-/apollon-3.3.15.tgz", + "integrity": "sha512-pr6KtXhNKLNiAE/dmlzq16/cnbw2RWAzFaEUYGBXlPchrlbyE39zWqBHgtL8t/UoRdvSmEwFgxi9eJ24hFME2g==", "license": "MIT", "dependencies": { "fast-json-patch": "3.1.1", @@ -5262,6 +5267,12 @@ "node": ">=6" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -5376,9 +5387,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.9.tgz", - "integrity": "sha512-/apDvs4qevjSWoYw3h3/c/mILFrf2EgCJfBy9f3E7PEgi2tjifOIszBRrLQkVpeHAaFgEH8zKS2ol0hAmOl8sw==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.10.tgz", + "integrity": "sha512-CGYr8rdM5ntdb4kLUAhrLBPrhJQ4KBPo3KMT6qJE/S+jJJn5zHzedpuGFOCVhC1Siw+n1pOBSI8leTRJIW/eCQ==", "dev": true, "license": "MIT", "engines": { @@ -5708,23 +5719,23 @@ } }, "node_modules/@nrwl/devkit": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-19.8.4.tgz", - "integrity": "sha512-OoIqDjj2mWzLs3aSF6w5OiC2xywYi/jBxHc7t7Lyi56Vc4dQq8vJMELa9WtG6qH0k05fF7N+jAoKlfvLgbbEFA==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-19.8.6.tgz", + "integrity": "sha512-F6+4Lv2hSS+02H7aqa+jYIHzbmip7082DF9/NkNtUAEqLUi8STsbung0nchaR1Tjg20E+BZujEsZgTC3GJegLQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "19.8.4" + "@nx/devkit": "19.8.6" } }, "node_modules/@nrwl/tao": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-19.8.4.tgz", - "integrity": "sha512-03/+QZ4/6HmKbEmvzCutLI1XIclBspNYtiVHmGPRWuwhnZViqYfnyl8J7RWVdFEoKKA5fhJqpg7e28aGuoMBvQ==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-19.8.6.tgz", + "integrity": "sha512-ibxGL7aDpNARgPegXQ8HAocemZ1WvZE5+NHkXDs7jSmnSt9qaXIKE1dXotDTqp3TqCirlje1/RMMTqzCl2oExQ==", "dev": true, "license": "MIT", "dependencies": { - "nx": "19.8.4", + "nx": "19.8.6", "tslib": "^2.3.0" }, "bin": { @@ -5732,13 +5743,13 @@ } }, "node_modules/@nx/devkit": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-19.8.4.tgz", - "integrity": "sha512-FPFT8gVDFRSEmU0n7nRkT4Rnqy7OMznfPXLfDZtVuzEi5Cl6ftG3UBUvCgJcJFCYJVAZAUuv6vRSRarHd51XFQ==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-19.8.6.tgz", + "integrity": "sha512-8NAdnqwzki3srj2sAImWQ9cQiq79NqwqVqx/XOdg0XHR6siugn+sAAXWpM3xJVdv4uRbcyz7BO1GWYxMW0AOYA==", "dev": true, "license": "MIT", "dependencies": { - "@nrwl/devkit": "19.8.4", + "@nrwl/devkit": "19.8.6", "ejs": "^3.1.7", "enquirer": "~2.3.6", "ignore": "^5.0.4", @@ -5749,7 +5760,7 @@ "yargs-parser": "21.1.1" }, "peerDependencies": { - "nx": ">= 17 <= 20" + "nx": ">= 19 <= 21" } }, "node_modules/@nx/devkit/node_modules/minimatch": { @@ -5779,9 +5790,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-19.8.4.tgz", - "integrity": "sha512-mbSGt63hYcVCSQ54kpHl0lFqr5CsbkGJ4L3liWE30Da7vXZJwUBr9f+b9DnQ64IZzlu6vAhNcaiYQXa9lAk0yQ==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-19.8.6.tgz", + "integrity": "sha512-lzFV07gUgvy07lPtRFJFhlQdcR0qNTPPq7/ZB+3alwUIDdAn706ZVzf6apCJWOBIgNFKbAQiy/du0zmuKPSzXA==", "cpu": [ "arm64" ], @@ -5796,9 +5807,9 @@ } }, "node_modules/@nx/nx-darwin-x64": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-19.8.4.tgz", - "integrity": "sha512-lTcXUCXNvqHdLmrNCOyDF+u6pDx209Ew7nSR47sQPvkycIHYi0gvgk0yndFn1Swah0lP4OxWg7rzAfmOlZd6ew==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-19.8.6.tgz", + "integrity": "sha512-1ZmOXwJva14jCcTHM8jmsEBp33CCLng/tXK8/554ACwL3Kk4kbtdLfUjM/VEMZ3v3c1D7cJWxyYfTav5meumxg==", "cpu": [ "x64" ], @@ -5813,9 +5824,9 @@ } }, "node_modules/@nx/nx-freebsd-x64": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-19.8.4.tgz", - "integrity": "sha512-4BUplOxPZeUwlUNfzHHMmebNVgDFW/jNX6TWRS+jINwOHnpWLkLFAXu27G80/S3OaniVCzEQklXO9b+1UsdgXw==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-19.8.6.tgz", + "integrity": "sha512-1a681ZqSS05H1pC6JG3ae0BLhnxGtISkCigl9R6W5NeyFLBgP+Y4BLh+H9cCAlKzzLwiKWWRmhbxvjpnlhzB+w==", "cpu": [ "x64" ], @@ -5830,9 +5841,9 @@ } }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-19.8.4.tgz", - "integrity": "sha512-Wahul8oz9huEm/Jv3wud5IGWdZxkGG4tdJm9i5TV5wxfUMAWbKU9v2nzZZins452UYESWvwvDkiuBPZqSto3qw==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-19.8.6.tgz", + "integrity": "sha512-qGztEgbEjMsFr9IjedQXJNmXLHCpSldW/sEtXoVZ8tXIzGr86GXbv+mLdZSZHrlJaNOq0y2K6XpVd2UH4ndwnQ==", "cpu": [ "arm" ], @@ -5847,9 +5858,9 @@ } }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-19.8.4.tgz", - "integrity": "sha512-L0RVCZkNAtZDplLT7uJV7M9cXxq2Fxw+8ex3eb9XSp7eyLeFO21T0R6vTouJ42E/PEvGApCAcyGqtnyPNMZFfw==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-19.8.6.tgz", + "integrity": "sha512-rSwsEISx5odXkg1kjXBZ6kjXCnM3fnAA+8YU1muRr7PmhUfM/zuCnNYcwmjtCRc7rRYBKzxmyE3T95fGK/NOIg==", "cpu": [ "arm64" ], @@ -5864,9 +5875,9 @@ } }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-19.8.4.tgz", - "integrity": "sha512-0q8r8I8WCsY3xowDI2j109SCUSkFns/BJ40aCfRh9hhrtaIIc5qXUw2YFTjxUZNcRJXx9j9+hTe9jBkUSIGvCw==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-19.8.6.tgz", + "integrity": "sha512-7rW21+uFj5KJx3z/HXhl6PUcp8+mQ8r/nUGbS59HjmMdVMZDd7PZKUVJF9Tu1ESproOCYSeJbOVk4WGiHtbF9Q==", "cpu": [ "arm64" ], @@ -5881,9 +5892,9 @@ } }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-19.8.4.tgz", - "integrity": "sha512-XcRBNe0ws7KB0PMcUlpQqzzjjxMP8VdqirBz7CfB2XQ8xKmP3370p0cDvqs/4oKDHK4PCkmvVFX60tzakutylA==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-19.8.6.tgz", + "integrity": "sha512-2/5WDr2wwWyvbqlB//ICWS5q3rRF4GyNX2NOp/tVkmh1RfDhH0ZAVZ/oJ7QvE1mKLQh0AM7bQBHsF5ikmMhUXw==", "cpu": [ "x64" ], @@ -5898,9 +5909,9 @@ } }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-19.8.4.tgz", - "integrity": "sha512-JB4tAuZBCF0yqSnKF3pHXa0b7LA3ebi3Bw08QmMr//ON4aU+eXURGBuj9XvULD2prY+gpBrvf+MsG1XJAHL6Zg==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-19.8.6.tgz", + "integrity": "sha512-G3UIMk+C090WR/btOaJCrBgRa7gjTj6ZBHinFceO7rii8r3D1SiN5cW1Njd1pV2K7IjJaSTuRtd9c1eLcIj9rQ==", "cpu": [ "x64" ], @@ -5915,9 +5926,9 @@ } }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-19.8.4.tgz", - "integrity": "sha512-WvQag/pN9ofRWRDvOZxj3jvJoTetlvV1uyirnDrhupRgi+Fj67OlGGt2zVUHaXFGEa1MfCEG6Vhk6152m4KyaQ==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-19.8.6.tgz", + "integrity": "sha512-8dfUstJkN2ChbIcj3TfcHgWyJy0b9za+3gU9IvZm82P9EeDCjEGoE/ld9VALGa+2UnX2Ve5BqlWGTD8BqYTeCA==", "cpu": [ "arm64" ], @@ -5932,9 +5943,9 @@ } }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-19.8.4.tgz", - "integrity": "sha512-//JntLrN3L7WL/WgP3D0FE34caYTPcG/GIMBguC9w7YDyTlEikLgLbobjdCPz+2f9OWGvIZbJgGmtHNjnETM/g==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-19.8.6.tgz", + "integrity": "sha512-kbWDZGD9kwP60UykTnfMR1hOUMDK0evXb5EnF4MAf4o18+b5KSzHyaL2TyNl+3s6lYdtZ2kYC679R+eJErKG8w==", "cpu": [ "x64" ], @@ -6570,14 +6581,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.9", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.9.tgz", - "integrity": "sha512-LlMHZQ6f8zrqSK24OBXi4u2MTNHNu9ZN6JXpbElq0bz/9QkUR2zy+Kk2wLpPxCwXYTZby7/xgHiTzXvG+zTdhw==", + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.10.tgz", + "integrity": "sha512-2pDHT4aSzfs8Up4RQmHHuFd5FeuUebS1ZJwyt46MfXzRMFtzUZV/JKsIvDqyMwnkvFfLvgJyTCkl8JGw5jQObg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.9", - "@angular-devkit/schematics": "18.2.9", + "@angular-devkit/core": "18.2.10", + "@angular-devkit/schematics": "18.2.10", "jsonc-parser": "3.3.1" }, "engines": { @@ -6587,73 +6598,73 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.34.0.tgz", - "integrity": "sha512-4AcYOzPzD1tL5eSRQ/GpKv5enquZf4dMVUez99/Bh3va8qiJrNP55AcM7UzZ7WZLTqKygIYruJTU5Zu2SpEAPQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.35.0.tgz", + "integrity": "sha512-uj9nwERm7HIS13f/Q52hF/NUS5Al8Ma6jkgpfYGeppYvU0uSjPkwMogtqoJQNbOoZg973tV8qUScbcWY616wNA==", "license": "MIT", "dependencies": { - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.34.0.tgz", - "integrity": "sha512-aYSM2KPUs0FLPxxbJCFSwCYG70VMzlT04xepD1Y/tTlPPOja/02tSv2tyOdZbv8Uw7xslZs3/8Lhj74oYcTBxw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.35.0.tgz", + "integrity": "sha512-7bjSaUhL0bDArozre6EiIhhdWdT/1AWNWBC1Wc5w1IxEi5xF7nvF/FfvjQYrONQzZAI3HRxc45J2qhLUzHBmoQ==", "license": "MIT", "dependencies": { - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.34.0.tgz", - "integrity": "sha512-EoMh9NYljNewZK1quY23YILgtNdGgrkzJ9TPsj6jXUG0LZ0Q7N7eFWd0xOEDBvFxrmI3cSXF1i4d1sBb+eyKRw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.35.0.tgz", + "integrity": "sha512-3wkW03vXYMyWtTLxl9yrtkV+qxbnKFgfASdoGWhXzfLjycgT6o4/04eb3Gn71q9aXqRwH17ISVQbVswnRqMcmA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.34.0", - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry-internal/browser-utils": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.34.0.tgz", - "integrity": "sha512-x8KhZcCDpbKHqFOykYXiamX6x0LRxv6N1OJHoH+XCrMtiDBZr4Yo30d/MaS6rjmKGMtSRij30v+Uq+YWIgxUrg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.35.0.tgz", + "integrity": "sha512-TUrH6Piv19kvHIiRyIuapLdnuwxk/Un/l1WDCQfq7mK9p1Pac0FkQ7Uufjp6zY3lyhDDZQ8qvCS4ioCMibCwQg==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.34.0", - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry-internal/replay": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.34.0.tgz", - "integrity": "sha512-FjBN5s+SFzTFHQh5DqWUGUp19p3V7p86I7Dq1a7MBCzmQukGM1bcW8+n6wLj6CxlEoyLCPPZpTIXIO4ulheIwg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.35.0.tgz", + "integrity": "sha512-mHbmvt8R79TvVSidshdKgKDE6GMii3rHjBBJmdwfBpRPmr28/XsrcheX6IOooePzyJucEcjkYCkrtHlHGs4kzg==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.34.0", - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0", + "@sentry/browser": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0", "tslib": "^2.4.1" }, "engines": { @@ -6667,52 +6678,52 @@ } }, "node_modules/@sentry/browser": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.34.0.tgz", - "integrity": "sha512-3HHG2NXxzHq1lVmDy2uRjYjGNf9NsJsTPlOC70vbQdOb+S49EdH/XMPy+J3ruIoyv6Cu0LwvA6bMOM6rHZOgNQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.35.0.tgz", + "integrity": "sha512-WHfI+NoZzpCsmIvtr6ChOe7yWPLQyMchPnVhY3Z4UeC70bkYNdKcoj/4XZbX3m0D8+71JAsm0mJ9s9OC3Ue6MQ==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.34.0", - "@sentry-internal/feedback": "8.34.0", - "@sentry-internal/replay": "8.34.0", - "@sentry-internal/replay-canvas": "8.34.0", - "@sentry/core": "8.34.0", - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry-internal/browser-utils": "8.35.0", + "@sentry-internal/feedback": "8.35.0", + "@sentry-internal/replay": "8.35.0", + "@sentry-internal/replay-canvas": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.34.0.tgz", - "integrity": "sha512-adrXCTK/zsg5pJ67lgtZqdqHvyx6etMjQW3P82NgWdj83c8fb+zH+K79Z47pD4zQjX0ou2Ws5nwwi4wJbz4bfA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.35.0.tgz", + "integrity": "sha512-Ci0Nmtw5ETWLqQJGY4dyF+iWh7PWKy6k303fCEoEmqj2czDrKJCp7yHBNV0XYbo00prj2ZTbCr6I7albYiyONA==", "license": "MIT", "dependencies": { - "@sentry/types": "8.34.0", - "@sentry/utils": "8.34.0" + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.34.0.tgz", - "integrity": "sha512-zLRc60CzohGCo6zNsNeQ9JF3SiEeRE4aDCP9fDDdIVCOKovS+mn1rtSip0qd0Vp2fidOu0+2yY0ALCz1A3PJSQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.35.0.tgz", + "integrity": "sha512-AVEZjb16MlYPifiDDvJ19dPQyDn0jlrtC1PHs6ZKO+Rzyz+2EX2BRdszvanqArldexPoU1p5Bn2w81XZNXThBA==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.34.0.tgz", - "integrity": "sha512-W1KoRlFUjprlh3t86DZPFxLfM6mzjRzshVfMY7vRlJFymBelJsnJ3A1lPeBZM9nCraOSiw6GtOWu6k5BAkiGIg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-MdMb6+uXjqND7qIPWhulubpSeHzia6HtxeJa8jYI09OCvIcmNGPydv/Gx/LZBwosfMHrLdTWcFH7Y7aCxrq7cg==", "license": "MIT", "dependencies": { - "@sentry/types": "8.34.0" + "@sentry/types": "8.35.0" }, "engines": { "node": ">=14.18" @@ -7185,6 +7196,12 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/emoji-js": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@types/emoji-js/-/emoji-js-3.5.2.tgz", + "integrity": "sha512-qPR85yjSPk2UEbdjYYNHfcOjVod7DCARSrJlPcL+cwaDFwdnmOFhPyYUvP5GaW0YZEy8mU93ZjTNgsVWz1zzlg==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -7206,9 +7223,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.0.tgz", - "integrity": "sha512-AbXMTZGt40T+KON9/Fdxx0B2WK5hsgxcfXJLr5bFpZ7b4JCex2WyQPTEKdXqfHiY5nKKBScZ7yCoO6Pvgxfvnw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", + "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", "dev": true, "license": "MIT", "dependencies": { @@ -7296,9 +7313,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7325,10 +7342,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", + "integrity": "sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==", "dev": true, "license": "MIT" }, @@ -7342,6 +7366,24 @@ "@types/lodash": "*" } }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7360,9 +7402,9 @@ } }, "node_modules/@types/node": { - "version": "22.7.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz", - "integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==", + "version": "22.7.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", + "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", "dev": true, "license": "MIT", "dependencies": { @@ -7380,9 +7422,9 @@ } }, "node_modules/@types/papaparse": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", - "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7410,9 +7452,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", - "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -7466,13 +7508,6 @@ "@types/send": "*" } }, - "node_modules/@types/showdown": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz", - "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/smoothscroll-polyfill": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@types/smoothscroll-polyfill/-/smoothscroll-polyfill-0.3.4.tgz", @@ -7518,6 +7553,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/turndown": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", + "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", @@ -7566,17 +7608,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", - "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.10.0", - "@typescript-eslint/type-utils": "8.10.0", - "@typescript-eslint/utils": "8.10.0", - "@typescript-eslint/visitor-keys": "8.10.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7600,16 +7642,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.10.0.tgz", - "integrity": "sha512-E24l90SxuJhytWJ0pTQydFT46Nk0Z+bsLKo/L8rtQSL93rQ6byd1V/QbDpHUTdLPOMsBCcYXZweADNCfOCmOAg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.10.0", - "@typescript-eslint/types": "8.10.0", - "@typescript-eslint/typescript-estree": "8.10.0", - "@typescript-eslint/visitor-keys": "8.10.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { @@ -7629,14 +7671,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", - "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.10.0", - "@typescript-eslint/visitor-keys": "8.10.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7647,14 +7689,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", - "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.10.0", - "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7672,9 +7714,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", - "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "license": "MIT", "engines": { @@ -7686,14 +7728,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", - "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.10.0", - "@typescript-eslint/visitor-keys": "8.10.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7715,16 +7757,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", - "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.10.0", - "@typescript-eslint/types": "8.10.0", - "@typescript-eslint/typescript-estree": "8.10.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7738,13 +7780,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", - "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -7774,6 +7816,15 @@ "integrity": "sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==", "license": "CC-BY-4.0" }, + "node_modules/@vscode/markdown-it-katex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vscode/markdown-it-katex/-/markdown-it-katex-1.1.0.tgz", + "integrity": "sha512-9cF2eJpsJOEs2V1cCAoJW/boKz9GQQLvZhNvI030K90z6ZE9lRGc9hDVvKut8zdFO2ObjwylPXXXVYvTdP2O2Q==", + "license": "MIT", + "dependencies": { + "katex": "^0.16.4" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -8038,10 +8089,20 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", + "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", "dev": true, "license": "MIT", "bin": { @@ -8323,17 +8384,16 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" + "engines": { + "node": ">= 0.4" } }, "node_modules/array-flatten": { @@ -8932,9 +8992,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", - "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "funding": [ { "type": "opencollective", @@ -8951,10 +9011,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001663", - "electron-to-chromium": "^1.5.28", + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -9146,9 +9206,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001668", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", - "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", "funding": [ { "type": "opencollective", @@ -9477,6 +9537,19 @@ "node": ">=6" } }, + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10620,16 +10693,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -10862,9 +10925,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.36.tgz", - "integrity": "sha512-HYTX8tKge/VNp6FGO+f/uVDmUkq+cEfcxYhKf15Akc4M5yxt5YmorwlAitKWjWhWQnKcDRBAQKXkhqqXMqcrjw==", + "version": "1.5.45", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.45.tgz", + "integrity": "sha512-vOzZS6uZwhhbkZbcRyiy99Wg+pYFV5hk+5YaECvx0+Z31NR3Tt5zS6dze2OepT6PCTzVzT0dIJItti+uAW5zmw==", "license": "ISC" }, "node_modules/emittery": { @@ -10880,6 +10943,19 @@ "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "node_modules/emoji-datasource": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/emoji-datasource/-/emoji-datasource-15.0.1.tgz", + "integrity": "sha512-aF5Q6LCKXzJzpG4K0ETiItuzz0xLYxNexR9qWw45/shuuEDWZkOIbeGHA23uopOSYA/LmeZIXIFsySCx+YKg2g==" + }, + "node_modules/emoji-js": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/emoji-js/-/emoji-js-3.8.0.tgz", + "integrity": "sha512-A5FNHKlRPRo6RJWrrdGWnoolIBMkVXHy4qkO0V5ahekQPjfVECxvOOWADeAF/SbzRVA9Sxdj24FCoRYGt06skA==", + "dependencies": { + "emoji-datasource": "15.0.1" + } + }, "node_modules/emoji-regex": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", @@ -10973,7 +11049,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -11145,18 +11220,18 @@ } }, "node_modules/eslint": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz", - "integrity": "sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", - "@eslint/core": "^0.6.0", + "@eslint/core": "^0.7.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.12.0", + "@eslint/js": "9.13.0", "@eslint/plugin-kit": "^0.2.0", "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", @@ -12170,11 +12245,11 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", - "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.17.1", @@ -12674,9 +12749,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true, "license": "MIT", "engines": { @@ -12953,15 +13028,6 @@ "node": ">= 0.4" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/highlight.js": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz", @@ -13025,17 +13091,6 @@ "integrity": "sha512-9SQg9oLQSAOZb8rO17mRNPkVB95QRh6iLY5J0Dbc/cgeoBT+XJBK/6XrQqfd+vxUVRjdctW+sfgYqgYzi0vg9g==", "license": "ISC" }, - "node_modules/html-encoder-decoder": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/html-encoder-decoder/-/html-encoder-decoder-1.3.10.tgz", - "integrity": "sha512-18SjgzQZ9U1mxb96rjcWgWMnTlEzNj2lU2wAU7OeUobdIWXTS6lOGc6419eLhMlX24sNQYDyQfgkSXWjyq/Ilg==", - "license": "MIT", - "dependencies": { - "he": "^1.1.0", - "iterate-object": "^1.3.2", - "regex-escape": "^3.4.2" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -13160,18 +13215,18 @@ } }, "node_modules/http-proxy-middleware": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.0.tgz", - "integrity": "sha512-36AV1fIaI2cWRzHo+rbcxhe3M3jUDCNzc4D5zRl57sEWRAxdXYtw7FSQKYY6PDKssiAKjLYypbssHk+xs/kMXw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.3.tgz", + "integrity": "sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg==", "dev": true, "license": "MIT", "dependencies": { - "@types/http-proxy": "^1.17.10", - "debug": "^4.3.4", + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.5" + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -13694,14 +13749,11 @@ } }, "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, "engines": { "node": ">=0.10.0" } @@ -13902,12 +13954,6 @@ "node": ">=8" } }, - "node_modules/iterate-object": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/iterate-object/-/iterate-object-1.3.4.tgz", - "integrity": "sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw==", - "license": "MIT" - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -16311,6 +16357,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lint-staged": { "version": "15.2.10", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", @@ -16973,12 +17028,50 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-class": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-class/-/markdown-it-class-1.0.0.tgz", + "integrity": "sha512-CVDYqSgmErLAqInwWu8WmAR2nX6MMIBIt8LB6qg8DNldca9+aoC6ZyuY0lvBMsaTSHNFJRkcHVR1XjLw9nr9qQ==", + "license": "MIT" + }, + "node_modules/markdown-it-highlightjs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.2.0.tgz", + "integrity": "sha512-NC7pXE8KkOl6xWJVRNt8p6wgJVznXKsE0HgYGdk6DD2tn1l4L9f0ALf3VIoGVkotNU1uGQatSxfBF1zZPUMmuQ==", + "license": "Unlicense", + "dependencies": { + "highlight.js": "^11.9.0" + } + }, "node_modules/material-colors": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", "license": "ISC" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -17565,9 +17658,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "license": "MIT", "engines": { @@ -18057,15 +18150,15 @@ "license": "MIT" }, "node_modules/nx": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/nx/-/nx-19.8.4.tgz", - "integrity": "sha512-fc833c3UKo6kuoG4z0kSKet17yWym3VzcQ+yPWYspxxxd8GFVVk42+9wieyVQDi9YqtKZQ6PdQfSEPm59/M7SA==", + "version": "19.8.6", + "resolved": "https://registry.npmjs.org/nx/-/nx-19.8.6.tgz", + "integrity": "sha512-VkEbXoCil4UnSDOJP5OcIKZgI13hKsFlQNf6oKhUHCYWoEHvVqpvabMv/ZY9mGG78skvqAorzn85BS3evlt0Cw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", - "@nrwl/tao": "19.8.4", + "@nrwl/tao": "19.8.6", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "3.0.0-rc.46", "@zkochan/js-yaml": "0.0.7", @@ -18104,16 +18197,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "19.8.4", - "@nx/nx-darwin-x64": "19.8.4", - "@nx/nx-freebsd-x64": "19.8.4", - "@nx/nx-linux-arm-gnueabihf": "19.8.4", - "@nx/nx-linux-arm64-gnu": "19.8.4", - "@nx/nx-linux-arm64-musl": "19.8.4", - "@nx/nx-linux-x64-gnu": "19.8.4", - "@nx/nx-linux-x64-musl": "19.8.4", - "@nx/nx-win32-arm64-msvc": "19.8.4", - "@nx/nx-win32-x64-msvc": "19.8.4" + "@nx/nx-darwin-arm64": "19.8.6", + "@nx/nx-darwin-x64": "19.8.6", + "@nx/nx-freebsd-x64": "19.8.6", + "@nx/nx-linux-arm-gnueabihf": "19.8.6", + "@nx/nx-linux-arm64-gnu": "19.8.6", + "@nx/nx-linux-arm64-musl": "19.8.6", + "@nx/nx-linux-x64-gnu": "19.8.6", + "@nx/nx-linux-x64-musl": "19.8.6", + "@nx/nx-win32-arm64-msvc": "19.8.6", + "@nx/nx-win32-x64-msvc": "19.8.6" }, "peerDependencies": { "@swc-node/register": "^1.8.0", @@ -18843,13 +18936,13 @@ } }, "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.0.tgz", + "integrity": "sha512-ZkDsAOcxsUMZ4Lz5fVciOehNcJ+Gb8gTzcA4yl3wnc273BAybYWrQ+Ks/OjCjSEpjvQkDSeZbybK9qj2VHHdGA==", "devOptional": true, "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -19014,9 +19107,9 @@ "license": "MIT" }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -19332,9 +19425,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.174.2", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.174.2.tgz", - "integrity": "sha512-UgS7eRcDVvVz2XSJ09NMX8zBcdpFnPayfiWDNF3xEbJTsIu1GipkkYNrVlsWlq8U1PIrviNm6i0Dyq8daaxssw==", + "version": "1.176.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.176.0.tgz", + "integrity": "sha512-T5XKNtRzp7q6CGb7Vc7wAI76rWap9fiuDUPxPsyPBPDkreKya91x9RIsSapAVFafwD1AEin1QMczCmt9Le9BWw==", "license": "MIT", "dependencies": { "core-js": "^3.38.1", @@ -19344,9 +19437,9 @@ } }, "node_modules/preact": { - "version": "10.24.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.2.tgz", - "integrity": "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==", + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", "funding": { "type": "opencollective", @@ -19537,6 +19630,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -19888,12 +19990,6 @@ "@babel/runtime": "^7.8.4" } }, - "node_modules/regex-escape": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/regex-escape/-/regex-escape-3.4.10.tgz", - "integrity": "sha512-qEqf7uzW+iYcKNLMDFnMkghhQBnGdivT6KqVQyKsyjSWnoFyooXVnxrw9dtv3AFLnD6VBGXxtZGAQNFGFTnCqA==", - "license": "MIT" - }, "node_modules/regex-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", @@ -20370,9 +20466,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.80.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.2.tgz", - "integrity": "sha512-9wXY8cGBlUmoUoT+vwOZOFCiS+naiWVjqlreN9ar9PudXbGwlMTFwCR5K9kB4dFumJ6ib98wZyAObJKsWf1nAA==", + "version": "1.80.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.4.tgz", + "integrity": "sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w==", "dev": true, "license": "MIT", "dependencies": { @@ -20790,57 +20886,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/showdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", - "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", - "license": "MIT", - "dependencies": { - "commander": "^9.0.0" - }, - "bin": { - "showdown": "bin/showdown.js" - }, - "funding": { - "type": "individual", - "url": "https://www.paypal.me/tiviesantos" - } - }, - "node_modules/showdown-highlight": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/showdown-highlight/-/showdown-highlight-3.1.0.tgz", - "integrity": "sha512-wrTxtE63L/bpW5A2Uy/AO1gblXnNHK/cDL6LszECOoCdMJKWTj0/4n4I/pmqub+3H3KCPVDDvtXpCArnT/heFA==", - "license": "MIT", - "dependencies": { - "highlight.js": "^11.5.0", - "html-encoder-decoder": "^1.3.9", - "showdown": "^2.0.3" - } - }, - "node_modules/showdown-katex": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/showdown-katex/-/showdown-katex-0.6.0.tgz", - "integrity": "sha512-eEOipJjqMxRJ+e69WlA7XENhFZzKhNl12csey0iLd4QbLzGF61+FBxNPhEZFz9wICYTJNfyqNgLSqmm8Uj0fGA==", - "license": "MIT", - "dependencies": { - "katex": "^0.10.0" - }, - "engines": { - "node": "*" - }, - "peerDependencies": { - "showdown": "^1.4.3" - } - }, - "node_modules/showdown/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -21905,22 +21950,22 @@ "license": "MIT" }, "node_modules/tldts": { - "version": "6.1.51", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.51.tgz", - "integrity": "sha512-33lfQoL0JsDogIbZ8fgRyvv77GnRtwkNE/MOKocwUgPO1WrSfsq7+vQRKxRQZai5zd+zg97Iv9fpFQSzHyWdLA==", + "version": "6.1.54", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.54.tgz", + "integrity": "sha512-rDaL1t59gb/Lg0HPMUGdV1vAKLQcXwU74D26aMaYV4QW7mnMvShd1Vmkg3HYAPWx2JCTUmsrXt/Yl9eJ5UFBQw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.51" + "tldts-core": "^6.1.54" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.51", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.51.tgz", - "integrity": "sha512-bu9oCYYWC1iRjx+3UnAjqCsfrWNZV1ghNQf49b3w5xE8J/tNShHTzp5syWJfwGH+pxUgTTLUnzHnfuydW7wmbg==", + "version": "6.1.54", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.54.tgz", + "integrity": "sha512-5cc42+0G0EjYRDfIJHKraaT3I5kPm7j6or3Zh1T9sF+Ftj1T+isT4thicUyQQ1bwN7/xjHQIuY2fXCoXP8Haqg==", "dev": true, "license": "MIT" }, @@ -21944,15 +21989,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -22243,6 +22279,15 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -22361,6 +22406,12 @@ "typescript-compare": "^0.0.2" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -22610,9 +22661,9 @@ } }, "node_modules/vite": { - "version": "5.4.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", - "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, "license": "MIT", "dependencies": { @@ -23184,9 +23235,9 @@ "license": "MIT" }, "node_modules/web-vitals": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.3.tgz", - "integrity": "sha512-/CFAm1mNxSmOj6i0Co+iGFJ58OS4NRGVP+AWS/l509uIK5a1bSoIVaHz/ZumpHTfHSZBpgrJ+wjfpAOrTHok5Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, "node_modules/webcola": { diff --git a/package.json b/package.json index 0128ec0da13e..5927d8b13550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "7.6.2", + "version": "7.6.5", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.9", - "@angular/common": "18.2.8", - "@angular/compiler": "18.2.8", - "@angular/core": "18.2.8", - "@angular/forms": "18.2.8", - "@angular/localize": "18.2.8", - "@angular/material": "18.2.9", - "@angular/platform-browser": "18.2.8", - "@angular/platform-browser-dynamic": "18.2.8", - "@angular/router": "18.2.8", - "@angular/service-worker": "18.2.8", + "@angular/animations": "18.2.9", + "@angular/cdk": "18.2.10", + "@angular/common": "18.2.9", + "@angular/compiler": "18.2.9", + "@angular/core": "18.2.9", + "@angular/forms": "18.2.9", + "@angular/localize": "18.2.9", + "@angular/material": "18.2.10", + "@angular/platform-browser": "18.2.9", + "@angular/platform-browser-dynamic": "18.2.9", + "@angular/router": "18.2.9", + "@angular/service-worker": "18.2.9", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.5.1", @@ -32,15 +32,16 @@ "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", - "@ls1intum/apollon": "3.3.14", + "@ls1intum/apollon": "3.3.15", "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.34.0", + "@sentry/angular": "8.35.0", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", + "@vscode/markdown-it-katex": "1.1.0", "bootstrap": "5.3.3", "compare-versions": "6.1.1", "core-js": "3.38.1", @@ -48,6 +49,7 @@ "dayjs": "1.11.13", "diff-match-patch-typescript": "1.1.0", "dompurify": "3.1.7", + "emoji-js": "3.8.0", "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", "franc-min": "6.2.0", @@ -57,23 +59,24 @@ "js-video-url-parser": "0.5.1", "jszip": "3.10.1", "lodash-es": "4.17.21", + "markdown-it": "14.1.0", + "markdown-it-class": "1.0.0", + "markdown-it-highlightjs": "4.2.0", "mobile-drag-drop": "3.0.0-rc.0", "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.174.2", + "posthog-js": "1.176.0", "rxjs": "7.8.1", - "showdown": "2.1.0", - "showdown-highlight": "3.1.0", - "showdown-katex": "0.6.0", "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", "tslib": "2.8.0", + "turndown": "7.2.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -88,7 +91,7 @@ "d3-transition": "^3.0.1" }, "@typescript-eslint/utils": { - "eslint": "^9.12.0" + "eslint": "^9.13.0" }, "braces": "3.0.3", "cookie": "1.0.1", @@ -98,19 +101,15 @@ "eslint": "^9.12.0" }, "eslint-plugin-jest": { - "@typescript-eslint/eslint-plugin": "^8.10.0" + "@typescript-eslint/eslint-plugin": "^8.11.0" }, "express": "5.0.1", "jsdom": "25.0.1", - "katex": "0.16.11", "postcss": "8.4.47", "rimraf": "6.0.1", "semver": "7.6.3", - "showdown-katex": { - "showdown": "2.1.0" - }, "tough-cookie": "5.0.0", - "vite": "5.4.9", + "vite": "5.4.10", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.1.0", "word-wrap": "1.2.5", @@ -119,30 +118,32 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.9", - "@angular-eslint/builder": "18.3.1", - "@angular-eslint/eslint-plugin": "18.3.1", - "@angular-eslint/eslint-plugin-template": "18.3.1", - "@angular-eslint/schematics": "18.3.1", - "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.9", - "@angular/compiler-cli": "18.2.8", - "@angular/language-service": "18.2.8", - "@sentry/types": "8.34.0", + "@angular-devkit/build-angular": "18.2.10", + "@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.10", + "@angular/compiler-cli": "18.2.9", + "@angular/language-service": "18.2.9", + "@sentry/types": "8.35.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", - "@types/jest": "29.5.13", + "@types/emoji-js": "3.5.2", + "@types/jest": "29.5.14", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.6", - "@types/papaparse": "5.3.14", - "@types/showdown": "2.0.6", + "@types/markdown-it": "14.1.2", + "@types/node": "22.7.9", + "@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.10.0", - "@typescript-eslint/parser": "8.10.0", - "eslint": "9.12.0", + "@typescript-eslint/eslint-plugin": "8.11.0", + "@typescript-eslint/parser": "8.11.0", + "eslint": "9.13.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.8.3", @@ -162,7 +163,7 @@ "ng-mocks": "14.13.1", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.80.2", + "sass": "1.80.4", "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/FeedbackAnalysisResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java new file mode 100644 index 000000000000..d913f0c96e3f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.assessment.dto; + +import java.util.List; + +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) { +} 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 23ea64b409b4..7b3fd09ad57d 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 @@ -3,5 +3,5 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, int taskNumber) { +public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, String taskNumber, String 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 new file mode 100644 index 000000000000..c63f9b5540f7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackPageableDTO.java @@ -0,0 +1,48 @@ +package de.tum.cit.aet.artemis.assessment.dto; + +import java.util.List; + +import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; + +public class FeedbackPageableDTO extends PageableSearchDTO { + + private List filterTasks; + + private List filterTestCases; + + private String[] filterOccurrence; + + private String searchTerm; + + public List getFilterTasks() { + return filterTasks; + } + + public void setFilterTasks(List filterTasks) { + this.filterTasks = filterTasks; + } + + public List getFilterTestCases() { + return filterTestCases; + } + + public void setFilterTestCases(List filterTestCases) { + this.filterTestCases = filterTestCases; + } + + public String[] getFilterOccurrence() { + return filterOccurrence; + } + + public void setFilterOccurrence(String[] filterOccurrence) { + this.filterOccurrence = filterOccurrence; + } + + public String getSearchTerm() { + return searchTerm != null ? searchTerm : ""; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java index 6ad61c4ef7ff..87df115afc0d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/LongFeedbackTextRepository.java @@ -1,14 +1,20 @@ package de.tum.cit.aet.artemis.assessment.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; +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.assessment.domain.LongFeedbackText; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +@Profile(PROFILE_CORE) +@Repository public interface LongFeedbackTextRepository extends ArtemisJpaRepository { @Query(""" 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 07b038b9cab2..a9e9050d5e2c 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 @@ -17,10 +17,12 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; +import org.apache.commons.lang3.StringUtils; import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; @@ -28,7 +30,9 @@ 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.FeedbackAnalysisResponseDTO; import de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO; +import de.tum.cit.aet.artemis.assessment.dto.FeedbackPageableDTO; import de.tum.cit.aet.artemis.assessment.repository.ComplaintRepository; import de.tum.cit.aet.artemis.assessment.repository.ComplaintResponseRepository; import de.tum.cit.aet.artemis.assessment.repository.FeedbackRepository; @@ -40,10 +44,12 @@ import de.tum.cit.aet.artemis.buildagent.dto.ResultBuildJob; 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.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.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -53,11 +59,14 @@ import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService; import de.tum.cit.aet.artemis.lti.service.LtiNewResultService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseTestCase; import de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType; import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseTask; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.TemplateProgrammingExerciseParticipationRepository; @@ -110,6 +119,8 @@ public class ResultService { private final ProgrammingExerciseTaskService programmingExerciseTaskService; + private final ProgrammingExerciseRepository programmingExerciseRepository; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, @@ -118,7 +129,7 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository, BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService, StudentParticipationRepository studentParticipationRepository, - ProgrammingExerciseTaskService programmingExerciseTaskService) { + ProgrammingExerciseTaskService programmingExerciseTaskService, ProgrammingExerciseRepository programmingExerciseRepository) { this.userRepository = userRepository; this.resultRepository = resultRepository; this.ltiNewResultService = ltiNewResultService; @@ -139,6 +150,7 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos this.buildLogEntryService = buildLogEntryService; this.studentParticipationRepository = studentParticipationRepository; this.programmingExerciseTaskService = programmingExerciseTaskService; + this.programmingExerciseRepository = programmingExerciseRepository; } /** @@ -530,31 +542,85 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { } /** - * Retrieves aggregated feedback details for a given exercise, calculating relative counts based on the total number of distinct results. - * The task numbers are assigned based on the associated test case names, using the set of tasks fetched from the database. + * Retrieves paginated and filtered aggregated feedback details for a given exercise. *
* 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 number is determined by matching the test case name with the tasks. + * 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. + *
+ * 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). + *
+ * 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. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @return A list of FeedbackDetailDTO objects, each containing: - * - feedback count, - * - relative count (as a percentage of distinct results), - * - detail text, - * - test case name, - * - determined task number (based on the test case name). + * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters (task names, test cases, + * occurrence range). + * @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. */ - public List findAggregatedFeedbackByExerciseId(long exerciseId) { + public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data) { + + // 1. Fetch programming exercise with associated test cases + ProgrammingExercise programmingExercise = programmingExerciseRepository.findWithTestCasesByIdElseThrow(exerciseId); + long distinctResultCount = studentParticipationRepository.countDistinctResultsByExerciseId(exerciseId); - Set tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); - List feedbackDetails = studentParticipationRepository.findAggregatedFeedbackByExerciseId(exerciseId); - - return feedbackDetails.stream().map(detail -> { - double relativeCount = (detail.count() * 100.0) / distinctResultCount; - int taskNumber = tasks.stream().filter(task -> task.getTestCases().stream().anyMatch(tc -> tc.getTestName().equals(detail.testCaseName()))).findFirst() - .map(task -> tasks.stream().toList().indexOf(task) + 1).orElse(0); - return new FeedbackDetailDTO(detail.count(), relativeCount, detail.detailText(), detail.testCaseName(), taskNumber); + + // 2. Extract test case names using streams + List testCaseNames = programmingExercise.getTestCases().stream().map(ProgrammingExerciseTestCase::getTestName).toList(); + + List tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + + // 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(); + + // 4. Set minOccurrence and maxOccurrence based on filterOccurrence + 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 + final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS); + + // 6. Fetch filtered feedback from the repository + 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); + } + + /** + * Retrieves the maximum feedback count for a given exercise. + *
+ * This method calls the repository to fetch the maximum number of feedback occurrences across all feedback items for a specific exercise. + * This is used for filtering feedback based on the number of occurrences. + * + * @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. + */ + public long getMaxCountForExercise(long exerciseId) { + return studentParticipationRepository.findMaxCountForExercise(exerciseId); } } 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 1692beaa7d69..5e28aa48b288 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 @@ -18,6 +18,7 @@ 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.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,19 +28,21 @@ 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.FeedbackDetailDTO; +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; import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.ResultService; 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.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.EnforceAtLeastEditorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; 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; @@ -280,16 +283,56 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo } /** - * GET /exercises/:exerciseId/feedback-details : Retrieves all aggregated feedback details for a given exercise. - * The feedback details include counts and relative counts of feedback occurrences, along with associated test case names and task numbers. + * 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. + *
+ * 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". + *
+ * 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. * * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @return A ResponseEntity containing a list of {@link FeedbackDetailDTO}s + * @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) + * @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. */ @GetMapping("exercises/{exerciseId}/feedback-details") - @EnforceAtLeastEditorInExercise - public ResponseEntity> getAllFeedbackDetailsForExercise(@PathVariable Long exerciseId) { - log.debug("REST request to get all Feedback details for Exercise {}", exerciseId); - return ResponseEntity.ok(resultService.findAggregatedFeedbackByExerciseId(exerciseId)); + @EnforceAtLeastInstructorInExercise + public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @ModelAttribute FeedbackPageableDTO data) { + FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data); + return ResponseEntity.ok(response); + } + + /** + * GET /exercises/{exerciseId}/feedback-details-max-count : Retrieves the maximum number of feedback occurrences for a given exercise. + * This method is useful for determining the highest count of feedback occurrences across all feedback items for the exercise, + * which can then be used to filter or adjust feedback analysis results. + * + * @param exerciseId The ID of the exercise for which the maximum feedback count should be retrieved. + * @return A {@link ResponseEntity} containing the maximum count of feedback occurrences (long). + */ + @GetMapping("exercises/{exerciseId}/feedback-details-max-count") + @EnforceAtLeastInstructorInExercise + public ResponseEntity getMaxCount(@PathVariable long exerciseId) { + long maxCount = resultService.getMaxCountForExercise(exerciseId); + return ResponseEntity.ok(maxCount); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/athena/dto/ResponseMetaDTO.java b/src/main/java/de/tum/cit/aet/artemis/athena/dto/ResponseMetaDTO.java new file mode 100644 index 000000000000..44d36a033552 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/athena/dto/ResponseMetaDTO.java @@ -0,0 +1,17 @@ +package de.tum.cit.aet.artemis.athena.dto; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.LLMRequest; + +/** + * DTO representing the meta information in the Athena response. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ResponseMetaDTO(TotalUsage totalUsage, List llmRequests) { + + public record TotalUsage(Integer numInputTokens, Integer numOutputTokens, Integer numTotalTokens, Float cost) { + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaFeedbackSuggestionsService.java b/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaFeedbackSuggestionsService.java index d9c81849b396..210b3c7ba859 100644 --- a/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaFeedbackSuggestionsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/athena/service/AthenaFeedbackSuggestionsService.java @@ -17,10 +17,18 @@ import de.tum.cit.aet.artemis.athena.dto.ExerciseBaseDTO; import de.tum.cit.aet.artemis.athena.dto.ModelingFeedbackDTO; import de.tum.cit.aet.artemis.athena.dto.ProgrammingFeedbackDTO; +import de.tum.cit.aet.artemis.athena.dto.ResponseMetaDTO; import de.tum.cit.aet.artemis.athena.dto.SubmissionBaseDTO; import de.tum.cit.aet.artemis.athena.dto.TextFeedbackDTO; +import de.tum.cit.aet.artemis.core.domain.LLMRequest; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; +import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.core.exception.NetworkingException; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.domain.Submission; +import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.modeling.domain.ModelingExercise; import de.tum.cit.aet.artemis.modeling.domain.ModelingSubmission; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -48,20 +56,24 @@ public class AthenaFeedbackSuggestionsService { private final AthenaDTOConverterService athenaDTOConverterService; + private final LLMTokenUsageService llmTokenUsageService; + /** * Create a new AthenaFeedbackSuggestionsService to receive feedback suggestions from the Athena service. * * @param athenaRestTemplate REST template used for the communication with Athena * @param athenaModuleService Athena module serviced used to determine the urls for different modules - * @param athenaDTOConverterService Service to convert exr + * @param athenaDTOConverterService Service to convert exrcises and submissions to DTOs + * @param llmTokenUsageService Service to store the usage of LLM tokens */ public AthenaFeedbackSuggestionsService(@Qualifier("athenaRestTemplate") RestTemplate athenaRestTemplate, AthenaModuleService athenaModuleService, - AthenaDTOConverterService athenaDTOConverterService) { + AthenaDTOConverterService athenaDTOConverterService, LLMTokenUsageService llmTokenUsageService) { textAthenaConnector = new AthenaConnector<>(athenaRestTemplate, ResponseDTOText.class); programmingAthenaConnector = new AthenaConnector<>(athenaRestTemplate, ResponseDTOProgramming.class); modelingAthenaConnector = new AthenaConnector<>(athenaRestTemplate, ResponseDTOModeling.class); this.athenaDTOConverterService = athenaDTOConverterService; this.athenaModuleService = athenaModuleService; + this.llmTokenUsageService = llmTokenUsageService; } @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -69,15 +81,15 @@ private record RequestDTO(ExerciseBaseDTO exercise, SubmissionBaseDTO submission } @JsonInclude(JsonInclude.Include.NON_EMPTY) - private record ResponseDTOText(List data) { + private record ResponseDTOText(List data, ResponseMetaDTO meta) { } @JsonInclude(JsonInclude.Include.NON_EMPTY) - private record ResponseDTOProgramming(List data) { + private record ResponseDTOProgramming(List data, ResponseMetaDTO meta) { } @JsonInclude(JsonInclude.Include.NON_EMPTY) - private record ResponseDTOModeling(List data) { + private record ResponseDTOModeling(List data, ResponseMetaDTO meta) { } /** @@ -100,6 +112,7 @@ public List getTextFeedbackSuggestions(TextExercise exercise, T final RequestDTO request = new RequestDTO(athenaDTOConverterService.ofExercise(exercise), athenaDTOConverterService.ofSubmission(exercise.getId(), submission), isGraded); ResponseDTOText response = textAthenaConnector.invokeWithRetry(athenaModuleService.getAthenaModuleUrl(exercise) + "/feedback_suggestions", request, 0); log.info("Athena responded to '{}' feedback suggestions request: {}", isGraded ? "Graded" : "Non Graded", response.data); + storeTokenUsage(exercise, submission, response.meta, !isGraded); return response.data.stream().toList(); } @@ -117,6 +130,7 @@ public List getProgrammingFeedbackSuggestions(Programmin final RequestDTO request = new RequestDTO(athenaDTOConverterService.ofExercise(exercise), athenaDTOConverterService.ofSubmission(exercise.getId(), submission), isGraded); ResponseDTOProgramming response = programmingAthenaConnector.invokeWithRetry(athenaModuleService.getAthenaModuleUrl(exercise) + "/feedback_suggestions", request, 0); log.info("Athena responded to '{}' feedback suggestions request: {}", isGraded ? "Graded" : "Non Graded", response.data); + storeTokenUsage(exercise, submission, response.meta, !isGraded); return response.data.stream().toList(); } @@ -139,6 +153,36 @@ public List getModelingFeedbackSuggestions(ModelingExercise final RequestDTO request = new RequestDTO(athenaDTOConverterService.ofExercise(exercise), athenaDTOConverterService.ofSubmission(exercise.getId(), submission), isGraded); ResponseDTOModeling response = modelingAthenaConnector.invokeWithRetry(athenaModuleService.getAthenaModuleUrl(exercise) + "/feedback_suggestions", request, 0); log.info("Athena responded to '{}' feedback suggestions request: {}", isGraded ? "Graded" : "Non Graded", response.data); + storeTokenUsage(exercise, submission, response.meta, !isGraded); return response.data; } + + /** + * Store the usage of LLM tokens for a given submission + * + * @param exercise the exercise the submission belongs to + * @param submission the submission for which the tokens were used + * @param meta the meta information of the response from Athena + * @param isPreliminaryFeedback whether the feedback is preliminary or not + */ + private void storeTokenUsage(Exercise exercise, Submission submission, ResponseMetaDTO meta, Boolean isPreliminaryFeedback) { + if (meta == null) { + return; + } + Long courseId = exercise.getCourseViaExerciseGroupOrCourseMember().getId(); + Long userId; + if (submission.getParticipation() instanceof StudentParticipation studentParticipation) { + userId = studentParticipation.getStudent().map(User::getId).orElse(null); + } + else { + userId = null; + } + List llmRequests = meta.llmRequests(); + if (llmRequests == null) { + return; + } + + llmTokenUsageService.saveLLMTokenUsage(llmRequests, LLMServiceType.ATHENA, + (llmTokenUsageBuilder -> llmTokenUsageBuilder.withCourse(courseId).withExercise(exercise.getId()).withUser(userId))); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/CompetencyProgressConfidenceReason.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/CompetencyProgressConfidenceReason.java index 9be56432aaac..415ab333378c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/CompetencyProgressConfidenceReason.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/CompetencyProgressConfidenceReason.java @@ -5,8 +5,8 @@ /** * Enum to define the different reasons why the confidence is above/below 1 in the {@link CompetencyProgress}. * A confidence != 1 leads to a higher/lower mastery, which is displayed to the student together with the reason. - * Also see {@link CompetencyProgress#setConfidenceReason}. + * Also see {@link de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService#setConfidenceReason}. */ public enum CompetencyProgressConfidenceReason { - NO_REASON, RECENT_SCORES_LOWER, RECENT_SCORES_HIGHER, MORE_EASY_POINTS, MORE_HARD_POINTS, QUICKLY_SOLVED_EXERCISES + NO_REASON, RECENT_SCORES_LOWER, RECENT_SCORES_HIGHER, MORE_EASY_POINTS, MORE_HARD_POINTS, QUICKLY_SOLVED_EXERCISES, MORE_LOW_WEIGHTED_EXERCISES, MORE_HIGH_WEIGHTED_EXERCISES } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/LearningObject.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/LearningObject.java index 765ee9eccf4e..d2e28b5bc585 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/LearningObject.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/LearningObject.java @@ -4,7 +4,7 @@ import java.util.Optional; import java.util.Set; -import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLearningObjectLink; import de.tum.cit.aet.artemis.core.domain.User; public interface LearningObject { @@ -19,7 +19,7 @@ public interface LearningObject { Long getId(); - Set getCompetencies(); + Set getCompetencyLinks(); boolean isVisibleToStudents(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyExerciseLink.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyExerciseLink.java new file mode 100644 index 000000000000..b6764a924893 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyExerciseLink.java @@ -0,0 +1,101 @@ +package de.tum.cit.aet.artemis.atlas.domain.competency; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import de.tum.cit.aet.artemis.exercise.domain.Exercise; + +@Entity +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@Table(name = "competency_exercise") +public class CompetencyExerciseLink extends CompetencyLearningObjectLink { + + @EmbeddedId + @JsonIgnore + protected CompetencyExerciseId id = new CompetencyExerciseId(); + + @ManyToOne(optional = false, cascade = CascadeType.PERSIST) + @MapsId("exerciseId") + private Exercise exercise; + + public CompetencyExerciseLink(CourseCompetency competency, Exercise exercise, double weight) { + super(competency, weight); + this.exercise = exercise; + } + + public CompetencyExerciseLink() { + // Empty constructor for Spring + } + + public Exercise getExercise() { + return exercise; + } + + public void setExercise(Exercise exercise) { + this.exercise = exercise; + } + + public CompetencyExerciseId getId() { + return id; + } + + @Override + public String toString() { + return "CompetencyExerciseLink{" + "exercise=" + exercise + ", id=" + id + ", competency=" + competency + ", weight=" + weight + '}'; + } + + @Embeddable + public static class CompetencyExerciseId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private long exerciseId; + + private long competencyId; + + public CompetencyExerciseId() { + // Empty constructor for Spring + } + + public CompetencyExerciseId(long exerciseId, long competencyId) { + this.exerciseId = exerciseId; + this.competencyId = competencyId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CompetencyExerciseId that)) { + return false; + } + return exerciseId == that.exerciseId && competencyId == that.competencyId; + } + + @Override + public int hashCode() { + return Objects.hash(exerciseId, competencyId); + } + + @Override + public String toString() { + return "CompetencyExerciseId{" + "exerciseId=" + exerciseId + ", competencyId=" + competencyId + '}'; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLearningObjectLink.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLearningObjectLink.java new file mode 100644 index 000000000000..9d6cef6115ce --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLearningObjectLink.java @@ -0,0 +1,45 @@ +package de.tum.cit.aet.artemis.atlas.domain.competency; + +import java.io.Serializable; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.MapsId; + +@MappedSuperclass +public abstract class CompetencyLearningObjectLink implements Serializable { + + @ManyToOne(optional = false, cascade = CascadeType.PERSIST) + @MapsId("competencyId") + protected CourseCompetency competency; + + @Column(name = "link_weight") + protected double weight; + + public CompetencyLearningObjectLink(CourseCompetency competency, double weight) { + this.competency = competency; + this.weight = weight; + } + + public CompetencyLearningObjectLink() { + // Empty constructor for Spring + } + + public CourseCompetency getCompetency() { + return competency; + } + + public void setCompetency(CourseCompetency competency) { + this.competency = competency; + } + + public double getWeight() { + return weight; + } + + public void setWeight(double weight) { + this.weight = weight; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLectureUnitLink.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLectureUnitLink.java new file mode 100644 index 000000000000..ce1e45183c02 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CompetencyLectureUnitLink.java @@ -0,0 +1,101 @@ +package de.tum.cit.aet.artemis.atlas.domain.competency; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; + +@Entity +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@Table(name = "competency_lecture_unit") +public class CompetencyLectureUnitLink extends CompetencyLearningObjectLink { + + @EmbeddedId + @JsonIgnore + protected CompetencyLectureUnitId id = new CompetencyLectureUnitId(); + + @ManyToOne(optional = false, cascade = CascadeType.PERSIST) + @MapsId("lectureUnitId") + private LectureUnit lectureUnit; + + public CompetencyLectureUnitLink(CourseCompetency competency, LectureUnit lectureUnit, double weight) { + super(competency, weight); + this.lectureUnit = lectureUnit; + } + + public CompetencyLectureUnitLink() { + // Empty constructor for Spring + } + + public LectureUnit getLectureUnit() { + return lectureUnit; + } + + public void setLectureUnit(LectureUnit lectureUnit) { + this.lectureUnit = lectureUnit; + } + + public CompetencyLectureUnitId getId() { + return id; + } + + @Override + public String toString() { + return "CompetencyLectureUnitLink{" + "lectureUnit=" + lectureUnit + ", id=" + id + ", competency=" + competency + ", weight=" + weight + '}'; + } + + @Embeddable + public static class CompetencyLectureUnitId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private long lectureUnitId; + + private long competencyId; + + public CompetencyLectureUnitId() { + // Empty constructor for Spring + } + + public CompetencyLectureUnitId(long lectureUnitId, long competencyId) { + this.lectureUnitId = lectureUnitId; + this.competencyId = competencyId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CompetencyLectureUnitId that)) { + return false; + } + return lectureUnitId == that.lectureUnitId && competencyId == that.competencyId; + } + + @Override + public int hashCode() { + return Objects.hash(lectureUnitId, competencyId); + } + + @Override + public String toString() { + return "CompetencyLectureUnitId{" + "lectureUnitId=" + lectureUnitId + ", competencyId=" + competencyId + '}'; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CourseCompetency.java b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CourseCompetency.java index fd81132c2bbb..34280dd924b7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CourseCompetency.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/domain/competency/CourseCompetency.java @@ -29,9 +29,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import de.tum.cit.aet.artemis.core.domain.Course; -import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; -import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; /** * CourseCompetency is an abstract class for all competency types that are part of a course. @@ -71,13 +69,13 @@ public abstract class CourseCompetency extends BaseCompetency { @JsonIgnoreProperties({ "competencies" }) private StandardizedCompetency linkedStandardizedCompetency; - @ManyToMany(mappedBy = "competencies") - @JsonIgnoreProperties({ "competencies", "course" }) - private Set exercises = new HashSet<>(); + @OneToMany(mappedBy = "competency", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnoreProperties("competency") + private Set exerciseLinks = new HashSet<>(); - @ManyToMany(mappedBy = "competencies") - @JsonIgnoreProperties("competencies") - private Set lectureUnits = new HashSet<>(); + @OneToMany(mappedBy = "competency", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnoreProperties("competency") + private Set lectureUnitLinks = new HashSet<>(); @OneToMany(mappedBy = "competency", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) @JsonIgnoreProperties({ "user", "competency" }) @@ -161,55 +159,20 @@ public void setLinkedStandardizedCompetency(StandardizedCompetency linkedStandar this.linkedStandardizedCompetency = linkedStandardizedCompetency; } - public Set getExercises() { - return exercises; - } - - public void setExercises(Set exercises) { - this.exercises = exercises; + public Set getExerciseLinks() { + return exerciseLinks; } - public void addExercise(Exercise exercise) { - this.exercises.add(exercise); - exercise.getCompetencies().add(this); + public void setExerciseLinks(Set exerciseLinks) { + this.exerciseLinks = exerciseLinks; } - public Set getLectureUnits() { - return lectureUnits; + public Set getLectureUnitLinks() { + return lectureUnitLinks; } - public void setLectureUnits(Set lectureUnits) { - this.lectureUnits = lectureUnits; - } - - /** - * Adds the lecture unit to the competency (bidirectional) - * Note: ExerciseUnits are not accepted, should be set via the connected exercise (see {@link #addExercise(Exercise)}) - * - * @param lectureUnit The lecture unit to add - */ - public void addLectureUnit(LectureUnit lectureUnit) { - if (lectureUnit instanceof ExerciseUnit) { - // The competencies of ExerciseUnits are taken from the corresponding exercise - throw new IllegalArgumentException("ExerciseUnits can not be connected to competencies"); - } - this.lectureUnits.add(lectureUnit); - lectureUnit.getCompetencies().add(this); - } - - /** - * Removes the lecture unit from the competency (bidirectional) - * Note: ExerciseUnits are not accepted, should be set via the connected exercise - * - * @param lectureUnit The lecture unit to remove - */ - public void removeLectureUnit(LectureUnit lectureUnit) { - if (lectureUnit instanceof ExerciseUnit) { - // The competencies of ExerciseUnits are taken from the corresponding exercise - throw new IllegalArgumentException("ExerciseUnits can not be disconnected from competencies"); - } - this.lectureUnits.remove(lectureUnit); - lectureUnit.getCompetencies().remove(this); + public void setLectureUnitLinks(Set lectureUnitLinks) { + this.lectureUnitLinks = lectureUnitLinks; } public Set getUserProgress() { @@ -234,6 +197,6 @@ public void setLearningPaths(Set learningPaths) { @PrePersist @PreUpdate public void prePersistOrUpdate() { - this.lectureUnits.removeIf(lectureUnit -> lectureUnit instanceof ExerciseUnit); + this.lectureUnitLinks.removeIf(lectureUnit -> lectureUnit.getLectureUnit() instanceof ExerciseUnit); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java index a0482e7f4d30..574b0b921af0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java @@ -7,6 +7,6 @@ import de.tum.cit.aet.artemis.exercise.domain.DifficultyLevel; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record CompetencyExerciseMasteryCalculationDTO(double maxPoints, DifficultyLevel difficulty, boolean isProgrammingExercise, Double lastScore, Double lastPoints, - Instant lastModifiedDate, long submissionCount) { +public record CompetencyExerciseMasteryCalculationDTO(long exerciseId, double maxPoints, DifficultyLevel difficulty, boolean isProgrammingExercise, double competencyLinkWeight, + Double lastScore, Double lastPoints, Instant lastModifiedDate, long submissionCount) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyLectureUnitMasteryCalculationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyLectureUnitMasteryCalculationDTO.java new file mode 100644 index 000000000000..d74d672e92be --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyLectureUnitMasteryCalculationDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.atlas.dto.metrics; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CompetencyLectureUnitMasteryCalculationDTO(long lectureUnitId, boolean completed, double competencyLinkWeight) { +} 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 new file mode 100644 index 000000000000..d19675ca6412 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyExerciseLinkRepository.java @@ -0,0 +1,15 @@ +package de.tum.cit.aet.artemis.atlas.repository; + +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.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +public interface CompetencyExerciseLinkRepository extends ArtemisJpaRepository { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyLectureUnitLinkRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyLectureUnitLinkRepository.java new file mode 100644 index 000000000000..d245ee76c6b2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyLectureUnitLinkRepository.java @@ -0,0 +1,26 @@ +package de.tum.cit.aet.artemis.atlas.repository; + +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.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +public interface CompetencyLectureUnitLinkRepository extends ArtemisJpaRepository { + + @Modifying + @Transactional // ok because of delete + @Query(""" + DELETE FROM CompetencyLectureUnitLink clul + WHERE clul.lectureUnit.lecture.id = :lectureId + """) + void deleteAllByLectureId(@Param("lectureId") long lectureId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyMetricsRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyMetricsRepository.java index 362064d79ec5..abc050e0479a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyMetricsRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyMetricsRepository.java @@ -45,7 +45,8 @@ public interface CompetencyMetricsRepository extends ArtemisJpaRepository findAllExerciseIdsByCompetencyIds(@Param("competencyIds") Set competencyIds); @@ -57,10 +58,10 @@ public interface CompetencyMetricsRepository extends ArtemisJpaRepository findAllLectureUnitIdsByCompetencyIds(@Param("competencyIds") Set competencyIds); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java index 91ae85978b42..38651ce86558 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java @@ -26,8 +26,10 @@ public interface CompetencyRepository extends ArtemisJpaRepository findByIdWithLectureUnits(@Param("competencyId") long competencyId); @@ -45,8 +48,10 @@ public interface CompetencyRepository extends ArtemisJpaRepository findByIdWithLectureUnitsAndExercises(@Param("competencyId") long competencyId); 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 d8b66519355c..fee3ca1e82f2 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 @@ -1,18 +1,23 @@ package de.tum.cit.aet.artemis.atlas.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; import java.util.Set; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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.LearningObject; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyExerciseMasteryCalculationDTO; +import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyLectureUnitMasteryCalculationDTO; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -21,6 +26,8 @@ /** * Spring Data JPA repository for the {@link CourseCompetency} entity. */ +@Profile(PROFILE_CORE) +@Repository public interface CourseCompetencyRepository extends ArtemisJpaRepository { @Query(""" @@ -33,7 +40,8 @@ public interface CourseCompetencyRepository extends ArtemisJpaRepository findByIdWithLectureUnits(@Param("competencyId") long competencyId); @@ -48,8 +56,10 @@ public interface CourseCompetencyRepository extends ArtemisJpaRepository findAllExerciseInfoByCompetencyIdAndUser(@Param("competencyId") long competencyId, @Param("user") User user); + + /** + * Fetches all information related to the calculation of the mastery for lecture units in a competency. + * The complex grouping by is necessary for postgres + * + * @param competencyId the id of the competency for which to fetch the lecture unit information + * @param user the user for which to fetch the lecture unit information + * @return the lecture unit information for the calculation of the mastery in the competency + */ + @Query(""" + SELECT new de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyLectureUnitMasteryCalculationDTO( + lu.id, + CASE WHEN u IS NOT NULL THEN TRUE ELSE FALSE END, + lul.weight + ) + FROM CourseCompetency c + LEFT JOIN c.lectureUnitLinks lul + LEFT JOIN lul.lectureUnit lu + LEFT JOIN lu.completedUsers cu + LEFT JOIN cu.user u ON u = :user WHERE c.id = :competencyId - AND ex IS NOT NULL - GROUP BY ex.maxPoints, ex.difficulty, TYPE(ex), sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate + AND lu IS NOT NULL + GROUP BY lu.id, u, lul.weight, u """) - Set findAllExerciseInfoByCompetencyId(@Param("competencyId") long competencyId, @Param("user") User user); + Set findAllLectureUnitInfoByCompetencyIdAndUser(@Param("competencyId") long competencyId, @Param("user") User user); @Query(""" SELECT c FROM CourseCompetency c - LEFT JOIN FETCH c.lectureUnits lu - LEFT JOIN FETCH c.exercises ex + LEFT JOIN FETCH c.lectureUnitLinks lul + LEFT JOIN FETCH lul.lectureUnit + LEFT JOIN FETCH c.exerciseLinks e + LEFT JOIN FETCH e.exercise WHERE c.id = :competencyId """) Optional findByIdWithExercisesAndLectureUnits(@Param("competencyId") long competencyId); @@ -125,10 +169,14 @@ GROUP BY ex.maxPoints, ex.difficulty, TYPE(ex), sS.lastScore, tS.lastScore, sS.l @Query(""" SELECT c FROM CourseCompetency c - LEFT JOIN FETCH c.exercises ex - LEFT JOIN FETCH ex.competencies - LEFT JOIN FETCH c.lectureUnits lu - LEFT JOIN FETCH lu.competencies + LEFT JOIN FETCH c.exerciseLinks el + LEFT JOIN FETCH el.exercise e + LEFT JOIN FETCH e.competencyLinks ecl + LEFT JOIN FETCH ecl.competency + LEFT JOIN FETCH c.lectureUnitLinks lul + LEFT JOIN FETCH lul.lectureUnit lu + LEFT JOIN FETCH lu.competencyLinks lucl + LEFT JOIN FETCH lucl.competency WHERE c.id = :competencyId """) Optional findByIdWithExercisesAndLectureUnitsBidirectional(@Param("competencyId") long competencyId); @@ -136,15 +184,17 @@ GROUP BY ex.maxPoints, ex.difficulty, TYPE(ex), sS.lastScore, tS.lastScore, sS.l @Query(""" SELECT c.id FROM CourseCompetency c - LEFT JOIN c.exercises ex - WHERE :exercise = ex + LEFT JOIN c.exerciseLinks el + LEFT JOIN el.exercise e + WHERE :exercise = e """) Set findAllIdsByExercise(@Param("exercise") Exercise exercise); @Query(""" SELECT c.id FROM CourseCompetency c - LEFT JOIN c.lectureUnits lu + LEFT JOIN c.lectureUnitLinks lul + LEFT JOIN lul.lectureUnit lu WHERE :lectureUnit = lu """) Set findAllIdsByLectureUnit(@Param("lectureUnit") LectureUnit lectureUnit); @@ -192,7 +242,8 @@ Page findForImportAndUserHasAccessToCourse(@Param("partialTitl @Query(""" SELECT c FROM CourseCompetency c - LEFT JOIN FETCH c.exercises ex + LEFT JOIN FETCH c.exerciseLinks el + LEFT JOIN FETCH el.exercise WHERE c.id = :competencyId """) Optional findByIdWithExercises(@Param("competencyId") long competencyId); @@ -200,8 +251,10 @@ Page findForImportAndUserHasAccessToCourse(@Param("partialTitl @Query(""" SELECT c FROM CourseCompetency c - LEFT JOIN FETCH c.lectureUnits lu - LEFT JOIN FETCH c.exercises + LEFT JOIN FETCH c.lectureUnitLinks lul + LEFT JOIN FETCH lul.lectureUnit + LEFT JOIN FETCH c.exerciseLinks el + LEFT JOIN FETCH el.exercise WHERE c.id = :competencyId """) Optional findByIdWithLectureUnitsAndExercises(@Param("competencyId") long competencyId); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/LearningPathRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/LearningPathRepository.java index 596b50ef2e78..d07a63bcc3b9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/LearningPathRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/LearningPathRepository.java @@ -63,7 +63,8 @@ SELECT COUNT (learningPath) """) long countLearningPathsOfEnrolledStudentsInCourse(@Param("courseId") long courseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies", "competencies.lectureUnits", "competencies.exercises" }) + @EntityGraph(type = LOAD, attributePaths = { "competencies", "competencies.lectureUnitLinks", "competencies.lectureUnitLinks.lectureUnit", "competencies.exerciseLinks", + "competencies.exerciseLinks.exercise" }) Optional findWithCompetenciesAndLectureUnitsAndExercisesById(long learningPathId); default LearningPath findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(long learningPathId) { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java index 9616c2a5f34b..e86b66612a76 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java @@ -1,11 +1,15 @@ package de.tum.cit.aet.artemis.atlas.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.List; import java.util.Optional; import java.util.Set; +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.Prerequisite; import de.tum.cit.aet.artemis.core.domain.Course; @@ -14,15 +18,17 @@ /** * Spring Data JPA repository for the {@link Prerequisite} entity. */ +@Profile(PROFILE_CORE) +@Repository public interface PrerequisiteRepository extends ArtemisJpaRepository { - List findAllByCourseIdOrderById(long courseId); - @Query(""" SELECT p FROM Prerequisite p - LEFT JOIN FETCH p.exercises - LEFT JOIN FETCH p.lectureUnits lu + LEFT JOIN FETCH p.exerciseLinks el + LEFT JOIN FETCH el.exercise + LEFT JOIN FETCH p.lectureUnitLinks lul + LEFT JOIN FETCH lul.lectureUnit lu LEFT JOIN FETCH lu.lecture l LEFT JOIN FETCH l.attachments WHERE p.course.id = :courseId @@ -32,8 +38,10 @@ public interface PrerequisiteRepository extends ArtemisJpaRepository findByIdWithLectureUnitsAndExercises(@Param("competencyId") long competencyId); @@ -41,7 +49,8 @@ public interface PrerequisiteRepository extends ArtemisJpaRepository findByIdWithLectureUnits(@Param("competencyId") long competencyId); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java index 6b67d8f01b44..d20c671193de 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java @@ -28,9 +28,13 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +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.CourseCompetencyRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; @@ -105,6 +109,10 @@ public class LearningObjectImportService { private final GradingCriterionRepository gradingCriterionRepository; + private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + + private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + public LearningObjectImportService(ExerciseRepository exerciseRepository, ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseImportService programmingExerciseImportService, FileUploadExerciseRepository fileUploadExerciseRepository, FileUploadExerciseImportService fileUploadExerciseImportService, ModelingExerciseRepository modelingExerciseRepository, @@ -112,7 +120,8 @@ public LearningObjectImportService(ExerciseRepository exerciseRepository, Progra QuizExerciseRepository quizExerciseRepository, QuizExerciseImportService quizExerciseImportService, LectureRepository lectureRepository, LectureImportService lectureImportService, LectureUnitRepository lectureUnitRepository, LectureUnitImportService lectureUnitImportService, CourseCompetencyRepository courseCompetencyRepository, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, - GradingCriterionRepository gradingCriterionRepository) { + GradingCriterionRepository gradingCriterionRepository, CompetencyExerciseLinkRepository competencyExerciseLinkRepository, + CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { this.exerciseRepository = exerciseRepository; this.programmingExerciseRepository = programmingExerciseRepository; this.programmingExerciseImportService = programmingExerciseImportService; @@ -131,6 +140,8 @@ public LearningObjectImportService(ExerciseRepository exerciseRepository, Progra this.courseCompetencyRepository = courseCompetencyRepository; this.programmingExerciseTaskRepository = programmingExerciseTaskRepository; this.gradingCriterionRepository = gradingCriterionRepository; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; + this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; } /** @@ -168,19 +179,23 @@ public void importRelatedLearningObjects(Collection private void importOrLoadExercises(Collection sourceCourseCompetencies, Map idToImportedCompetency, Course courseToImportInto, Set importedExercises) { for (CourseCompetency sourceCourseCompetency : sourceCourseCompetencies) { - for (Exercise sourceExercise : sourceCourseCompetency.getExercises()) { + sourceCourseCompetency.getExerciseLinks().forEach(sourceExerciseLink -> { try { - Exercise importedExercise = importOrLoadExercise(sourceExercise, courseToImportInto); + Exercise importedExercise = importOrLoadExercise(sourceExerciseLink.getExercise(), courseToImportInto); importedExercises.add(importedExercise); - importedExercise.getCompetencies().add(idToImportedCompetency.get(sourceCourseCompetency.getId()).competency()); - idToImportedCompetency.get(sourceCourseCompetency.getId()).competency().getExercises().add(importedExercise); + CourseCompetency importedCompetency = idToImportedCompetency.get(sourceCourseCompetency.getId()).competency(); + CompetencyExerciseLink link = new CompetencyExerciseLink(importedCompetency, importedExercise, sourceExerciseLink.getWeight()); + link = competencyExerciseLinkRepository.save(link); + importedExercise.getCompetencyLinks().add(link); + importedCompetency.getExerciseLinks().add(link); } catch (Exception e) { - log.error("Failed to import exercise with title {} together with its competency with id {}", sourceExercise.getTitle(), sourceCourseCompetency.getId(), e); + log.error("Failed to import exercise with title {} together with its competency with id {}", sourceExerciseLink.getExercise().getTitle(), + sourceCourseCompetency.getId(), e); } - } + }); } } @@ -257,7 +272,7 @@ private void clearProgrammingExerciseAttributes(ProgrammingExercise programmingE programmingExercise.setAttachments(new HashSet<>()); programmingExercise.setPosts(new HashSet<>()); programmingExercise.setPlagiarismCases(new HashSet<>()); - programmingExercise.setCompetencies(new HashSet<>()); + programmingExercise.setCompetencyLinks(new HashSet<>()); } /** @@ -281,7 +296,7 @@ private Exercise importOrLoadExercise(E exercise, Course co exercise = loadForImport.apply(exercise.getId()); exercise.setCourse(course); exercise.setId(null); - exercise.setCompetencies(new HashSet<>()); + exercise.setCompetencyLinks(new HashSet<>()); return importFunction.apply(exercise, exercise); } @@ -299,19 +314,23 @@ private Exercise importOrLoadExercise(E exercise, Course co private void importOrLoadLectureUnits(Collection sourceCourseCompetencies, Map idToImportedCompetency, Course courseToImportInto, Map titleToImportedLectures, Set importedLectureUnits) { for (CourseCompetency sourceCourseCompetency : sourceCourseCompetencies) { - for (LectureUnit sourceLectureUnit : sourceCourseCompetency.getLectureUnits()) { + for (CompetencyLectureUnitLink sourceLectureUnitLink : sourceCourseCompetency.getLectureUnitLinks()) { try { - importOrLoadLectureUnit(sourceLectureUnit, sourceCourseCompetency, idToImportedCompetency, courseToImportInto, titleToImportedLectures, importedLectureUnits); + importOrLoadLectureUnit(sourceLectureUnitLink, sourceCourseCompetency, idToImportedCompetency, courseToImportInto, titleToImportedLectures, + importedLectureUnits); } catch (Exception e) { - log.error("Failed to import lecture unit with name {} together with its competency with id {}", sourceLectureUnit.getName(), sourceCourseCompetency.getId(), e); + log.error("Failed to import lecture unit with name {} together with its competency with id {}", sourceLectureUnitLink.getLectureUnit().getName(), + sourceCourseCompetency.getId(), e); } } } } - private void importOrLoadLectureUnit(LectureUnit sourceLectureUnit, CourseCompetency sourceCourseCompetency, Map idToImportedCompetency, - Course courseToImportInto, Map titleToImportedLectures, Set importedLectureUnits) throws NoUniqueQueryException { + private void importOrLoadLectureUnit(CompetencyLectureUnitLink sourceLectureUnitLink, CourseCompetency sourceCourseCompetency, + Map idToImportedCompetency, Course courseToImportInto, Map titleToImportedLectures, + Set importedLectureUnits) throws NoUniqueQueryException { + LectureUnit sourceLectureUnit = sourceLectureUnitLink.getLectureUnit(); Lecture sourceLecture = sourceLectureUnit.getLecture(); Lecture importedLecture = importOrLoadLecture(sourceLecture, courseToImportInto, titleToImportedLectures); @@ -330,8 +349,11 @@ private void importOrLoadLectureUnit(LectureUnit sourceLectureUnit, CourseCompet importedLectureUnits.add(importedLectureUnit); - importedLectureUnit.getCompetencies().add(idToImportedCompetency.get(sourceCourseCompetency.getId()).competency()); - idToImportedCompetency.get(sourceCourseCompetency.getId()).competency().getLectureUnits().add(importedLectureUnit); + CourseCompetency importedCompetency = idToImportedCompetency.get(sourceCourseCompetency.getId()).competency(); + CompetencyLectureUnitLink link = new CompetencyLectureUnitLink(importedCompetency, importedLectureUnit, sourceLectureUnitLink.getWeight()); + link = competencyLectureUnitLinkRepository.save(link); + importedLectureUnit.getCompetencyLinks().add(link); + importedCompetency.getLectureUnitLinks().add(link); } private Lecture importOrLoadLecture(Lecture sourceLecture, Course courseToImportInto, Map titleToImportedLectures) throws NoUniqueQueryException { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyProgressService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyProgressService.java index bd6ff4b3148d..813408442238 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyProgressService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyProgressService.java @@ -9,6 +9,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.DoubleStream; import jakarta.validation.constraints.NotNull; @@ -22,22 +23,23 @@ import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreService; import de.tum.cit.aet.artemis.atlas.domain.CompetencyProgressConfidenceReason; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLearningObjectLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyExerciseMasteryCalculationDTO; +import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyLectureUnitMasteryCalculationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; 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.CourseCompetencyProgressDTO; -import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.SecurityUtils; import de.tum.cit.aet.artemis.core.util.RoundingUtil; import de.tum.cit.aet.artemis.exercise.domain.DifficultyLevel; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.participation.Participant; -import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; @@ -58,21 +60,21 @@ public class CompetencyProgressService { private final LectureUnitCompletionRepository lectureUnitCompletionRepository; - private final UserRepository userRepository; - private final CourseCompetencyRepository courseCompetencyRepository; private static final int MIN_EXERCISES_RECENCY_CONFIDENCE = 3; private static final int MAX_SUBMISSIONS_FOR_QUICK_SOLVE_HEURISTIC = 3; - private static final double DEFAULT_CONFIDENCE = 1.0; + private static final double DEFAULT_CONFIDENCE = 1; + + private static final double MAX_CONFIDENCE = 2; private static final double MAX_CONFIDENCE_HEURISTIC = 0.25; private static final double CONFIDENCE_REASON_DEADZONE = 0.05; - public CompetencyProgressService(CompetencyProgressRepository competencyProgressRepository, UserRepository userRepository, LearningPathService learningPathService, + public CompetencyProgressService(CompetencyProgressRepository competencyProgressRepository, LearningPathService learningPathService, ParticipantScoreService participantScoreService, LectureUnitCompletionRepository lectureUnitCompletionRepository, CourseCompetencyRepository courseCompetencyRepository) { this.competencyProgressRepository = competencyProgressRepository; @@ -80,7 +82,6 @@ public CompetencyProgressService(CompetencyProgressRepository competencyProgress this.participantScoreService = participantScoreService; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.courseCompetencyRepository = courseCompetencyRepository; - this.userRepository = userRepository; } /** @@ -126,19 +127,6 @@ public void updateProgressByCompetencyAsync(CourseCompetency competency) { existingProgress.stream().map(CompetencyProgress::getUser).forEach(user -> updateCompetencyProgress(competency.getId(), user)); } - /** - * Asynchronously update the progress of all users in the course for a specific competency - * - * @param competency The competency for which to update all existing student progress - */ - @Async - public void updateProgressByCompetencyAndUsersInCourseAsync(CourseCompetency competency) { - SecurityUtils.setAuthorizationObject(); // Required for async - Set users = userRepository.getUsersInCourse(competency.getCourse()); - log.debug("Updating competency progress for {} users.", users.size()); - users.forEach(user -> updateCompetencyProgress(competency.getId(), user)); - } - /** * Asynchronously update the existing progress for all changed competencies linked to the given learning object * If new competencies are added, the progress is updated for all users in the course, otherwise only the existing progresses are updated. @@ -150,8 +138,10 @@ public void updateProgressByCompetencyAndUsersInCourseAsync(CourseCompetency com public void updateProgressForUpdatedLearningObjectAsync(LearningObject originalLearningObject, Optional updatedLearningObject) { SecurityUtils.setAuthorizationObject(); // Required for async - Set originalCompetencyIds = originalLearningObject.getCompetencies().stream().map(CourseCompetency::getId).collect(Collectors.toSet()); - Set updatedCompetencies = updatedLearningObject.map(LearningObject::getCompetencies).orElse(Set.of()); + Set originalCompetencyIds = originalLearningObject.getCompetencyLinks().stream().map(CompetencyLearningObjectLink::getCompetency).map(CourseCompetency::getId) + .collect(Collectors.toSet()); + Set updatedCompetencies = updatedLearningObject + .map(learningObject -> learningObject.getCompetencyLinks().stream().map(CompetencyLearningObjectLink::getCompetency).collect(Collectors.toSet())).orElse(Set.of()); Set updatedCompetencyIds = updatedCompetencies.stream().map(CourseCompetency::getId).collect(Collectors.toSet()); Set removedCompetencyIds = originalCompetencyIds.stream().filter(id -> !updatedCompetencyIds.contains(id)).collect(Collectors.toSet()); @@ -210,7 +200,7 @@ public void updateProgressByLearningObjectSync(LearningObject learningObject, Se * @return The updated competency progress, which is also persisted to the database */ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) { - Optional optionalCompetency = courseCompetencyRepository.findByIdWithLectureUnits(competencyId); + Optional optionalCompetency = courseCompetencyRepository.findById(competencyId); if (user == null || optionalCompetency.isEmpty()) { log.debug("User or competency no longer exist, skipping."); @@ -218,12 +208,10 @@ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) } CourseCompetency competency = optionalCompetency.get(); - Set lectureUnits = competency.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)).collect(Collectors.toSet()); - Set exerciseInfos = courseCompetencyRepository.findAllExerciseInfoByCompetencyId(competencyId, user); - int numberOfCompletedLectureUnits = lectureUnitCompletionRepository - .countByLectureUnitIdsAndUserId(competency.getLectureUnits().stream().map(LectureUnit::getId).collect(Collectors.toSet()), user.getId()); + Set exerciseInfos = courseCompetencyRepository.findAllExerciseInfoByCompetencyIdAndUser(competencyId, user); + Set lectureUnitInfos = courseCompetencyRepository.findAllLectureUnitInfoByCompetencyIdAndUser(competencyId, user); - var competencyProgress = competencyProgressRepository.findEagerByCompetencyIdAndUserId(competencyId, user.getId()); + var competencyProgress = competencyProgressRepository.findByCompetencyIdAndUserId(competencyId, user.getId()); if (competencyProgress.isPresent()) { var lastModified = competencyProgress.get().getLastModifiedDate(); @@ -235,8 +223,8 @@ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) var studentProgress = competencyProgress.orElse(new CompetencyProgress()); - calculateProgress(lectureUnits, exerciseInfos, numberOfCompletedLectureUnits, studentProgress); - calculateConfidence(exerciseInfos, studentProgress); + calculateProgress(exerciseInfos, lectureUnitInfos, studentProgress); + calculateConfidence(exerciseInfos, lectureUnitInfos, studentProgress); studentProgress.setCompetency(competency); studentProgress.setUser(user); @@ -261,14 +249,13 @@ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) * The progress for lecture units is the percentage of lecture units completed by the user. * The final progress is a weighted average of the progress in exercises and lecture units. * - * @param lectureUnits The lecture units linked to the competency - * @param exerciseInfos The information about the exercises linked to the competency - * @param numberOfCompletedLectureUnits The number of lecture units completed by the user - * @param competencyProgress The progress entity to update + * @param exerciseInfos The information about the exercises linked to the competency + * @param lectureUnitInfos The information about the lecture units linked to the competency + * @param competencyProgress The progress entity to update */ - private void calculateProgress(Set lectureUnits, Set exerciseInfos, int numberOfCompletedLectureUnits, + private void calculateProgress(Set exerciseInfos, Set lectureUnitInfos, CompetencyProgress competencyProgress) { - double numberOfLearningObjects = lectureUnits.size() + exerciseInfos.size(); + double numberOfLearningObjects = lectureUnitInfos.size() + exerciseInfos.size(); if (numberOfLearningObjects == 0) { // If nothing is linked to the competency, the competency is considered completed competencyProgress.setProgress(100.0); @@ -278,10 +265,11 @@ private void calculateProgress(Set lectureUnits, Set 0 ? achievedPoints / maxPoints * 100 : 0; - double lectureProgress = 100.0 * numberOfCompletedLectureUnits / lectureUnits.size(); + long numberOfCompletedLectureUnits = lectureUnitInfos.stream().filter(CompetencyLectureUnitMasteryCalculationDTO::completed).count(); + double lectureProgress = 100.0 * numberOfCompletedLectureUnits / lectureUnitInfos.size(); double weightedExerciseProgress = exerciseInfos.size() / numberOfLearningObjects * exerciseProgress; - double weightedLectureProgress = lectureUnits.size() / numberOfLearningObjects * lectureProgress; + double weightedLectureProgress = lectureUnitInfos.size() / numberOfLearningObjects * lectureProgress; double progress = weightedExerciseProgress + weightedLectureProgress; // Bonus points can lead to a progress > 100% @@ -293,21 +281,27 @@ private void calculateProgress(Set lectureUnits, Set exerciseInfos, CompetencyProgress competencyProgress) { + private void calculateConfidence(Set exerciseInfos, Set lectureUnitInfos, + CompetencyProgress competencyProgress) { Set participantScoreInfos = exerciseInfos.stream() .filter(info -> info.lastScore() != null && info.lastPoints() != null && info.lastModifiedDate() != null).collect(Collectors.toSet()); double recencyConfidenceHeuristic = calculateRecencyConfidenceHeuristic(participantScoreInfos); double difficultyConfidenceHeuristic = calculateDifficultyConfidenceHeuristic(participantScoreInfos, exerciseInfos); double quickSolveConfidenceHeuristic = calculateQuickSolveConfidenceHeuristic(participantScoreInfos); + double competencyLinkWeightConfidenceHeuristic = calculateCompetencyLinkWeightConfidenceHeuristic(exerciseInfos, lectureUnitInfos, competencyProgress.getProgress()); // Standard factor of 1 (no change to mastery compared to progress) plus the confidence heuristics - double confidence = DEFAULT_CONFIDENCE + recencyConfidenceHeuristic + difficultyConfidenceHeuristic + quickSolveConfidenceHeuristic; + double confidence = DEFAULT_CONFIDENCE + recencyConfidenceHeuristic + difficultyConfidenceHeuristic + quickSolveConfidenceHeuristic + + competencyLinkWeightConfidenceHeuristic; + // Prevent extreme confidence values + confidence = Math.clamp(confidence, 0, MAX_CONFIDENCE); competencyProgress.setConfidence(confidence); - setConfidenceReason(competencyProgress, recencyConfidenceHeuristic, difficultyConfidenceHeuristic, quickSolveConfidenceHeuristic); + setConfidenceReason(competencyProgress, recencyConfidenceHeuristic, difficultyConfidenceHeuristic, quickSolveConfidenceHeuristic, competencyLinkWeightConfidenceHeuristic); } /** @@ -415,43 +409,97 @@ private double calculateQuickSolveConfidenceHeuristic(@NotNull Set exerciseInfos, + Set lectureUnitInfos, double progress) { + double numberOfLearningObjects = lectureUnitInfos.size() + exerciseInfos.size(); + if (numberOfLearningObjects == 0) { + return 0; + } + + double linkWeightedAchievedPoints = exerciseInfos.stream().mapToDouble(info -> info.lastPoints() != null ? info.lastPoints() * info.competencyLinkWeight() : 0).sum(); + double linkWeightedMaxPoints = exerciseInfos.stream().mapToDouble(info -> info.maxPoints() * info.competencyLinkWeight()).sum(); + double linkWeightedExerciseProgress = linkWeightedMaxPoints > 0 ? linkWeightedAchievedPoints / linkWeightedMaxPoints * 100 : 0; + + double linkWeightedCompletedLectureUnits = lectureUnitInfos.stream().mapToDouble(info -> info.completed() ? info.competencyLinkWeight() : 0).sum(); + double linkWeightedLectureUnits = lectureUnitInfos.stream().mapToDouble(CompetencyLectureUnitMasteryCalculationDTO::competencyLinkWeight).sum(); + double linkWeightedLectureProgress = linkWeightedLectureUnits > 0 ? linkWeightedCompletedLectureUnits / linkWeightedLectureUnits * 100 : 0; + + double weightedExerciseProgress = exerciseInfos.size() / numberOfLearningObjects * linkWeightedExerciseProgress; + double weightedLectureProgress = lectureUnitInfos.size() / numberOfLearningObjects * linkWeightedLectureProgress; + + double linkWeightedProgress = weightedExerciseProgress + weightedLectureProgress; + // Bonus points can lead to a progress > 100% + linkWeightedProgress = Math.clamp(Math.round(linkWeightedProgress), 0, 100); + + double linkWeightConfidence = linkWeightedProgress - progress; + + return Math.clamp(linkWeightConfidence, -MAX_CONFIDENCE_HEURISTIC, MAX_CONFIDENCE_HEURISTIC); + } + /** * Find most important heuristic that influences the confidence score and set the confidence reason accordingly. * If the confidence does not deviate significantly from 1, the reason is set to NO_REASON. * - * @param competencyProgress the progress entity add the confidence reason to - * @param recencyConfidence the recency confidence heuristic - * @param difficultyConfidence the difficulty confidence heuristic - * @param quickSolveConfidence the quick solve confidence heuristic + * @param competencyProgress the progress entity add the confidence reason to + * @param recencyConfidence the recency confidence heuristic + * @param difficultyConfidence the difficulty confidence heuristic + * @param quickSolveConfidence the quick solve confidence heuristic + * @param competencyLinkWeightConfidenceHeuristic the competency link weight confidence heuristic */ - private void setConfidenceReason(CompetencyProgress competencyProgress, double recencyConfidence, double difficultyConfidence, double quickSolveConfidence) { + private void setConfidenceReason(CompetencyProgress competencyProgress, double recencyConfidence, double difficultyConfidence, double quickSolveConfidence, + double competencyLinkWeightConfidenceHeuristic) { if (competencyProgress.getConfidence() < DEFAULT_CONFIDENCE - CONFIDENCE_REASON_DEADZONE) { - double minConfidenceHeuristic = Math.min(recencyConfidence, difficultyConfidence); - if (recencyConfidence == minConfidenceHeuristic) { - competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_LOWER); - } - else { - // quickSolveConfidence cannot be negative therefore we don't check it here - competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_EASY_POINTS); - } + setConfidenceReasonLow(competencyProgress, recencyConfidence, difficultyConfidence, competencyLinkWeightConfidenceHeuristic); } else if (competencyProgress.getConfidence() > DEFAULT_CONFIDENCE + CONFIDENCE_REASON_DEADZONE) { - double maxConfidenceHeuristic = Math.max(recencyConfidence, Math.max(difficultyConfidence, quickSolveConfidence)); - if (recencyConfidence == maxConfidenceHeuristic) { - competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_HIGHER); - } - else if (difficultyConfidence == maxConfidenceHeuristic) { - competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_HARD_POINTS); - } - else { - competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.QUICKLY_SOLVED_EXERCISES); - } + setConfidenceReasonHigh(competencyProgress, recencyConfidence, difficultyConfidence, quickSolveConfidence, competencyLinkWeightConfidenceHeuristic); } else { competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.NO_REASON); } } + private void setConfidenceReasonLow(CompetencyProgress competencyProgress, double recencyConfidence, double difficultyConfidence, + double competencyLinkWeightConfidenceHeuristic) { + double minConfidenceHeuristic = DoubleStream.of(recencyConfidence, difficultyConfidence, competencyLinkWeightConfidenceHeuristic).min().getAsDouble(); + if (recencyConfidence == minConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_LOWER); + } + else if (difficultyConfidence == minConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_EASY_POINTS); + } + else { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_LOW_WEIGHTED_EXERCISES); + } + } + + private void setConfidenceReasonHigh(CompetencyProgress competencyProgress, double recencyConfidence, double difficultyConfidence, double quickSolveConfidence, + double competencyLinkWeightConfidenceHeuristic) { + double maxConfidenceHeuristic = DoubleStream.of(recencyConfidence, difficultyConfidence, quickSolveConfidence, competencyLinkWeightConfidenceHeuristic).max().getAsDouble(); + if (recencyConfidence == maxConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_HIGHER); + } + else if (difficultyConfidence == maxConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_HARD_POINTS); + } + else if (quickSolveConfidence == maxConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.QUICKLY_SOLVED_EXERCISES); + } + else { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_HIGH_WEIGHTED_EXERCISES); + } + } + /** * Calculates a user's mastery level for competency given the progress. * @@ -491,8 +539,8 @@ public static boolean isMastered(@NotNull CompetencyProgress competencyProgress) * @return true if the competency can be mastered without completing any exercises, false otherwise */ public static boolean canBeMasteredWithoutExercises(@NotNull CourseCompetency competency) { - double numberOfLectureUnits = competency.getLectureUnits().size(); - double numberOfLearningObjects = numberOfLectureUnits + competency.getExercises().size(); + double numberOfLectureUnits = competency.getLectureUnitLinks().size(); + double numberOfLearningObjects = numberOfLectureUnits + competency.getExerciseLinks().size(); if (numberOfLearningObjects == 0) { return true; } @@ -521,7 +569,8 @@ public void deleteProgressForCompetency(long competencyId) { public CourseCompetencyProgressDTO getCompetencyCourseProgress(@NotNull CourseCompetency competency, @NotNull Course course) { var numberOfStudents = competencyProgressRepository.countByCompetency(competency.getId()); var numberOfMasteredStudents = competencyProgressRepository.countByCompetencyAndMastered(competency.getId(), competency.getMasteryThreshold()); - var averageStudentScore = RoundingUtil.roundScoreSpecifiedByCourseSettings(participantScoreService.getAverageOfAverageScores(competency.getExercises()), course); + Set exercises = competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise).collect(Collectors.toSet()); + var averageStudentScore = RoundingUtil.roundScoreSpecifiedByCourseSettings(participantScoreService.getAverageOfAverageScores(exercises), course); return new CourseCompetencyProgressDTO(competency.getId(), numberOfStudents, numberOfMasteredStudents, averageStudentScore); } } 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 fbe46aa979b0..9c11f0f33fdc 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 @@ -15,6 +15,7 @@ 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.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.CompetencyRepository; @@ -42,9 +43,10 @@ public CompetencyService(CompetencyRepository competencyRepository, Authorizatio LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { + LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, + competencyLectureUnitLinkRepository, courseRepository); this.competencyRepository = competencyRepository; } @@ -81,18 +83,6 @@ public List importStandardizedCompetencies(List competen return super.importStandardizedCompetencies(competencyIdsToImport, course, Competency::new); } - /** - * Creates a new competency and links it to a course and lecture units. - * - * @param competency the competency to create - * @param course the course to link the competency to - * @return the persisted competency - */ - public Competency createCompetency(CourseCompetency competency, Course course) { - Competency competencyToCreate = new Competency(competency); - return createCourseCompetency(competencyToCreate, course); - } - /** * Creates a list of new competencies and links them to a course and lecture units. * 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 ea31bff10fad..8e47f7443297 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 @@ -9,6 +9,7 @@ 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; import java.util.function.Supplier; @@ -20,6 +21,8 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyRelation; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; @@ -28,6 +31,7 @@ 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; @@ -45,6 +49,7 @@ import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; +import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @@ -79,13 +84,15 @@ 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, CourseRepository courseRepository) { + LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { this.competencyProgressRepository = competencyProgressRepository; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyRelationRepository = competencyRelationRepository; @@ -97,6 +104,7 @@ public CourseCompetencyService(CompetencyProgressRepository competencyProgressRe this.standardizedCompetencyRepository = standardizedCompetencyRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.learningObjectImportService = learningObjectImportService; + this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; this.courseRepository = courseRepository; } @@ -177,13 +185,23 @@ public SearchResultPageDTO getOnPageWithSizeForImport(final Co * @param currentUser The user for whom to filter the learning objects */ public void filterOutLearningObjectsThatUserShouldNotSee(CourseCompetency competency, User currentUser) { - competency.setLectureUnits(competency.getLectureUnits().stream().filter(lectureUnit -> authCheckService.isAllowedToSeeLectureUnit(lectureUnit, currentUser)) - .peek(lectureUnit -> lectureUnit.setCompleted(lectureUnit.isCompletedFor(currentUser))).collect(Collectors.toSet())); - - Set exercisesUserIsAllowedToSee = exerciseService.filterOutExercisesThatUserShouldNotSee(competency.getExercises(), currentUser); - Set exercisesWithAllInformationNeeded = exerciseService - .loadExercisesWithInformationForDashboard(exercisesUserIsAllowedToSee.stream().map(Exercise::getId).collect(Collectors.toSet()), currentUser); - competency.setExercises(exercisesWithAllInformationNeeded); + competency.setLectureUnitLinks(competency.getLectureUnitLinks().stream() + .filter(lectureUnitLink -> authCheckService.isAllowedToSeeLectureUnit(lectureUnitLink.getLectureUnit(), currentUser)) + .peek(lectureUnitLink -> lectureUnitLink.getLectureUnit().setCompleted(lectureUnitLink.getLectureUnit().isCompletedFor(currentUser))).collect(Collectors.toSet())); + + Set exercises = competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise).collect(Collectors.toSet()); + Set exerciseIdsUserIsAllowedToSee = exerciseService.filterOutExercisesThatUserShouldNotSee(exercises, currentUser).stream().map(Exercise::getId) + .collect(Collectors.toSet()); + Set exercisesWithAllInformationNeeded = exerciseService.loadExercisesWithInformationForDashboard(exerciseIdsUserIsAllowedToSee, currentUser); + + Set exerciseLinksWithAllInformation = competency.getExerciseLinks().stream() + .filter(exerciseLink -> exerciseIdsUserIsAllowedToSee.contains(exerciseLink.getExercise().getId())).peek(exerciseLink -> { + Optional exerciseWithAllInformationNeeded = exercisesWithAllInformationNeeded.stream() + .filter(exercise -> exercise.getId().equals(exerciseLink.getExercise().getId())).findFirst(); + exerciseWithAllInformationNeeded.ifPresent(exerciseLink::setExercise); + }).collect(Collectors.toSet()); + + competency.setExerciseLinks(exerciseLinksWithAllInformation); } /** @@ -300,11 +318,8 @@ public List importStandardizedCompetencies(List competen */ public C createCourseCompetency(C competencyToCreate, Course course) { competencyToCreate.setCourse(course); - var persistedCompetency = courseCompetencyRepository.save(competencyToCreate); - updateLectureUnits(competencyToCreate, persistedCompetency); - if (course.getLearningPathsEnabled()) { learningPathService.linkCompetencyToLearningPathsOfCourse(persistedCompetency, course.getId()); } @@ -329,8 +344,6 @@ public List createCourseCompetencies(List com createdCompetency.setCourse(course); createdCompetency = courseCompetencyRepository.save(createdCompetency); - updateLectureUnits(competency, createdCompetency); - createdCompetencies.add(createdCompetency); } @@ -356,14 +369,8 @@ public C updateCourseCompetency(C competencyToUpdat competencyToUpdate.setMasteryThreshold(competency.getMasteryThreshold()); competencyToUpdate.setTaxonomy(competency.getTaxonomy()); competencyToUpdate.setOptional(competency.isOptional()); - final var persistedCompetency = courseCompetencyRepository.save(competencyToUpdate); - // update competency progress if necessary - if (competency.getLectureUnits().size() != competencyToUpdate.getLectureUnits().size() || !competencyToUpdate.getLectureUnits().containsAll(competency.getLectureUnits())) { - competencyProgressService.updateProgressByCompetencyAndUsersInCourseAsync(persistedCompetency); - } - - return persistedCompetency; + return courseCompetencyRepository.save(competencyToUpdate); } /** @@ -376,10 +383,11 @@ public C updateCourseCompetency(C competencyToUpdat */ public C findProgressAndLectureUnitCompletionsForUser(C competency, Long userId) { competencyProgressRepository.findByCompetencyIdAndUserId(competency.getId(), userId).ifPresent(progress -> competency.setUserProgress(Set.of(progress))); + Set lectureUnits = competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).collect(Collectors.toSet()); // collect to map lecture unit id -> this - var completions = lectureUnitCompletionRepository.findByLectureUnitsAndUserId(competency.getLectureUnits(), userId).stream() + var completions = lectureUnitCompletionRepository.findByLectureUnitsAndUserId(lectureUnits, userId).stream() .collect(Collectors.toMap(completion -> completion.getLectureUnit().getId(), completion -> completion)); - competency.getLectureUnits().forEach(lectureUnit -> { + lectureUnits.forEach(lectureUnit -> { if (completions.containsKey(lectureUnit.getId())) { lectureUnit.setCompletedUsers(Set.of(completions.get(lectureUnit.getId()))); } @@ -425,8 +433,8 @@ public void deleteCourseCompetency(CourseCompetency courseCompetency, Course cou competencyRelationRepository.deleteAllByCompetencyId(courseCompetency.getId()); competencyProgressService.deleteProgressForCompetency(courseCompetency.getId()); - exerciseService.removeCompetency(courseCompetency.getExercises(), courseCompetency); - lectureUnitService.removeCompetency(courseCompetency.getLectureUnits(), courseCompetency); + exerciseService.removeCompetency(courseCompetency.getExerciseLinks(), courseCompetency); + lectureUnitService.removeCompetency(courseCompetency.getLectureUnitLinks(), courseCompetency); if (course.getLearningPathsEnabled()) { learningPathService.removeLinkedCompetencyFromLearningPathsOfCourse(courseCompetency, course.getId()); @@ -447,11 +455,4 @@ public void checkIfCompetencyBelongsToCourse(long competencyId, long courseId) { throw new BadRequestAlertException("The competency does not belong to the course", ENTITY_NAME, "competencyWrongCourse"); } } - - private void updateLectureUnits(CourseCompetency competency, CourseCompetency createdCompetency) { - if (!competency.getLectureUnits().isEmpty()) { - lectureUnitService.linkLectureUnitsToCompetency(createdCompetency, competency.getLectureUnits(), Set.of()); - competencyProgressService.updateProgressByCompetencyAndUsersInCourseAsync(createdCompetency); - } - } } 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 4bf07e6e42ca..996ebd7d4385 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 @@ -15,6 +15,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; 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.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; @@ -42,9 +43,10 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { + LearningObjectImportService learningObjectImportService, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, + competencyLectureUnitLinkRepository, courseRepository); this.prerequisiteRepository = prerequisiteRepository; } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNavigationService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNavigationService.java index f4d9bc336544..a744a0688446 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNavigationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNavigationService.java @@ -82,7 +82,8 @@ public LearningPathNavigationDTO getNavigation(LearningPath learningPath) { private CourseCompetency findCorrespondingCompetencyForLearningObject(RecommendationState recommendationState, LearningObject learningObject, boolean firstCompetency) { Stream potentialCompetencies = recommendationState.recommendedOrderOfCompetencies().stream() .map(competencyId -> recommendationState.competencyIdMap().get(competencyId)) - .filter(competency -> competency.getLectureUnits().contains(learningObject) || competency.getExercises().contains(learningObject)); + .filter(competency -> competency.getLectureUnitLinks().stream().anyMatch(lul -> lul.getLectureUnit().equals(learningObject)) + || competency.getExerciseLinks().stream().anyMatch(el -> el.getExercise().equals(learningObject))); // There will always be at least one competency that contains the learning object, otherwise the learning object would not be in the learning path Comparator comparator = Comparator.comparingInt(competency -> recommendationState.recommendedOrderOfCompetencies().indexOf(competency.getId())); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNgxService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNgxService.java index 8a3b228a27e5..f0351804c341 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNgxService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathNgxService.java @@ -17,6 +17,8 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyRelation; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; @@ -86,7 +88,7 @@ private void generateNgxGraphRepresentationForCompetency(LearningPath learningPa currentCluster.add(NgxLearningPathDTO.Node.of(endNodeId, NgxLearningPathDTO.NodeType.COMPETENCY_END, competency.getId())); // generate nodes and edges for lecture units - competency.getLectureUnits().forEach(lectureUnit -> { + competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).forEach(lectureUnit -> { currentCluster.add(NgxLearningPathDTO.Node.of(getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), NgxLearningPathDTO.NodeType.LECTURE_UNIT, lectureUnit.getId(), lectureUnit.getLecture().getId(), lectureUnit.isCompletedFor(learningPath.getUser()), lectureUnit.getName())); edges.add(new NgxLearningPathDTO.Edge(getLectureUnitInEdgeId(competency.getId(), lectureUnit.getId()), startNodeId, @@ -95,7 +97,7 @@ private void generateNgxGraphRepresentationForCompetency(LearningPath learningPa endNodeId)); }); // generate nodes and edges for exercises - competency.getExercises().forEach(exercise -> { + competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise).forEach(exercise -> { currentCluster.add(NgxLearningPathDTO.Node.of(getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), false, exercise.getTitle())); edges.add(new NgxLearningPathDTO.Edge(getExerciseInEdgeId(competency.getId(), exercise.getId()), startNodeId, getExerciseNodeId(competency.getId(), exercise.getId()))); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathRecommendationService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathRecommendationService.java index 5fe455ec8ce7..1f3981baa4ce 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathRecommendationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathRecommendationService.java @@ -24,6 +24,8 @@ import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreService; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; @@ -34,9 +36,9 @@ import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.exercise.domain.BaseExercise; import de.tum.cit.aet.artemis.exercise.domain.DifficultyLevel; import de.tum.cit.aet.artemis.exercise.domain.Exercise; -import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; import de.tum.cit.aet.artemis.lecture.service.LearningObjectService; @@ -390,8 +392,8 @@ else if (timeDelta > 0) { * @return earliest due date of the competency */ private static Optional getEarliestDueDate(CourseCompetency competency) { - final var lectureDueDates = competency.getLectureUnits().stream().map(LectureUnit::getLecture).map(Lecture::getEndDate); - final var exerciseDueDates = competency.getExercises().stream().map(Exercise::getDueDate); + final var lectureDueDates = competency.getLectureUnitLinks().stream().map(lectureUnitLink -> lectureUnitLink.getLectureUnit().getLecture().getEndDate()); + final var exerciseDueDates = competency.getExerciseLinks().stream().map(exerciseLink -> exerciseLink.getExercise().getDueDate()); return Stream.concat(Stream.concat(Stream.of(competency.getSoftDueDate()), lectureDueDates), exerciseDueDates).filter(Objects::nonNull).min(Comparator.naturalOrder()); } @@ -466,6 +468,7 @@ public List getRecommendedOrderOfLearningObjects(User user, Cour /** * Analyzes the current progress within the learning path and generates a recommended ordering of uncompleted learning objects in a competency. + * The ordering is based on the competency link weights in decreasing order * * @param user the user that should be analyzed * @param competency the competency @@ -473,7 +476,8 @@ public List getRecommendedOrderOfLearningObjects(User user, Cour * @return the recommended ordering of learning objects */ public List getRecommendedOrderOfLearningObjects(User user, CourseCompetency competency, double combinedPriorConfidence) { - var pendingLectureUnits = competency.getLectureUnits().stream().filter(lectureUnit -> !lectureUnit.isCompletedFor(user)).toList(); + var pendingLectureUnits = competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).filter(lectureUnit -> !lectureUnit.isCompletedFor(user)) + .toList(); List recommendedOrder = new ArrayList<>(pendingLectureUnits); // early return if competency can be trivially mastered @@ -486,10 +490,14 @@ public List getRecommendedOrderOfLearningObjects(User user, Cour final var numberOfRequiredExercisePointsToMaster = calculateNumberOfExercisePointsRequiredToMaster(user, competency, weightedConfidence); - final var pendingExercises = competency.getExercises().stream().filter(exercise -> !learningObjectService.isCompletedByUser(exercise, user)).collect(Collectors.toSet()); - final var pendingExercisePoints = pendingExercises.stream().mapToDouble(Exercise::getMaxPoints).sum(); + // First sort exercises based on title to ensure consistent ordering over multiple calls then prefer higher weighted exercises + final var pendingExercises = competency.getExerciseLinks().stream().filter(link -> !learningObjectService.isCompletedByUser(link.getExercise(), user)) + .sorted(Comparator.comparing(link -> link.getExercise().getTitle())).sorted(Comparator.comparingDouble(CompetencyExerciseLink::getWeight).reversed()) + .map(CompetencyExerciseLink::getExercise).toList(); - Map> difficultyLevelMap = generateDifficultyLevelMap(pendingExercises); + final var pendingExercisePoints = pendingExercises.stream().mapToDouble(BaseExercise::getMaxPoints).sum(); + + Map> difficultyLevelMap = generateDifficultyLevelMap(pendingExercises); if (numberOfRequiredExercisePointsToMaster >= pendingExercisePoints) { scheduleAllExercises(recommendedOrder, difficultyLevelMap); return recommendedOrder; @@ -506,7 +514,7 @@ public List getRecommendedOrderOfLearningObjects(User user, Cour * @param recommendedOrder the list storing the recommended order of learning objects * @param difficultyLevelMap a map from difficulty level to a set of corresponding exercises */ - private void scheduleAllExercises(List recommendedOrder, Map> difficultyLevelMap) { + private void scheduleAllExercises(List recommendedOrder, Map> difficultyLevelMap) { for (var difficulty : DifficultyLevel.values()) { recommendedOrder.addAll(difficultyLevelMap.get(difficulty)); } @@ -520,10 +528,10 @@ private void scheduleAllExercises(List recommendedOrder, Map recommendedOrder, double[] recommendedExercisePointDistribution, - Map> difficultyMap) { - final var easyExercises = new HashSet(); - final var mediumExercises = new HashSet(); - final var hardExercises = new HashSet(); + Map> difficultyMap) { + final var easyExercises = new ArrayList(); + final var mediumExercises = new ArrayList(); + final var hardExercises = new ArrayList(); // choose as many exercises from the correct difficulty level as possible final var missingEasy = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, recommendedExercisePointDistribution[0], easyExercises); @@ -562,8 +570,8 @@ private void scheduleExercisesByDistribution(List recommendedOrd * @param exercises the set to store the selected exercises * @return amount of points that are missing, if negative the amount of points that are selected too much */ - private static double selectExercisesWithDifficulty(Map> difficultyMap, DifficultyLevel difficulty, double exercisePoints, - Set exercises) { + private static double selectExercisesWithDifficulty(Map> difficultyMap, DifficultyLevel difficulty, double exercisePoints, + List exercises) { var remainingExercisePoints = new AtomicDouble(exercisePoints); var selectedExercises = difficultyMap.get(difficulty).stream().takeWhile(exercise -> remainingExercisePoints.getAndAdd(-exercise.getMaxPoints()) >= 0) .collect(Collectors.toSet()); @@ -605,16 +613,16 @@ private static double computeCombinedPriorConfidence(CourseCompetency competency private double calculateNumberOfExercisePointsRequiredToMaster(User user, CourseCompetency competency, double weightedConfidence) { // we assume that the student may perform slightly worse than previously and dampen the confidence for the prediction process weightedConfidence *= 0.9; - double currentPoints = participantScoreService.getStudentAndTeamParticipationPointsAsDoubleStream(user, competency.getExercises()).sum(); - double maxPoints = competency.getExercises().stream().mapToDouble(Exercise::getMaxPoints).sum(); - double lectureUnits = competency.getLectureUnits().size(); - double exercises = competency.getExercises().size(); - double learningObjects = lectureUnits + exercises; + Set exercises = competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise).collect(Collectors.toSet()); + double currentPoints = participantScoreService.getStudentAndTeamParticipationPointsAsDoubleStream(user, exercises).sum(); + double maxPoints = exercises.stream().mapToDouble(Exercise::getMaxPoints).sum(); + double lectureUnits = competency.getLectureUnitLinks().size(); + double learningObjects = lectureUnits + exercises.size(); double masteryThreshold = competency.getMasteryThreshold(); double neededProgress = masteryThreshold / weightedConfidence; double maxLectureUnitProgress = lectureUnits / learningObjects * 100; - double exerciseWeight = exercises / learningObjects; + double exerciseWeight = exercises.size() / learningObjects; double neededTotalExercisePoints = (neededProgress - maxLectureUnitProgress) / exerciseWeight * (maxPoints / 100); double neededExercisePoints = neededTotalExercisePoints - currentPoints; @@ -628,10 +636,10 @@ private double calculateNumberOfExercisePointsRequiredToMaster(User user, Course * @param exercises the exercises that should be contained in the map * @return a map from difficulty level to a set of corresponding exercises */ - private static Map> generateDifficultyLevelMap(Set exercises) { - Map> difficultyLevelMap = new HashMap<>(); + private static Map> generateDifficultyLevelMap(List exercises) { + Map> difficultyLevelMap = new HashMap<>(); for (var difficulty : DifficultyLevel.values()) { - difficultyLevelMap.put(difficulty, new HashSet<>()); + difficultyLevelMap.put(difficulty, new ArrayList<>()); } exercises.forEach(exercise -> { @@ -702,13 +710,15 @@ public List getOrderOfLearningObjectsForCompetency(long competen public List getOrderOfLearningObjectsForCompetency(CourseCompetency competency, User user) { Optional optionalCompetencyProgress = competencyProgressRepository.findByCompetencyIdAndUserId(competency.getId(), user.getId()); competency.setUserProgress(optionalCompetencyProgress.map(Set::of).orElse(Set.of())); - learningObjectService.setLectureUnitCompletions(competency.getLectureUnits(), user); + Set lectureUnits = competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).collect(Collectors.toSet()); + learningObjectService.setLectureUnitCompletions(lectureUnits, user); Set priorCompetencyProgresses = competencyProgressRepository.findAllPriorByCompetencyId(competency, user); double combinedPriorConfidence = priorCompetencyProgresses.stream().mapToDouble(CompetencyProgress::getConfidence).average().orElse(0); double weightedConfidence = computeWeightedConfidence(combinedPriorConfidence, optionalCompetencyProgress); - Stream completedLectureUnits = competency.getLectureUnits().stream().filter(lectureUnit -> lectureUnit.isCompletedFor(user)); - Stream completedExercises = competency.getExercises().stream().filter(exercise -> learningObjectService.isCompletedByUser(exercise, user)); + Stream completedLectureUnits = lectureUnits.stream().filter(lectureUnit -> lectureUnit.isCompletedFor(user)); + Stream completedExercises = competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise) + .filter(exercise -> learningObjectService.isCompletedByUser(exercise, user)); Stream pendingLearningObjects = getRecommendedOrderOfLearningObjects(user, competency, weightedConfidence).stream(); return Stream.concat(completedLectureUnits, Stream.concat(completedExercises, pendingLearningObjects)).toList(); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java index ea2a4bd9ec37..9a1c87429ecf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java @@ -18,6 +18,8 @@ import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyRelation; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; @@ -45,7 +47,6 @@ 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.util.PageUtil; -import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; @@ -430,17 +431,19 @@ public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUs LearningPath learningPath = learningPathRepository.findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(learningPathId); // Remove exercises that are not visible to students - learningPath.getCompetencies() - .forEach(competency -> competency.setExercises(competency.getExercises().stream().filter(Exercise::isVisibleToStudents).collect(Collectors.toSet()))); + learningPath.getCompetencies().forEach(competency -> competency + .setExerciseLinks(competency.getExerciseLinks().stream().filter(exerciseLink -> exerciseLink.getExercise().isVisibleToStudents()).collect(Collectors.toSet()))); // Remove unreleased lecture units as well as exercise units, since they are already retrieved as exercises - learningPath.getCompetencies().forEach(competency -> competency.setLectureUnits(competency.getLectureUnits().stream() - .filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit) && lectureUnit.isVisibleToStudents()).collect(Collectors.toSet()))); + learningPath.getCompetencies() + .forEach(competency -> competency.setLectureUnitLinks(competency.getLectureUnitLinks().stream() + .filter(lectureUnitLink -> !(lectureUnitLink.getLectureUnit() instanceof ExerciseUnit) && lectureUnitLink.getLectureUnit().isVisibleToStudents()) + .collect(Collectors.toSet()))); if (learningPath.getUser() == null) { learningPath.getCompetencies().forEach(competency -> { competency.setUserProgress(Collections.emptySet()); - competency.getLectureUnits().forEach(lectureUnit -> lectureUnit.setCompletedUsers(Collections.emptySet())); - competency.getExercises().forEach(exercise -> exercise.setStudentParticipations(Collections.emptySet())); + competency.getLectureUnitLinks().forEach(lectureUnitLink -> lectureUnitLink.getLectureUnit().setCompletedUsers(Collections.emptySet())); + competency.getExerciseLinks().forEach(exerciseLink -> exerciseLink.getExercise().setStudentParticipations(Collections.emptySet())); }); return learningPath; } @@ -448,10 +451,12 @@ public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUs Set competencyIds = learningPath.getCompetencies().stream().map(CourseCompetency::getId).collect(Collectors.toSet()); Map competencyProgresses = competencyProgressRepository.findAllByCompetencyIdsAndUserId(competencyIds, userId).stream() .collect(Collectors.toMap(progress -> progress.getCompetency().getId(), cp -> cp)); - Set lectureUnits = learningPath.getCompetencies().stream().flatMap(competency -> competency.getLectureUnits().stream()).collect(Collectors.toSet()); + Set lectureUnits = learningPath.getCompetencies().stream() + .flatMap(competency -> competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit)).collect(Collectors.toSet()); Map completions = lectureUnitCompletionRepository.findByLectureUnitsAndUserId(lectureUnits, userId).stream() .collect(Collectors.toMap(completion -> completion.getLectureUnit().getId(), cp -> cp)); - Set exerciseIds = learningPath.getCompetencies().stream().flatMap(competency -> competency.getExercises().stream()).map(Exercise::getId).collect(Collectors.toSet()); + Set exerciseIds = learningPath.getCompetencies().stream().flatMap(competency -> competency.getExerciseLinks().stream()) + .map(exerciseLink -> exerciseLink.getExercise().getId()).collect(Collectors.toSet()); Map studentParticipations = studentParticipationRepository.findDistinctAllByExerciseIdInAndStudentId(exerciseIds, userId).stream() .collect(Collectors.toMap(participation -> participation.getExercise().getId(), sp -> sp)); learningPath.getCompetencies().forEach(competency -> { @@ -461,7 +466,7 @@ public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUs else { competency.setUserProgress(Collections.emptySet()); } - competency.getLectureUnits().forEach(lectureUnit -> { + competency.getLectureUnitLinks().stream().map(CompetencyLectureUnitLink::getLectureUnit).forEach(lectureUnit -> { if (completions.containsKey(lectureUnit.getId())) { lectureUnit.setCompletedUsers(Set.of(completions.get(lectureUnit.getId()))); } @@ -469,7 +474,7 @@ public LearningPath findWithCompetenciesAndReleasedLearningObjectsAndCompletedUs lectureUnit.setCompletedUsers(Collections.emptySet()); } }); - competency.getExercises().forEach(exercise -> { + competency.getExerciseLinks().stream().map(CompetencyExerciseLink::getExercise).forEach(exercise -> { if (studentParticipations.containsKey(exercise.getId())) { exercise.setStudentParticipations(Set.of(studentParticipations.get(exercise.getId()))); } 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 aa1a9f78dc0e..9bfb3a92acfe 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 @@ -43,7 +43,6 @@ import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; -import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @Profile(PROFILE_CORE) @RestController @@ -67,21 +66,18 @@ public class CompetencyResource { private final CompetencyService competencyService; - private final LectureUnitService lectureUnitService; - private final CourseCompetencyRepository courseCompetencyRepository; private final CourseCompetencyService courseCompetencyService; public CompetencyResource(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, - CompetencyRepository competencyRepository, CompetencyService competencyService, LectureUnitService lectureUnitService, - CourseCompetencyRepository courseCompetencyRepository, CourseCompetencyService courseCompetencyService) { + CompetencyRepository competencyRepository, CompetencyService competencyService, CourseCompetencyRepository courseCompetencyRepository, + CourseCompetencyService courseCompetencyService) { this.courseRepository = courseRepository; this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; this.competencyRepository = competencyRepository; this.competencyService = competencyService; - this.lectureUnitService = lectureUnitService; this.courseCompetencyRepository = courseCompetencyRepository; this.courseCompetencyService = courseCompetencyService; } @@ -301,7 +297,6 @@ public ResponseEntity updateCompetency(@PathVariable long courseId, checkCourseForCompetency(course, existingCompetency); var persistedCompetency = competencyService.updateCourseCompetency(existingCompetency, competency); - lectureUnitService.linkLectureUnitsToCompetency(persistedCompetency, competency.getLectureUnits(), existingCompetency.getLectureUnits()); return ResponseEntity.ok(persistedCompetency); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java index a9be53c3c4a4..4f75c618282d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java @@ -44,7 +44,6 @@ import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; -import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; /** * REST controller for managing {@link Prerequisite Prerequisite} entities. @@ -71,21 +70,18 @@ public class PrerequisiteResource { private final PrerequisiteService prerequisiteService; - private final LectureUnitService lectureUnitService; - private final CourseCompetencyRepository courseCompetencyRepository; private final CourseCompetencyService courseCompetencyService; public PrerequisiteResource(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, - PrerequisiteRepository prerequisiteRepository, PrerequisiteService prerequisiteService, LectureUnitService lectureUnitService, - CourseCompetencyRepository courseCompetencyRepository, CourseCompetencyService courseCompetencyService) { + PrerequisiteRepository prerequisiteRepository, PrerequisiteService prerequisiteService, CourseCompetencyRepository courseCompetencyRepository, + CourseCompetencyService courseCompetencyService) { this.courseRepository = courseRepository; this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; this.prerequisiteRepository = prerequisiteRepository; this.prerequisiteService = prerequisiteService; - this.lectureUnitService = lectureUnitService; this.courseCompetencyRepository = courseCompetencyRepository; this.courseCompetencyService = courseCompetencyService; } @@ -305,7 +301,6 @@ public ResponseEntity updatePrerequisite(@PathVariable long course checkCourseForPrerequisite(course, existingPrerequisite); var persistedPrerequisite = prerequisiteService.updateCourseCompetency(existingPrerequisite, prerequisite); - lectureUnitService.linkLectureUnitsToCompetency(persistedPrerequisite, prerequisite.getLectureUnits(), existingPrerequisite.getLectureUnits()); return ResponseEntity.ok(persistedPrerequisite); } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentDTO.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentDTO.java new file mode 100644 index 000000000000..2ffa0f2daa61 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentDTO.java @@ -0,0 +1,10 @@ +package de.tum.cit.aet.artemis.buildagent.dto; + +import java.io.Serial; +import java.io.Serializable; + +public record BuildAgentDTO(String name, String memberAddress, String displayName) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java index ab24012f51fa..40af5049060e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java @@ -11,7 +11,7 @@ // in the future are migrated or cleared. Changes should be communicated in release notes as potentially breaking changes. @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, +public record BuildAgentInformation(BuildAgentDTO buildAgent, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, BuildAgentStatus status, List recentBuildJobs, String publicSshKey) implements Serializable { @Serial @@ -24,7 +24,7 @@ public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJ * @param recentBuildJobs The list of recent build jobs */ public BuildAgentInformation(BuildAgentInformation agentInformation, List recentBuildJobs) { - this(agentInformation.name(), agentInformation.maxNumberOfConcurrentBuildJobs(), agentInformation.numberOfCurrentBuildJobs(), agentInformation.runningBuildJobs, + this(agentInformation.buildAgent(), agentInformation.maxNumberOfConcurrentBuildJobs(), agentInformation.numberOfCurrentBuildJobs(), agentInformation.runningBuildJobs, agentInformation.status(), recentBuildJobs, agentInformation.publicSshKey()); } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java index d9bfff039c2e..7a4220399819 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java @@ -14,7 +14,7 @@ // in the future are migrated or cleared. Changes should be communicated in release notes as potentially breaking changes. @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record BuildJobQueueItem(String id, String name, String buildAgentAddress, long participationId, long courseId, long exerciseId, int retryCount, int priority, +public record BuildJobQueueItem(String id, String name, BuildAgentDTO buildAgent, long participationId, long courseId, long exerciseId, int retryCount, int priority, BuildStatus status, RepositoryInfo repositoryInfo, JobTimingInfo jobTimingInfo, BuildConfig buildConfig, ResultDTO submissionResult) implements Serializable { @Serial @@ -28,7 +28,7 @@ public record BuildJobQueueItem(String id, String name, String buildAgentAddress * @param status The status/result of the build job */ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildCompletionDate, BuildStatus status) { - this(queueItem.id(), queueItem.name(), queueItem.buildAgentAddress(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), + this(queueItem.id(), queueItem.name(), queueItem.buildAgent(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), queueItem.priority(), status, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), queueItem.jobTimingInfo.buildStartDate(), buildCompletionDate), queueItem.buildConfig(), null); } @@ -36,17 +36,16 @@ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildComplet /** * Constructor used to create a new processing build job from a queued build job * - * @param queueItem The queued build job - * @param hazelcastMemberAddress The address of the hazelcast member that is processing the build job + * @param queueItem The queued build job + * @param buildAgent The build agent that will process the build job */ - public BuildJobQueueItem(BuildJobQueueItem queueItem, String hazelcastMemberAddress) { - this(queueItem.id(), queueItem.name(), hazelcastMemberAddress, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), - queueItem.priority(), null, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), - queueItem.buildConfig(), null); + public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent) { + this(queueItem.id(), queueItem.name(), buildAgent, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), queueItem.priority(), + null, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), queueItem.buildConfig(), null); } public BuildJobQueueItem(BuildJobQueueItem queueItem, ResultDTO submissionResult) { - this(queueItem.id(), queueItem.name(), queueItem.buildAgentAddress(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), + this(queueItem.id(), queueItem.name(), queueItem.buildAgent(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), queueItem.priority(), queueItem.status(), queueItem.repositoryInfo(), queueItem.jobTimingInfo(), queueItem.buildConfig(), submissionResult); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java index b7c97daa9786..5d4c72d5b491 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java @@ -300,16 +300,17 @@ public void populateBuildJobContainer(String buildJobContainerId, Path assignmen executeDockerCommand(buildJobContainerId, null, false, false, true, "chmod", "-R", "777", LOCALCI_WORKING_DIRECTORY + "/testing-dir"); // Copy the test repository to the container and move it to the test checkout path (may be the working directory) - addAndPrepareDirectory(buildJobContainerId, testRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + testCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, testRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + testCheckoutPath); // Copy the assignment repository to the container and move it to the assignment checkout path - addAndPrepareDirectory(buildJobContainerId, assignmentRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + assignmentCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, assignmentRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + assignmentCheckoutPath); if (solutionRepositoryPath != null) { solutionCheckoutPath = (!StringUtils.isBlank(solutionCheckoutPath)) ? solutionCheckoutPath : RepositoryCheckoutPath.SOLUTION.forProgrammingLanguage(programmingLanguage); - addAndPrepareDirectory(buildJobContainerId, solutionRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + solutionCheckoutPath); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, solutionRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + solutionCheckoutPath); } for (int i = 0; i < auxiliaryRepositoriesPaths.length; i++) { - addAndPrepareDirectory(buildJobContainerId, auxiliaryRepositoriesPaths[i], LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + auxiliaryRepositoryCheckoutDirectories[i]); + addAndPrepareDirectoryAndReplaceContent(buildJobContainerId, auxiliaryRepositoriesPaths[i], + LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + auxiliaryRepositoryCheckoutDirectories[i]); } createScriptFile(buildJobContainerId); @@ -320,12 +321,17 @@ private void createScriptFile(String buildJobContainerId) { executeDockerCommand(buildJobContainerId, null, false, false, true, "bash", "-c", "chmod +x " + LOCALCI_WORKING_DIRECTORY + "/script.sh"); } - private void addAndPrepareDirectory(String containerId, Path repositoryPath, String newDirectoryName) { + private void addAndPrepareDirectoryAndReplaceContent(String containerId, Path repositoryPath, String newDirectoryName) { copyToContainer(repositoryPath.toString(), containerId); - addDirectory(containerId, getParentFolderPath(newDirectoryName), true); + addDirectory(containerId, newDirectoryName, true); + removeDirectoryAndFiles(containerId, newDirectoryName); renameDirectoryOrFile(containerId, LOCALCI_WORKING_DIRECTORY + "/" + repositoryPath.getFileName().toString(), newDirectoryName); } + private void removeDirectoryAndFiles(String containerId, String newName) { + executeDockerCommand(containerId, null, false, false, true, "rm", "-rf", newName); + } + private void renameDirectoryOrFile(String containerId, String oldName, String newName) { executeDockerCommand(containerId, null, false, false, true, "mv", oldName, newName); } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index e73f8bac244b..0823ec5a4f9b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -25,6 +25,7 @@ import jakarta.annotation.PreDestroy; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -45,6 +46,7 @@ import com.hazelcast.map.IMap; import com.hazelcast.topic.ITopic; +import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentDTO; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.BuildResult; @@ -116,9 +118,15 @@ public class SharedQueueProcessingService { */ private final AtomicBoolean processResults = new AtomicBoolean(true); - @Value("${artemis.continuous-integration.pause-grace-period-seconds:15}") + @Value("${artemis.continuous-integration.pause-grace-period-seconds:60}") private int pauseGracePeriodSeconds; + @Value("${artemis.continuous-integration.build-agent.short-name}") + private String buildAgentShortName; + + @Value("${artemis.continuous-integration.build-agent.display-name:}") + private String buildAgentDisplayName; + public SharedQueueProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ExecutorService localCIBuildExecutorService, BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap, BuildAgentSshKeyService buildAgentSSHKeyService, TaskScheduler taskScheduler) { this.hazelcastInstance = hazelcastInstance; @@ -134,6 +142,17 @@ public SharedQueueProcessingService(@Qualifier("hazelcastInstance") HazelcastIns */ @EventListener(ApplicationReadyEvent.class) public void init() { + if (!buildAgentShortName.matches("^[a-z0-9-]+$")) { + String errorMessage = "Build agent short name must not be empty and only contain lowercase letters, numbers and hyphens." + + " Build agent short name should be changed in the application properties under 'artemis.continuous-integration.build-agent.short-name'."; + log.error(errorMessage); + throw new IllegalArgumentException(errorMessage); + } + + if (StringUtils.isBlank(buildAgentDisplayName)) { + buildAgentDisplayName = buildAgentShortName; + } + this.buildAgentInformation = this.hazelcastInstance.getMap("buildAgentInformation"); this.processingJobs = this.hazelcastInstance.getMap("processingJobs"); this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); @@ -154,14 +173,14 @@ public void init() { ITopic pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); pauseBuildAgentTopic.addMessageListener(message -> { - if (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + if (buildAgentShortName.equals(message.getMessageObject())) { pauseBuildAgent(); } }); ITopic resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); resumeBuildAgentTopic.addMessageListener(message -> { - if (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + if (buildAgentShortName.equals(message.getMessageObject())) { resumeBuildAgent(); } }); @@ -201,6 +220,7 @@ public void updateBuildAgentInformation() { log.debug("There are only lite member in the cluster. Not updating build agent information."); return; } + // Remove build agent information of offline nodes removeOfflineNodes(); @@ -253,7 +273,7 @@ private void checkAvailabilityAndProcessNextBuild() { if (buildJob != null) { processingJobs.remove(buildJob.id()); - buildJob = new BuildJobQueueItem(buildJob, ""); + buildJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO("", "", "")); log.info("Adding build job back to the queue: {}", buildJob); queue.add(buildJob); localProcessingJobs.decrementAndGet(); @@ -275,7 +295,7 @@ private BuildJobQueueItem addToProcessingJobs() { if (buildJob != null) { String hazelcastMemberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, hazelcastMemberAddress); + BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName)); processingJobs.put(processingJob.id(), processingJob); localProcessingJobs.incrementAndGet(); @@ -297,10 +317,10 @@ private void updateLocalBuildAgentInformationWithRecentJob(BuildJobQueueItem rec // Add/update BuildAgentInformation info = getUpdatedLocalBuildAgentInformation(recentBuildJob); try { - buildAgentInformation.put(info.name(), info); + buildAgentInformation.put(info.buildAgent().memberAddress(), info); } catch (Exception e) { - log.error("Error while updating build agent information for agent {}", info.name(), e); + log.error("Error while updating build agent information for agent {} with address {}", info.buildAgent().name(), info.buildAgent().memberAddress(), e); } } finally { @@ -334,11 +354,13 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue String publicSshKey = buildAgentSSHKeyService.getPublicKeyAsString(); - return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, status, recentBuildJobs, publicSshKey); + BuildAgentDTO agentInfo = new BuildAgentDTO(buildAgentShortName, memberAddress, buildAgentDisplayName); + + return new BuildAgentInformation(agentInfo, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, status, recentBuildJobs, publicSshKey); } private List getProcessingJobsOfNode(String memberAddress) { - return processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgentAddress(), memberAddress)).toList(); + return processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgent().memberAddress(), memberAddress)).toList(); } private void removeOfflineNodes() { @@ -371,7 +393,7 @@ private void processBuild(BuildJobQueueItem buildJob) { log.debug("Build job completed: {}", buildJob); JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now()); - BuildJobQueueItem finishedJob = new BuildJobQueueItem(buildJob.id(), buildJob.name(), buildJob.buildAgentAddress(), buildJob.participationId(), buildJob.courseId(), + BuildJobQueueItem finishedJob = new BuildJobQueueItem(buildJob.id(), buildJob.name(), buildJob.buildAgent(), buildJob.participationId(), buildJob.courseId(), buildJob.exerciseId(), buildJob.retryCount(), buildJob.priority(), BuildStatus.SUCCESSFUL, buildJob.repositoryInfo(), jobTimingInfo, buildJob.buildConfig(), null); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java index d40778fbaae6..db460a6b27c6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/CustomPostRepository.java @@ -1,11 +1,17 @@ package de.tum.cit.aet.artemis.communication.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.communication.domain.Post; +@Profile(PROFILE_CORE) +@Repository public interface CustomPostRepository { Page findPostIdsWithSpecification(Specification specification, Pageable pageable); 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 bd8bb8989995..0361014a2076 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 @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; /** @@ -30,6 +31,8 @@ public interface FaqRepository extends ArtemisJpaRepository { """) Set findAllCategoriesByCourseId(@Param("courseId") Long courseId); + Set findAllByCourseIdAndFaqState(Long courseId, FaqState faqState); + @Transactional @Modifying void deleteAllByCourseId(Long courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java index 91a542aaa220..4f67dbb77ef6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -23,14 +23,17 @@ import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; 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.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.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; +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.util.HeaderUtil; @@ -56,10 +59,9 @@ public class FaqResource { private final FaqRepository faqRepository; public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository) { - + this.faqRepository = faqRepository; this.courseRepository = courseRepository; this.authCheckService = authCheckService; - this.faqRepository = faqRepository; } /** @@ -72,18 +74,16 @@ public FaqResource(CourseRepository courseRepository, AuthorizationCheckService * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/faqs") - @EnforceAtLeastInstructor + @EnforceAtLeastTutorInCourse public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long courseId) throws URISyntaxException { log.debug("REST request to save Faq : {}", faq); if (faq.getId() != null) { throw new BadRequestAlertException("A new faq cannot already have an ID", ENTITY_NAME, "idExists"); } - + checkPriviledgeForAcceptedElseThrow(faq, courseId); if (faq.getCourse() == null || !faq.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("Course ID in path and FAQ do not match", ENTITY_NAME, "courseIdMismatch"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); - Faq savedFaq = faqRepository.save(faq); FaqDTO dto = new FaqDTO(savedFaq); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/faqs/" + savedFaq.getId())).body(dto); @@ -99,14 +99,15 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long * if the faq is not valid or if the faq course id does not match with the path variable */ @PutMapping("courses/{courseId}/faqs/{faqId}") - @EnforceAtLeastInstructor + @EnforceAtLeastTutorInCourse public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long faqId, @PathVariable Long courseId) { log.debug("REST request to update Faq : {}", faq); if (faqId == null || !faqId.equals(faq.getId())) { throw new BadRequestAlertException("Id of FAQ and path must match", ENTITY_NAME, "idNull"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + checkPriviledgeForAcceptedElseThrow(faq, courseId); Faq existingFaq = faqRepository.findByIdElseThrow(faqId); + checkPriviledgeForAcceptedElseThrow(existingFaq, courseId); if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } @@ -115,6 +116,19 @@ public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long return ResponseEntity.ok().body(dto); } + /** + * @param faq the faq to be checked * + * @param courseId the id of the course the faq belongs to + * @throws AccessForbiddenException if the user is not an instructor + * + */ + private void checkPriviledgeForAcceptedElseThrow(Faq faq, Long courseId) { + if (faq.getFaqState() == FaqState.ACCEPTED) { + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + } + } + /** * GET /courses/:courseId/faqs/:faqId : get the faq with the id faqId. * @@ -123,14 +137,13 @@ public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long * @return the ResponseEntity with status 200 (OK) and with body the faq, or with status 404 (Not Found) */ @GetMapping("courses/{courseId}/faqs/{faqId}") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity getFaq(@PathVariable Long faqId, @PathVariable Long courseId) { log.debug("REST request to get faq {}", faqId); Faq faq = faqRepository.findByIdElseThrow(faqId); if (faq.getCourse() == null || !faq.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("Course ID in path and FAQ do not match", ENTITY_NAME, "courseIdMismatch"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, faq.getCourse(), null); FaqDTO dto = new FaqDTO(faq); return ResponseEntity.ok(dto); } @@ -143,12 +156,11 @@ public ResponseEntity getFaq(@PathVariable Long faqId, @PathVariable Lon * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("courses/{courseId}/faqs/{faqId}") - @EnforceAtLeastInstructor + @EnforceAtLeastInstructorInCourse public ResponseEntity deleteFaq(@PathVariable Long faqId, @PathVariable Long courseId) { log.debug("REST request to delete faq {}", faqId); Faq existingFaq = faqRepository.findByIdElseThrow(faqId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, existingFaq.getCourse(), null); if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); } @@ -163,17 +175,30 @@ public ResponseEntity deleteFaq(@PathVariable Long faqId, @PathVariable Lo * @return the ResponseEntity with status 200 (OK) and the list of faqs in body */ @GetMapping("courses/{courseId}/faqs") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity> getFaqForCourse(@PathVariable Long courseId) { log.debug("REST request to get all Faqs for the course with id : {}", courseId); - - Course course = courseRepository.findByIdElseThrow(courseId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); Set faqs = faqRepository.findAllByCourseId(courseId); Set faqDTOS = faqs.stream().map(FaqDTO::new).collect(Collectors.toSet()); return ResponseEntity.ok().body(faqDTOS); } + /** + * GET /courses/:courseId/faq-status/:faqState : get all the faqs of a course in the specified status + * + * @param courseId the courseId of the course for which all faqs should be returned + * @param faqState the state of all returned FAQs + * @return the ResponseEntity with status 200 (OK) and the list of faqs in body + */ + @GetMapping("courses/{courseId}/faq-state/{faqState}") + @EnforceAtLeastStudentInCourse + public ResponseEntity> getAllFaqsForCourseByStatus(@PathVariable Long courseId, @PathVariable FaqState faqState) { + log.debug("REST request to get all Faqs for the course with id : " + courseId + "and status " + faqState, courseId); + Set faqs = faqRepository.findAllByCourseIdAndFaqState(courseId, faqState); + Set faqDTOS = faqs.stream().map(FaqDTO::new).collect(Collectors.toSet()); + return ResponseEntity.ok().body(faqDTOS); + } + /** * GET /courses/:courseId/faq-categories : get all the faq categories of a course * @@ -181,12 +206,9 @@ public ResponseEntity> getFaqForCourse(@PathVariable Long courseId) * @return the ResponseEntity with status 200 (OK) and the list of faqs in body */ @GetMapping("courses/{courseId}/faq-categories") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity> getFaqCategoriesForCourse(@PathVariable Long courseId) { log.debug("REST request to get all Faq Categories for the course with id : {}", courseId); - - Course course = courseRepository.findByIdElseThrow(courseId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); Set faqs = faqRepository.findAllCategoriesByCourseId(courseId); return ResponseEntity.ok().body(faqs); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 3f61f7de5ff0..c3f2ddc1c320 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -108,6 +108,9 @@ public final class Constants { public static final long MAX_NUMBER_OF_LOCKED_SUBMISSIONS_PER_TUTOR = 10; + // Note: The values in input.constants.ts (client) need to be the same + public static final long MAX_FILE_SIZE_COMMUNICATION = 5 * 1024 * 1024; // 5 MB + // Note: The values in input.constants.ts (client) need to be the same public static final long MAX_SUBMISSION_FILE_SIZE = 8 * 1024 * 1024; // 8 MB diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMRequest.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMRequest.java new file mode 100644 index 000000000000..040b6ad88893 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMRequest.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.core.domain; + +/** + * This record is used for the LLMTokenUsageService to provide relevant information about LLM Token usage + * + * @param model LLM model (e.g. gpt-4o) + * @param numInputTokens number of tokens of the LLM call + * @param costPerMillionInputToken cost in Euro per million input tokens + * @param numOutputTokens number of tokens of the LLM answer + * @param costPerMillionOutputToken cost in Euro per million output tokens + * @param pipelineId String with the pipeline name (e.g. IRIS_COURSE_CHAT_PIPELINE) + */ +public record LLMRequest(String model, int numInputTokens, float costPerMillionInputToken, int numOutputTokens, float costPerMillionOutputToken, String pipelineId) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMServiceType.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMServiceType.java new file mode 100644 index 000000000000..22465bc57b5f --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMServiceType.java @@ -0,0 +1,8 @@ +package de.tum.cit.aet.artemis.core.domain; + +/** + * Enum representing different types of LLM (Large Language Model) services used in the system. + */ +public enum LLMServiceType { + IRIS, ATHENA +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageRequest.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageRequest.java new file mode 100644 index 000000000000..81d7ca8f21a8 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageRequest.java @@ -0,0 +1,104 @@ +package de.tum.cit.aet.artemis.core.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents the token usage details of a single LLM request, including model, service pipeline, token counts, and costs. + */ +@Entity +@Table(name = "llm_token_usage_request") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class LLMTokenUsageRequest extends DomainObject { + + /** + * LLM model (e.g. gpt-4o) + */ + @Column(name = "model") + private String model; + + /** + * pipeline that was called (e.g. IRIS_COURSE_CHAT_PIPELINE) + */ + @Column(name = "service_pipeline_id") + private String servicePipelineId; + + @Column(name = "num_input_tokens") + private int numInputTokens; + + @Column(name = "cost_per_million_input_tokens") + private float costPerMillionInputTokens; + + @Column(name = "num_output_tokens") + private int numOutputTokens; + + @Column(name = "cost_per_million_output_tokens") + private float costPerMillionOutputTokens; + + @ManyToOne + private LLMTokenUsageTrace trace; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getServicePipelineId() { + return servicePipelineId; + } + + public void setServicePipelineId(String servicePipelineId) { + this.servicePipelineId = servicePipelineId; + } + + public float getCostPerMillionInputTokens() { + return costPerMillionInputTokens; + } + + public void setCostPerMillionInputTokens(float costPerMillionInputToken) { + this.costPerMillionInputTokens = costPerMillionInputToken; + } + + public float getCostPerMillionOutputTokens() { + return costPerMillionOutputTokens; + } + + public void setCostPerMillionOutputTokens(float costPerMillionOutputToken) { + this.costPerMillionOutputTokens = costPerMillionOutputToken; + } + + public int getNumInputTokens() { + return numInputTokens; + } + + public void setNumInputTokens(int numInputTokens) { + this.numInputTokens = numInputTokens; + } + + public int getNumOutputTokens() { + return numOutputTokens; + } + + public void setNumOutputTokens(int numOutputTokens) { + this.numOutputTokens = numOutputTokens; + } + + public LLMTokenUsageTrace getTrace() { + return trace; + } + + public void setTrace(LLMTokenUsageTrace trace) { + this.trace = trace; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageTrace.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageTrace.java new file mode 100644 index 000000000000..1773a0c507da --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/LLMTokenUsageTrace.java @@ -0,0 +1,111 @@ +package de.tum.cit.aet.artemis.core.domain; + +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.Set; + +import jakarta.annotation.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * This represents a trace that contains one or more requests of type {@link LLMTokenUsageRequest} + */ +@Entity +@Table(name = "llm_token_usage_trace") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class LLMTokenUsageTrace extends DomainObject { + + @Column(name = "service") + @Enumerated(EnumType.STRING) + private LLMServiceType serviceType; + + @Nullable + @Column(name = "course_id") + private Long courseId; + + @Nullable + @Column(name = "exercise_id") + private Long exerciseId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "time") + private ZonedDateTime time = ZonedDateTime.now(); + + @Nullable + @Column(name = "iris_message_id") + private Long irisMessageId; + + @OneToMany(mappedBy = "trace", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Set llmRequests = new HashSet<>(); + + public LLMServiceType getServiceType() { + return serviceType; + } + + public void setServiceType(LLMServiceType serviceType) { + this.serviceType = serviceType; + } + + public Long getCourseId() { + return courseId; + } + + public void setCourseId(Long courseId) { + this.courseId = courseId; + } + + public Long getExerciseId() { + return exerciseId; + } + + public void setExerciseId(Long exerciseId) { + this.exerciseId = exerciseId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public ZonedDateTime getTime() { + return time; + } + + public void setTime(ZonedDateTime time) { + this.time = time; + } + + public Set getLLMRequests() { + return llmRequests; + } + + public void setLlmRequests(Set llmRequests) { + this.llmRequests = llmRequests; + } + + public Long getIrisMessageId() { + return irisMessageId; + } + + public void setIrisMessageId(Long messageId) { + this.irisMessageId = messageId; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseExistingExerciseDetailsDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseExistingExerciseDetailsDTO.java new file mode 100644 index 000000000000..cae411dfb905 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseExistingExerciseDetailsDTO.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.core.dto; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseExistingExerciseDetailsDTO(Set exerciseTitles, Set shortNames) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java new file mode 100644 index 000000000000..c0b003e668bc --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.core.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * DTO for representing archived courses from previous semesters. + * + * @param id The id of the course + * @param title The title of the course + * @param semester The semester in which the course was offered + * @param color The background color of the course + * @param icon The icon of the course + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseForArchiveDTO(long id, String title, String semester, String color, String icon) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java index 4e3f3f0466af..70a1078fbf7b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/AuthorityRepository.java @@ -1,13 +1,20 @@ package de.tum.cit.aet.artemis.core.repository; +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.stereotype.Repository; + import de.tum.cit.aet.artemis.core.domain.Authority; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; /** * Spring Data JPA repository for the Authority entity. */ +@Profile(PROFILE_CORE) +@Repository public interface AuthorityRepository extends ArtemisJpaRepository { /** 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 ad4c3ab139f5..b7b34537848d 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 @@ -25,6 +25,7 @@ import de.tum.cit.aet.artemis.core.domain.CourseInformationSharingConfiguration; 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.StatisticsEntry; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -542,4 +543,30 @@ SELECT COUNT(c) > 0 """) boolean hasLearningPathsEnabled(@Param("courseId") long courseId); + /** + * Retrieves all courses that the user has access to based on their role + * or if they are an admin. Filters out any courses that do not belong to + * a specific semester (i.e., have a null semester). + * + * @param isAdmin A boolean flag indicating whether the user is an admin + * @param groups A set of groups that the user belongs to + * @param now The current time to check if the course is still active + * @return A set of courses that the user has access to and belong to a specific semester + */ + @Query(""" + SELECT new de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO(c.id, c.title, c.semester, c.color, c.courseIcon) + FROM Course c + WHERE (:isAdmin = TRUE + OR c.studentGroupName IN :groups + OR c.teachingAssistantGroupName IN :groups + OR c.editorGroupName IN :groups + OR c.instructorGroupName IN :groups + ) + AND c.semester IS NOT NULL + AND c.endDate IS NOT NULL + AND c.endDate < :now + """) + Set findInactiveCoursesForUserRolesWithNonNullSemester(@Param("isAdmin") boolean isAdmin, @Param("groups") Set groups, + @Param("now") ZonedDateTime now); + } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageRequestRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageRequestRepository.java new file mode 100644 index 000000000000..145383bf124a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageRequestRepository.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.core.repository; + +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.LLMTokenUsageRequest; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +public interface LLMTokenUsageRequestRepository extends ArtemisJpaRepository { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageTraceRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageTraceRepository.java new file mode 100644 index 000000000000..cc1b0e588c4e --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/LLMTokenUsageTraceRepository.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.core.repository; + +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.LLMTokenUsageTrace; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +public interface LLMTokenUsageTraceRepository extends ArtemisJpaRepository { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java index 71b6b9c1a8c4..12ff470bed9f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/MigrationChangeRepository.java @@ -1,7 +1,14 @@ package de.tum.cit.aet.artemis.core.repository; +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.MigrationChangelog; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +@Profile(PROFILE_CORE) +@Repository public interface MigrationChangeRepository extends ArtemisJpaRepository { } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java index 9c4c133fe6da..c2afe2117540 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/PersistenceAuditEventRepository.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.core.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.time.Instant; @@ -9,12 +10,14 @@ import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; 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.core.domain.PersistentAuditEvent; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -22,6 +25,8 @@ /** * Spring Data JPA repository for the PersistentAuditEvent entity. */ +@Profile(PROFILE_CORE) +@Repository public interface PersistenceAuditEventRepository extends ArtemisJpaRepository { @EntityGraph(type = LOAD, attributePaths = { "data" }) 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 cba16e58ad7e..a286744dbe8a 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 @@ -72,6 +72,7 @@ import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseContentCount; import de.tum.cit.aet.artemis.core.dto.CourseDeletionSummaryDTO; +import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO; import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO; import de.tum.cit.aet.artemis.core.dto.DueDateStat; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; @@ -692,6 +693,18 @@ public List getAllCoursesForManagementOverview(boolean onlyActive) { return courseRepository.findAllCoursesByManagementGroupNames(userGroups); } + /** + * Retrieves all inactive courses from non-null semesters that the current user is enrolled in + * for the course archive. + * + * @return A list of courses for the course archive. + */ + public Set getAllCoursesForCourseArchive() { + var user = userRepository.getUserWithGroupsAndAuthorities(); + boolean isAdmin = authCheckService.isAdmin(user); + return courseRepository.findInactiveCoursesForUserRolesWithNonNullSemester(isAdmin, user.getGroups(), ZonedDateTime.now()); + } + /** * Get the active students for these particular exercise ids * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java index efb8bdc4b1b6..f2c628197ea8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/FilePathService.java @@ -75,6 +75,10 @@ public static Path getMarkdownFilePath() { return Path.of(fileUploadPath, "markdown"); } + public static Path getMarkdownFilePathForConversation(long courseId, long conversationId) { + return getMarkdownFilePath().resolve("communication").resolve(String.valueOf(courseId)).resolve(String.valueOf(conversationId)); + } + /** * Convert the given public file url to its corresponding local path * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/FileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/FileService.java index 20aa37c7bacc..56edc1308149 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/FileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/FileService.java @@ -186,6 +186,32 @@ public URI handleSaveFile(MultipartFile file, boolean keepFilename, boolean mark return URI.create(markdown ? MARKDOWN_FILE_SUBPATH : DEFAULT_FILE_SUBPATH).resolve(currentFilename); } + /** + * Handles the saving of a file in a conversation. + * + * @param file The file to be uploaded. + * @param courseId The ID of the course. + * @param conversationId The ID of the conversation. + * @return The URI of the saved file. + */ + public URI handleSaveFileInConversation(MultipartFile file, Long courseId, Long conversationId) { + // TODO: Improve the access check. The course is already checked, but the user might not be a member of the conversation. The course may not belong to the conversation + String filename = checkAndSanitizeFilename(file.getOriginalFilename()); + + validateExtension(filename, true); + + final String filenamePrefix = "Markdown_"; + final Path path = FilePathService.getMarkdownFilePathForConversation(courseId, conversationId); + + String fileName = generateFilename(filenamePrefix, filename, false); // TODO: keep? + Path filePath = path.resolve(fileName); + + copyFile(file, filePath); + + String currentFilename = filePath.getFileName().toString(); + return URI.create("/api/files/courses/" + courseId + "/conversations/" + conversationId + "/").resolve(currentFilename); + } + /** * Saves a file to the given path using a generated filename. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/LLMTokenUsageService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/LLMTokenUsageService.java new file mode 100644 index 000000000000..c3dc2af1e519 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/LLMTokenUsageService.java @@ -0,0 +1,143 @@ +package de.tum.cit.aet.artemis.core.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.LLMRequest; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; +import de.tum.cit.aet.artemis.core.domain.LLMTokenUsageRequest; +import de.tum.cit.aet.artemis.core.domain.LLMTokenUsageTrace; +import de.tum.cit.aet.artemis.core.repository.LLMTokenUsageRequestRepository; +import de.tum.cit.aet.artemis.core.repository.LLMTokenUsageTraceRepository; + +/** + * Service for managing the LLMTokenUsage by all LLMs in Artemis + */ +@Profile(PROFILE_CORE) +@Service +public class LLMTokenUsageService { + + private final LLMTokenUsageTraceRepository llmTokenUsageTraceRepository; + + private final LLMTokenUsageRequestRepository llmTokenUsageRequestRepository; + + public LLMTokenUsageService(LLMTokenUsageTraceRepository llmTokenUsageTraceRepository, LLMTokenUsageRequestRepository llmTokenUsageRequestRepository) { + this.llmTokenUsageTraceRepository = llmTokenUsageTraceRepository; + this.llmTokenUsageRequestRepository = llmTokenUsageRequestRepository; + } + + /** + * Saves the token usage to the database. + * This method records the usage of tokens by various LLM services in the system. + * + * @param llmRequests List of LLM requests containing details about the token usage. + * @param serviceType Type of the LLM service (e.g., IRIS, GPT-3). + * @param builderFunction A function that takes an LLMTokenUsageBuilder and returns a modified LLMTokenUsageBuilder. + * This function is used to set additional properties on the LLMTokenUsageTrace object, such as + * the course ID, user ID, exercise ID, and Iris message ID. + * Example usage: + * builder -> builder.withCourse(courseId).withUser(userId) + * @return The saved LLMTokenUsageTrace object, which includes the details of the token usage. + */ + // TODO: this should ideally be done Async + public LLMTokenUsageTrace saveLLMTokenUsage(List llmRequests, LLMServiceType serviceType, Function builderFunction) { + LLMTokenUsageTrace llmTokenUsageTrace = new LLMTokenUsageTrace(); + llmTokenUsageTrace.setServiceType(serviceType); + + LLMTokenUsageBuilder builder = builderFunction.apply(new LLMTokenUsageBuilder()); + builder.getIrisMessageID().ifPresent(llmTokenUsageTrace::setIrisMessageId); + builder.getCourseID().ifPresent(llmTokenUsageTrace::setCourseId); + builder.getExerciseID().ifPresent(llmTokenUsageTrace::setExerciseId); + builder.getUserID().ifPresent(llmTokenUsageTrace::setUserId); + + llmTokenUsageTrace.setLlmRequests(llmRequests.stream().map(LLMTokenUsageService::convertLLMRequestToLLMTokenUsageRequest) + .peek(llmTokenUsageRequest -> llmTokenUsageRequest.setTrace(llmTokenUsageTrace)).collect(Collectors.toSet())); + + return llmTokenUsageTraceRepository.save(llmTokenUsageTrace); + } + + private static LLMTokenUsageRequest convertLLMRequestToLLMTokenUsageRequest(LLMRequest llmRequest) { + LLMTokenUsageRequest llmTokenUsageRequest = new LLMTokenUsageRequest(); + llmTokenUsageRequest.setModel(llmRequest.model()); + llmTokenUsageRequest.setNumInputTokens(llmRequest.numInputTokens()); + llmTokenUsageRequest.setNumOutputTokens(llmRequest.numOutputTokens()); + llmTokenUsageRequest.setCostPerMillionInputTokens(llmRequest.costPerMillionInputToken()); + llmTokenUsageRequest.setCostPerMillionOutputTokens(llmRequest.costPerMillionOutputToken()); + llmTokenUsageRequest.setServicePipelineId(llmRequest.pipelineId()); + return llmTokenUsageRequest; + } + + // TODO: this should ideally be done Async + public void appendRequestsToTrace(List requests, LLMTokenUsageTrace trace) { + var requestSet = requests.stream().map(LLMTokenUsageService::convertLLMRequestToLLMTokenUsageRequest).peek(llmTokenUsageRequest -> llmTokenUsageRequest.setTrace(trace)) + .collect(Collectors.toSet()); + llmTokenUsageRequestRepository.saveAll(requestSet); + } + + /** + * Finds an LLMTokenUsageTrace by its ID. + * + * @param id The ID of the LLMTokenUsageTrace to find. + * @return An Optional containing the LLMTokenUsageTrace if found, or an empty Optional otherwise. + */ + public Optional findLLMTokenUsageTraceById(Long id) { + return llmTokenUsageTraceRepository.findById(id); + } + + /** + * Class LLMTokenUsageBuilder to be used for saveLLMTokenUsage() + */ + public static class LLMTokenUsageBuilder { + + private Optional courseID = Optional.empty(); + + private Optional irisMessageID = Optional.empty(); + + private Optional exerciseID = Optional.empty(); + + private Optional userID = Optional.empty(); + + public LLMTokenUsageBuilder withCourse(Long courseID) { + this.courseID = Optional.ofNullable(courseID); + return this; + } + + public LLMTokenUsageBuilder withIrisMessageID(Long irisMessageID) { + this.irisMessageID = Optional.ofNullable(irisMessageID); + return this; + } + + public LLMTokenUsageBuilder withExercise(Long exerciseID) { + this.exerciseID = Optional.ofNullable(exerciseID); + return this; + } + + public LLMTokenUsageBuilder withUser(Long userID) { + this.userID = Optional.ofNullable(userID); + return this; + } + + public Optional getCourseID() { + return courseID; + } + + public Optional getIrisMessageID() { + return irisMessageID; + } + + public Optional getExerciseID() { + return exerciseID; + } + + public Optional getUserID() { + return userID; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java index 83438c369cfd..3e2a906bee8b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleService.java @@ -4,15 +4,19 @@ import java.util.List; import java.util.Map; +import java.util.Optional; -import jakarta.annotation.PostConstruct; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; @@ -20,6 +24,8 @@ @Service public class FeatureToggleService { + private static final Logger log = LoggerFactory.getLogger(FeatureToggleService.class); + private static final String TOPIC_FEATURE_TOGGLES = "/topic/management/feature-toggles"; @Value("${artemis.science.event-logging.enable:false}") @@ -36,10 +42,22 @@ public FeatureToggleService(WebsocketMessagingService websocketMessagingService, this.hazelcastInstance = hazelcastInstance; } + private Optional> getFeatures() { + try { + if (isHazelcastRunning()) { + return Optional.ofNullable(features); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to get features in {} as Hazelcast instance is not active anymore.", FeatureToggleService.class.getSimpleName()); + } + return Optional.empty(); + } + /** * Initialize relevant data from hazelcast */ - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void init() { // The map will automatically be distributed between all instances by Hazelcast. features = hazelcastInstance.getMap("features"); @@ -63,8 +81,10 @@ public void init() { * @param feature The feature that should be enabled */ public void enableFeature(Feature feature) { - features.put(feature, true); - sendUpdate(); + getFeatures().ifPresent(features -> { + features.put(feature, true); + sendUpdate(); + }); } /** @@ -73,23 +93,34 @@ public void enableFeature(Feature feature) { * @param feature The feature that should be disabled */ public void disableFeature(Feature feature) { - features.put(feature, false); - sendUpdate(); + getFeatures().ifPresent(features -> { + features.put(feature, false); + sendUpdate(); + }); } /** * Updates the given feature toggles and enables/disables the features based on the given map. Also notifies all clients * by sending a message via the websocket. * - * @param features A map of features (feature -> shouldBeActivated) + * @param updatedFeatures A map of features (feature -> shouldBeActivated) */ - public void updateFeatureToggles(final Map features) { - this.features.putAll(features); - sendUpdate(); + public void updateFeatureToggles(final Map updatedFeatures) { + getFeatures().ifPresent(features -> { + features.putAll(updatedFeatures); + sendUpdate(); + }); } private void sendUpdate() { - websocketMessagingService.sendMessage(TOPIC_FEATURE_TOGGLES, enabledFeatures()); + try { + if (isHazelcastRunning()) { + websocketMessagingService.sendMessage(TOPIC_FEATURE_TOGGLES, enabledFeatures()); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to send features update in {} as Hazelcast instance is not active anymore.", FeatureToggleService.class.getSimpleName()); + } } /** @@ -99,8 +130,16 @@ private void sendUpdate() { * @return if the feature is enabled */ public boolean isFeatureEnabled(Feature feature) { - Boolean isEnabled = features.get(feature); - return Boolean.TRUE.equals(isEnabled); + try { + if (isHazelcastRunning()) { + Boolean isEnabled = features.get(feature); + return Boolean.TRUE.equals(isEnabled); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to check if feature is enabled in FeatureToggleService as Hazelcast instance is not active any more."); + } + return false; } /** @@ -109,7 +148,15 @@ public boolean isFeatureEnabled(Feature feature) { * @return A list of enabled features */ public List enabledFeatures() { - return features.entrySet().stream().filter(feature -> Boolean.TRUE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + try { + if (isHazelcastRunning()) { + return features.entrySet().stream().filter(feature -> Boolean.TRUE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to retrieve enabled features update in FeatureToggleService as Hazelcast instance is not active any more."); + } + return List.of(); } /** @@ -118,6 +165,18 @@ public List enabledFeatures() { * @return A list of disabled features */ public List disabledFeatures() { - return features.entrySet().stream().filter(feature -> Boolean.FALSE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + try { + if (isHazelcastRunning()) { + return features.entrySet().stream().filter(feature -> Boolean.FALSE.equals(feature.getValue())).map(Map.Entry::getKey).toList(); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to retrieve disabled features update in FeatureToggleService as Hazelcast instance is not active any more."); + } + return List.of(); + } + + private boolean isHazelcastRunning() { + return hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning(); } } 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 40cbff0c217d..b1d3aaf5c20e 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 @@ -6,6 +6,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; import de.tum.cit.aet.artemis.core.dto.SortingOrder; import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; @@ -69,6 +70,11 @@ public enum ColumnMapping { "id", "id", "name", "name", "build_completion_date", "buildCompletionDate" + )), + FEEDBACK_ANALYSIS(Map.of( + "count", "COUNT(f.id)", + "detailText", "f.detailText", + "testCaseName", "f.testCase.testName" )); // @formatter:on @@ -87,9 +93,29 @@ public String getMappedColumnName(String columnName) { } } + /** + * Creates a default {@link PageRequest} based on the provided {@link PageableSearchDTO} and {@link ColumnMapping}. + * This method maps the sorted column name from the provided search DTO using the column mapping, + * applies the appropriate sorting order (ascending or descending), and constructs a {@link PageRequest} + * with pagination and sorting information. + * + *

+ * If the mapped column name contains a "COUNT(" expression, this method treats it as an unsafe sort expression + * and uses {@link JpaSort(String)} to apply sorting directly to the database column. + *

+ * + * @param search The {@link PageableSearchDTO} containing pagination and sorting parameters (e.g., page number, page size, sorted column, and sorting order). + * @param columnMapping The {@link ColumnMapping} object used to map the sorted column name from the DTO to the actual database column. + * @return A {@link PageRequest} object containing the pagination and sorting options based on the search and column mapping. + * @throws IllegalArgumentException if any of the parameters are invalid or missing. + * @throws NullPointerException if the search or columnMapping parameters are null. + */ @NotNull public static PageRequest createDefaultPageRequest(PageableSearchDTO search, ColumnMapping columnMapping) { - var sortOptions = Sort.by(columnMapping.getMappedColumnName(search.getSortedColumn())); + String mappedColumn = columnMapping.getMappedColumnName(search.getSortedColumn()); + + var sortOptions = mappedColumn.contains("(") ? JpaSort.unsafe(mappedColumn) : Sort.by(mappedColumn); + sortOptions = search.getSortingOrder() == SortingOrder.ASCENDING ? sortOptions.ascending() : sortOptions.descending(); return PageRequest.of(search.getPage() - 1, search.getPageSize(), sortOptions); } 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 471dec3111a7..951d9965a539 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 @@ -72,6 +72,8 @@ import de.tum.cit.aet.artemis.core.config.Constants; 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.CourseExistingExerciseDetailsDTO; +import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO; import de.tum.cit.aet.artemis.core.dto.CourseForDashboardDTO; import de.tum.cit.aet.artemis.core.dto.CourseForImportDTO; import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO; @@ -96,6 +98,7 @@ 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.enforceRoleInCourse.EnforceAtLeastEditorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.CourseService; @@ -107,6 +110,7 @@ import de.tum.cit.aet.artemis.exam.repository.ExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; +import de.tum.cit.aet.artemis.exercise.domain.ExerciseType; import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.exercise.domain.Team; import de.tum.cit.aet.artemis.exercise.domain.participation.Participant; @@ -556,6 +560,25 @@ public ResponseEntity> getCoursesForManagementOverview(@RequestPara return ResponseEntity.ok(courseService.getAllCoursesForManagementOverview(onlyActive)); } + /** + * GET /courses/for-archive : get all courses for course archive + * + * @return the ResponseEntity with status 200 (OK) and with body containing + * a set of DTOs, which contain the courses with id, title, semester, color, icon + */ + @GetMapping("courses/for-archive") + @EnforceAtLeastStudent + public ResponseEntity> getCoursesForArchive() { + long start = System.nanoTime(); + User user = userRepository.getUserWithGroupsAndAuthorities(); + log.debug("REST request to get all inactive courses from previous semesters user {} has access to", user.getLogin()); + Set courses = courseService.getAllCoursesForCourseArchive(); + log.debug("courseService.getAllCoursesForCourseArchive done"); + + log.info("GET /courses/for-archive took {} for {} courses for user {}", TimeLogUtil.formatDurationFrom(start), courses.size(), user.getLogin()); + return ResponseEntity.ok(courses); + } + /** * GET /courses/{courseId}/for-enrollment : get a course by id if the course allows enrollment and is currently active. * @@ -1455,6 +1478,37 @@ public ResponseEntity getNumberOfAllowedComplaintsInCourse(@PathVariable L return ResponseEntity.ok(Math.max(complaintService.getMaxComplaintsPerParticipant(course, participant) - unacceptedComplaints, 0)); } + /** + * GET courses/{courseId}/existing-exercise-details: Get the exercise names (and shortNames for {@link ExerciseType#PROGRAMMING} exercises) + * of all exercises with the given type in the given course. + * + * @param courseId of the course for which all exercise names should be fetched + * @param exerciseType for which the details should be fetched, as the name of an exercise only needs to be unique for each exercise type + * @return {@link CourseExistingExerciseDetailsDTO} with the exerciseNames (and already used shortNames if a {@link ExerciseType#PROGRAMMING} exercise is requested) + */ + @GetMapping("courses/{courseId}/existing-exercise-details") + @EnforceAtLeastEditorInCourse + public ResponseEntity getExistingExerciseDetails(@PathVariable Long courseId, @RequestParam String exerciseType) { + log.debug("REST request to get details of existing exercises in course : {}", courseId); + Course course = courseRepository.findByIdWithEagerExercisesElseThrow(courseId); + + Set alreadyTakenExerciseNames = new HashSet<>(); + Set alreadyTakenShortNames = new HashSet<>(); + + boolean includeShortNames = exerciseType.equals(ExerciseType.PROGRAMMING.toString()); + + course.getExercises().forEach((exercise -> { + if (exercise.getType().equals(exerciseType)) { + alreadyTakenExerciseNames.add(exercise.getTitle()); + if (includeShortNames && exercise.getShortName() != null) { + alreadyTakenShortNames.add(exercise.getShortName()); + } + } + })); + + return ResponseEntity.ok(new CourseExistingExerciseDetailsDTO(alreadyTakenExerciseNames, alreadyTakenShortNames)); + } + @PostMapping("courses/{courseId}/general-information") @EnforceAtLeastInstructorInCourse public ResponseEntity updateGeneralInformation(@PathVariable long courseId, @RequestBody String generalInformation) { 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 e8ad0e1fc5fe..5b73b836b9ad 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 @@ -39,7 +39,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import de.tum.cit.aet.artemis.core.config.Constants; 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; @@ -52,6 +54,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.EnforceAtLeastStudentInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; @@ -163,6 +166,48 @@ public ResponseEntity saveMarkdownFile(@RequestParam(value = "file") Mul return ResponseEntity.created(new URI(responsePath)).body(responseBody); } + /** + * POST /files/courses/{courseId}/conversations/{conversationId} : Upload a new file for use in a conversation. + * + * @param file The file to save. The size must not exceed Constants.MAX_FILE_SIZE_COMMUNICATION. + * @param courseId The ID of the course the conversation belongs to. + * @param conversationId The ID of the conversation the file is used in. + * @return The path of the file. + * @throws URISyntaxException If the response path can't be converted into a URI. + */ + @PostMapping("files/courses/{courseId}/conversations/{conversationId}") + @EnforceAtLeastStudentInCourse + public ResponseEntity saveMarkdownFileForConversation(@RequestParam(value = "file") MultipartFile file, @PathVariable Long courseId, @PathVariable Long conversationId) + throws URISyntaxException { + log.debug("REST request to upload file for markdown in conversation: {} for conversation {} in course {}", file.getOriginalFilename(), conversationId, courseId); + if (file.getSize() > Constants.MAX_FILE_SIZE_COMMUNICATION) { + throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "The file is too large. Maximum file size is " + Constants.MAX_FILE_SIZE_COMMUNICATION + " bytes."); + } + String responsePath = fileService.handleSaveFileInConversation(file, courseId, conversationId).toString(); + + // return path for getting the file + String responseBody = "{\"path\":\"" + responsePath + "\"}"; + + return ResponseEntity.created(new URI(responsePath)).body(responseBody); + } + + /** + * GET /files/courses/{courseId}/conversations/{conversationId}/{filename} : Get the markdown file with the given filename for the given conversation. + * + * @param courseId The ID of the course the conversation belongs to. + * @param conversationId The ID of the conversation the file is used in. + * @param filename The filename of the file to get. + * @return The requested file, or 404 if the file doesn't exist. The response will enable caching. + */ + @GetMapping("files/courses/{courseId}/conversations/{conversationId}/{filename}") + @EnforceAtLeastStudentInCourse + public ResponseEntity getMarkdownFileForConversation(@PathVariable Long courseId, @PathVariable Long conversationId, @PathVariable String filename) { + // TODO: Improve the access check + log.debug("REST request to get file for markdown in conversation: File {} for conversation {} in course {}", filename, conversationId, courseId); + sanitizeFilenameElseThrow(filename); + return buildFileResponse(FilePathService.getMarkdownFilePathForConversation(courseId, conversationId), filename, true); + } + /** * GET /files/markdown/:filename : Get the markdown file with the given filename * @@ -483,7 +528,7 @@ public ResponseEntity getAttachmentUnitFile(@PathVariable Long courseId, } /** - * GET files/attachments/slides/attachment-unit/:attachmentUnitId/slide/:slideNumber : Get the lecture unit attachment slide by slide number + * GET files/attachments/attachment-unit/{attachmentUnitId}/slide/{slideNumber} : Get the lecture unit attachment slide by slide number * * @param attachmentUnitId ID of the attachment unit, the attachment belongs to * @param slideNumber the slideNumber of the file diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index 33e7bd099155..27cd50a6e4b9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java @@ -4,6 +4,7 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -95,9 +96,12 @@ public ResponseEntity> getBuildAgentSummary() { @GetMapping("build-agent") public ResponseEntity getBuildAgentDetails(@RequestParam String agentName) { log.debug("REST request to get information on build agent {}", agentName); - BuildAgentInformation buildAgentDetails = localCIBuildJobQueueService.getBuildAgentInformation().stream().filter(agent -> agent.name().equals(agentName)).findFirst() - .orElse(null); - return ResponseEntity.ok(buildAgentDetails); + Optional buildAgentDetails = localCIBuildJobQueueService.getBuildAgentInformation().stream() + .filter(agent -> agent.buildAgent().name().equals(agentName)).findFirst(); + if (buildAgentDetails.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(buildAgentDetails.get()); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java index b25eb7ab154d..9fa635e491eb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java @@ -29,8 +29,6 @@ import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.JoinColumn; -import jakarta.persistence.JoinTable; -import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; @@ -55,7 +53,7 @@ import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.assessment.domain.TutorParticipation; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; -import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; @@ -116,11 +114,10 @@ public abstract class Exercise extends BaseExercise implements LearningObject { @Column(name = "grading_instructions") private String gradingInstructions; - @ManyToMany - @JoinTable(name = "competency_exercise", joinColumns = @JoinColumn(name = "exercise_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "competency_id", referencedColumnName = "id")) - @JsonIgnoreProperties({ "exercises", "course" }) + @OneToMany(mappedBy = "exercise", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnoreProperties("exercise") @JsonView(QuizView.Before.class) - private Set competencies = new HashSet<>(); + private Set competencyLinks = new HashSet<>(); @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = "exercise_categories", joinColumns = @JoinColumn(name = "exercise_id")) @@ -461,12 +458,12 @@ public void setPlagiarismDetectionConfig(PlagiarismDetectionConfig plagiarismDet // jhipster-needle-entity-add-getters-setters - JHipster will add getters and setters here, do not remove @Override - public Set getCompetencies() { - return competencies; + public Set getCompetencyLinks() { + return competencyLinks; } - public void setCompetencies(Set competencies) { - this.competencies = competencies; + public void setCompetencyLinks(Set competencyLinks) { + this.competencyLinks = competencyLinks; } public Long getNumberOfParticipations() { diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/ExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/ExerciseRepository.java index ff509559e132..60b85bc14f79 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/ExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/ExerciseRepository.java @@ -76,7 +76,8 @@ public interface ExerciseRepository extends ArtemisJpaRepository @Query(""" SELECT e FROM Exercise e - LEFT JOIN FETCH e.competencies + LEFT JOIN FETCH e.competencyLinks cl + LEFT JOIN FETCH cl.competency WHERE e.id = :exerciseId """) Optional findWithCompetenciesById(@Param("exerciseId") long exerciseId); 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 aceb0bd9c2ae..499818ace8a2 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 @@ -1199,12 +1199,29 @@ 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 and test case name. + * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text, test case name, and task. *
- * The relative count and task number are initially set to 0 and are calculated in a separate step in the service layer. + * 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. + *
+ * It supports filtering by: + * - Search term: Case-insensitive filtering on feedback detail text. + * - Test case names: Filters feedback based on specific test case names. + * - 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). + *
+ * Grouping is done by feedback detail text and test case name. The occurrence count is filtered using the HAVING clause. * - * @param exerciseId Exercise ID. - * @return a list of {@link FeedbackDetailDTO} objects, with the relative count and task number set to 0. + * @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. + * @return A page of {@link FeedbackDetailDTO} objects representing the aggregated feedback details. */ @Query(""" SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackDetailDTO( @@ -1212,38 +1229,87 @@ SELECT COALESCE(AVG(p.presentationScore), 0) 0, f.detailText, f.testCase.testName, - 0 - ) + 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 + 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 r.id = ( - SELECT MAX(pr.id) - FROM p.results pr - ) - AND f.positive = FALSE - GROUP BY f.detailText, f.testCase.testName + 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 """) - List findAggregatedFeedbackByExerciseId(@Param("exerciseId") long exerciseId); + 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); /** * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. + *
+ * For each participation, it selects only the latest result (using MAX) and ensures that the participation is not a test run. * - * @param exerciseId Exercise ID. - * @return The count of distinct latest results for the exercise. + * @param exerciseId Exercise ID for which distinct results should be counted. + * @return The total number of distinct latest results for the given exercise. */ @Query(""" - SELECT COUNT(DISTINCT r.id) + SELECT COUNT(DISTINCT r.id) + FROM StudentParticipation p + JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + """) + long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); + + /** + * Retrieves the maximum feedback count for a given exercise. + *
+ * This query calculates the maximum number of feedback occurrences across all feedback entries for a specific exercise. + * It considers only the latest result per participation and excludes test runs. + *
+ * Grouping is done by feedback detail text and test case name, and the maximum feedback count is returned. + * + * @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. + */ + @Query(""" + SELECT MAX(feedbackCounts.feedbackCount) + FROM ( + SELECT COUNT(f.id) AS feedbackCount FROM StudentParticipation p - JOIN p.results r + 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 r.id = ( - SELECT MAX(pr.id) - FROM p.results pr - ) + AND p.testRun = FALSE + AND f.positive = FALSE + GROUP BY f.detailText, f.testCase.testName + ) AS feedbackCounts """) - long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); + long findMaxCountForExercise(@Param("exerciseId") long exerciseId); } 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 73f31c2b73a5..24da1af374d9 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 @@ -4,6 +4,7 @@ import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; @@ -15,7 +16,8 @@ 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.CourseCompetency; +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; @@ -25,6 +27,7 @@ import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; +import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; import de.tum.cit.aet.artemis.lecture.repository.ExerciseUnitRepository; import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @@ -78,11 +81,16 @@ public class ExerciseDeletionService { private final CompetencyProgressService competencyProgressService; + private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + + private final Optional irisSettingsService; + public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUnitRepository exerciseUnitRepository, ParticipationService participationService, ProgrammingExerciseService programmingExerciseService, ModelingExerciseService modelingExerciseService, QuizExerciseService quizExerciseService, TutorParticipationRepository tutorParticipationRepository, ExampleSubmissionService exampleSubmissionService, StudentExamRepository studentExamRepository, LectureUnitService lectureUnitService, PlagiarismResultRepository plagiarismResultRepository, TextExerciseService textExerciseService, - ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService) { + ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService, + CompetencyExerciseLinkRepository competencyExerciseLinkRepository, Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -98,6 +106,8 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.channelRepository = channelRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; + this.irisSettingsService = irisSettingsService; } /** @@ -143,7 +153,7 @@ public void cleanup(Long exerciseId, boolean deleteRepositories) { */ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolean deleteBaseReposBuildPlans) { var exercise = exerciseRepository.findWithCompetenciesByIdElseThrow(exerciseId); - Set competencies = exercise.getCompetencies(); + Set competencyLinks = exercise.getCompetencyLinks(); log.info("Request to delete {} with id {}", exercise.getClass().getSimpleName(), exerciseId); long start = System.nanoTime(); @@ -169,6 +179,10 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea lectureUnitService.removeLectureUnit(exerciseUnit); } + if (irisSettingsService.isPresent()) { + irisSettingsService.get().deleteSettingsFor(exercise); + } + // delete all plagiarism results belonging to this exercise plagiarismResultRepository.deletePlagiarismResultsByExerciseId(exerciseId); @@ -204,7 +218,7 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea exerciseRepository.delete(exercise); } - competencies.forEach(competencyProgressService::updateProgressByCompetencyAsync); + competencyLinks.stream().map(CompetencyExerciseLink::getCompetency).forEach(competencyProgressService::updateProgressByCompetencyAsync); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseImportService.java index 21d0d05a9a8b..5ad1f4b87268 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseImportService.java @@ -63,7 +63,7 @@ protected void copyExerciseBasis(final Exercise newExercise, final Exercise impo newExercise.setDifficulty(importedExercise.getDifficulty()); newExercise.setGradingInstructions(importedExercise.getGradingInstructions()); newExercise.setGradingCriteria(importedExercise.copyGradingCriteria(gradingInstructionCopyTracker)); - newExercise.setCompetencies(importedExercise.getCompetencies()); + newExercise.setCompetencyLinks(importedExercise.getCompetencyLinks()); if (importedExercise.getPlagiarismDetectionConfig() != null) { newExercise.setPlagiarismDetectionConfig(new PlagiarismDetectionConfig(importedExercise.getPlagiarismDetectionConfig())); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java index 816d2bfdb558..0a355a870620 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseService.java @@ -14,11 +14,13 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import jakarta.validation.constraints.NotNull; import org.apache.commons.lang3.StringUtils; +import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.actuate.audit.AuditEvent; @@ -43,7 +45,9 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.assessment.service.RatingService; import de.tum.cit.aet.artemis.assessment.service.TutorLeaderboardService; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyExerciseLinkRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; @@ -126,13 +130,16 @@ public class ExerciseService { private final GroupNotificationScheduleService groupNotificationScheduleService; + private final CompetencyExerciseLinkRepository competencyExerciseLinkRepository; + public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheckService authCheckService, AuditEventRepository auditEventRepository, TeamRepository teamRepository, ProgrammingExerciseRepository programmingExerciseRepository, Optional lti13ResourceLaunchRepository, StudentParticipationRepository studentParticipationRepository, ResultRepository resultRepository, SubmissionRepository submissionRepository, ParticipantScoreRepository participantScoreRepository, UserRepository userRepository, ComplaintRepository complaintRepository, TutorLeaderboardService tutorLeaderboardService, ComplaintResponseRepository complaintResponseRepository, GradingCriterionRepository gradingCriterionRepository, FeedbackRepository feedbackRepository, RatingService ratingService, ExerciseDateService exerciseDateService, ExampleSubmissionRepository exampleSubmissionRepository, - QuizBatchService quizBatchService, ExamLiveEventsService examLiveEventsService, GroupNotificationScheduleService groupNotificationScheduleService) { + QuizBatchService quizBatchService, ExamLiveEventsService examLiveEventsService, GroupNotificationScheduleService groupNotificationScheduleService, + CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { this.exerciseRepository = exerciseRepository; this.resultRepository = resultRepository; this.authCheckService = authCheckService; @@ -155,6 +162,7 @@ public ExerciseService(ExerciseRepository exerciseRepository, AuthorizationCheck this.quizBatchService = quizBatchService; this.examLiveEventsService = examLiveEventsService; this.groupNotificationScheduleService = groupNotificationScheduleService; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; } /** @@ -761,12 +769,12 @@ public List getFeedbackToBeDeletedAfterGradingInstructionUpdate(boolea /** * Removes competency from all exercises. * - * @param exercises set of exercises - * @param competency competency to remove + * @param exerciseLinks set of exercise links + * @param competency competency to remove */ - public void removeCompetency(@NotNull Set exercises, @NotNull CourseCompetency competency) { - exercises.forEach(exercise -> exercise.getCompetencies().remove(competency)); - exerciseRepository.saveAll(exercises); + public void removeCompetency(@NotNull Set exerciseLinks, @NotNull CourseCompetency competency) { + competencyExerciseLinkRepository.deleteAll(exerciseLinks); + competency.getExerciseLinks().removeAll(exerciseLinks); } /** @@ -788,4 +796,37 @@ else if (now().plusMinutes(EXAM_START_WAIT_TIME_MINUTES).isAfter(originalExercis this.examLiveEventsService.createAndSendProblemStatementUpdateEvent(updatedExercise, notificationText, instructor); } } + + /** + * Saves the exercise and links it to the competencies. + * + * @param exercise exercise to save + * @param saveFunction function to save the exercise + * @param type of the exercise + * @return saved exercise + */ + public T saveWithCompetencyLinks(T exercise, Function saveFunction) { + // persist exercise before linking it to the competency + Set links = exercise.getCompetencyLinks(); + exercise.setCompetencyLinks(new HashSet<>()); + + T savedExercise = saveFunction.apply(exercise); + + if (Hibernate.isInitialized(links) && !links.isEmpty()) { + savedExercise.setCompetencyLinks(links); + reconnectCompetencyExerciseLinks(savedExercise); + savedExercise.setCompetencyLinks(new HashSet<>(competencyExerciseLinkRepository.saveAll(links))); + } + + return savedExercise; + } + + /** + * Reconnects the competency exercise links to the exercise after the cycle was broken by the deserialization. + * + * @param exercise exercise to reconnect the links + */ + public void reconnectCompetencyExerciseLinks(Exercise exercise) { + exercise.getCompetencyLinks().forEach(link -> link.setExercise(exercise)); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index 898b4456de07..4d549bb3fe66 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -418,7 +418,7 @@ else if (exercise instanceof ProgrammingExercise) { // Process feedback request StudentParticipation updatedParticipation; if (exercise instanceof TextExercise) { - updatedParticipation = textExerciseFeedbackService.handleNonGradedFeedbackRequest(exercise.getId(), participation, (TextExercise) exercise); + updatedParticipation = textExerciseFeedbackService.handleNonGradedFeedbackRequest(participation, (TextExercise) exercise); } else if (exercise instanceof ModelingExercise) { updatedParticipation = modelingExerciseFeedbackService.handleNonGradedFeedbackRequest(participation, (ModelingExercise) exercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java index 376a70db4c7e..ae6c5ac3f436 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java @@ -35,16 +35,16 @@ public interface FileUploadExerciseRepository extends ArtemisJpaRepository findByCourseIdWithCategories(@Param("courseId") Long courseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies" }) + @EntityGraph(type = LOAD, attributePaths = { "competencyLinks.competency" }) Optional findWithEagerCompetenciesById(Long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencies" }) + @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencyLinks.competency" }) Optional findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesById(Long exerciseId); @Query(""" SELECT f FROM FileUploadExercise f - LEFT JOIN FETCH f.competencies + LEFT JOIN FETCH f.competencyLinks WHERE f.title = :title AND f.course.id = :courseId """) diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadExerciseImportService.java index a6e1a51ad71e..f997bf8d30e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/service/FileUploadExerciseImportService.java @@ -19,6 +19,7 @@ import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.repository.SubmissionRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseImportService; +import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.fileupload.domain.FileUploadExercise; import de.tum.cit.aet.artemis.fileupload.repository.FileUploadExerciseRepository; @@ -34,13 +35,16 @@ public class FileUploadExerciseImportService extends ExerciseImportService { private final CompetencyProgressService competencyProgressService; + private final ExerciseService exerciseService; + public FileUploadExerciseImportService(ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, FileUploadExerciseRepository fileUploadExerciseRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressService competencyProgressService, ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.fileUploadExerciseRepository = fileUploadExerciseRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; + this.exerciseService = exerciseService; } /** @@ -57,7 +61,7 @@ public FileUploadExercise importFileUploadExercise(final FileUploadExercise temp log.debug("Creating a new Exercise based on exercise {}", templateExercise); FileUploadExercise newExercise = copyFileUploadExerciseBasis(importedExercise); - FileUploadExercise newFileUploadExercise = fileUploadExerciseRepository.save(newExercise); + FileUploadExercise newFileUploadExercise = exerciseService.saveWithCompetencyLinks(newExercise, fileUploadExerciseRepository::save); channelService.createExerciseChannel(newFileUploadExercise, Optional.ofNullable(importedExercise.getChannelName())); diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadExerciseResource.java index 84e361d2b4fe..02e060f7c931 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/web/FileUploadExerciseResource.java @@ -156,7 +156,7 @@ public ResponseEntity createFileUploadExercise(@RequestBody // Check that the user is authorized to create the exercise authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); - FileUploadExercise result = fileUploadExerciseRepository.save(fileUploadExercise); + FileUploadExercise result = exerciseService.saveWithCompetencyLinks(fileUploadExercise, fileUploadExerciseRepository::save); channelService.createExerciseChannel(result, Optional.ofNullable(fileUploadExercise.getChannelName())); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(fileUploadExercise); @@ -281,7 +281,7 @@ public ResponseEntity updateFileUploadExercise(@RequestBody channelService.updateExerciseChannel(fileUploadExerciseBeforeUpdate, fileUploadExercise); - var updatedExercise = fileUploadExerciseRepository.save(fileUploadExercise); + var updatedExercise = exerciseService.saveWithCompetencyLinks(fileUploadExercise, fileUploadExerciseRepository::save); exerciseService.logUpdate(updatedExercise, updatedExercise.getCourseViaExerciseGroupOrCourseMember(), user); exerciseService.updatePointsInRelatedParticipantScores(fileUploadExerciseBeforeUpdate, updatedExercise); participationRepository.removeIndividualDueDatesIfBeforeDueDate(updatedExercise, fileUploadExerciseBeforeUpdate.getDueDate()); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java index bf2851ae7979..e8773c783914 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java @@ -1,7 +1,11 @@ package de.tum.cit.aet.artemis.iris.domain.settings; +import java.util.SortedSet; +import java.util.TreeSet; + import jakarta.annotation.Nullable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -24,6 +28,10 @@ public class IrisChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; + @Column(name = "enabled_for_categories") + @Convert(converter = IrisListConverter.class) + private SortedSet enabledForCategories = new TreeSet<>(); + @Nullable public Integer getRateLimit() { return rateLimit; @@ -41,4 +49,12 @@ public Integer getRateLimitTimeframeHours() { public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours) { this.rateLimitTimeframeHours = rateLimitTimeframeHours; } + + public SortedSet getEnabledForCategories() { + return enabledForCategories; + } + + public void setEnabledForCategories(SortedSet enabledForCategories) { + this.enabledForCategories = enabledForCategories; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java index ed090bbe892a..2b96a709a9ad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java @@ -1,7 +1,11 @@ package de.tum.cit.aet.artemis.iris.domain.settings; +import java.util.SortedSet; +import java.util.TreeSet; + import jakarta.annotation.Nullable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -23,6 +27,11 @@ public class IrisTextExerciseChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; + @Nullable + @Column(name = "enabled_for_categories") + @Convert(converter = IrisListConverter.class) + private SortedSet enabledForCategories = new TreeSet<>(); + @Nullable public Integer getRateLimit() { return rateLimit; @@ -41,4 +50,12 @@ public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours this.rateLimitTimeframeHours = rateLimitTimeframeHours; } + @Nullable + public SortedSet getEnabledForCategories() { + return enabledForCategories; + } + + public void setEnabledForCategories(@Nullable SortedSet enabledForCategories) { + this.enabledForCategories = enabledForCategories; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisChatWebsocketDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisChatWebsocketDTO.java index 75b56488e513..9057b8229fb5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisChatWebsocketDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisChatWebsocketDTO.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.artemis.core.domain.LLMRequest; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @@ -21,7 +22,7 @@ */ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record IrisChatWebsocketDTO(IrisWebsocketMessageType type, IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, - List suggestions) { + List suggestions, List tokens) { /** * Creates a new IrisWebsocketDTO instance with the given parameters @@ -31,8 +32,9 @@ public record IrisChatWebsocketDTO(IrisWebsocketMessageType type, IrisMessage me * @param rateLimitInfo the rate limit information * @param stages the stages of the Pyris pipeline */ - public IrisChatWebsocketDTO(@Nullable IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, List suggestions) { - this(determineType(message), message, rateLimitInfo, stages, suggestions); + public IrisChatWebsocketDTO(@Nullable IrisMessage message, IrisRateLimitService.IrisRateLimitInformation rateLimitInfo, List stages, List suggestions, + List tokens) { + this(determineType(message), message, rateLimitInfo, stages, suggestions, tokens); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java index c5589e824507..4f003471a4d7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java @@ -1,13 +1,13 @@ package de.tum.cit.aet.artemis.iris.dto; -import java.util.Set; +import java.util.SortedSet; import jakarta.annotation.Nullable; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant) { +public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java index 4f02a2d87720..f8a5ccb61748 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java @@ -1,13 +1,13 @@ package de.tum.cit.aet.artemis.iris.dto; -import java.util.Set; +import java.util.SortedSet; import jakarta.annotation.Nullable; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedTextExerciseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant) { +public record IrisCombinedTextExerciseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java index a8f76c5ff679..be8d6c3b4331 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.iris.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.util.Collections; @@ -7,10 +8,12 @@ import jakarta.validation.constraints.NotNull; +import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; 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.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -21,6 +24,8 @@ * Repository interface for managing {@link IrisTextExerciseChatSession} entities. * Provides custom queries for finding text exercise chat sessions based on different criteria. */ +@Profile(PROFILE_IRIS) +@Repository public interface IrisTextExerciseChatSessionRepository extends ArtemisJpaRepository { /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java index 98182ae92b06..88906ff80628 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisCompetencyGenerationService.java @@ -7,7 +7,11 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyTaxonomy; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyExtractionPipelineExecutionDTO; @@ -25,14 +29,24 @@ public class IrisCompetencyGenerationService { private final PyrisPipelineService pyrisPipelineService; + private final LLMTokenUsageService llmTokenUsageService; + + private final CourseRepository courseRepository; + private final IrisWebsocketService websocketService; private final PyrisJobService pyrisJobService; - public IrisCompetencyGenerationService(PyrisPipelineService pyrisPipelineService, IrisWebsocketService websocketService, PyrisJobService pyrisJobService) { + private final UserRepository userRepository; + + public IrisCompetencyGenerationService(PyrisPipelineService pyrisPipelineService, LLMTokenUsageService llmTokenUsageService, CourseRepository courseRepository, + IrisWebsocketService websocketService, PyrisJobService pyrisJobService, UserRepository userRepository) { this.pyrisPipelineService = pyrisPipelineService; + this.llmTokenUsageService = llmTokenUsageService; + this.courseRepository = courseRepository; this.websocketService = websocketService; this.pyrisJobService = pyrisJobService; + this.userRepository = userRepository; } /** @@ -48,9 +62,9 @@ public void executeCompetencyExtractionPipeline(User user, Course course, String pyrisPipelineService.executePipeline( "competency-extraction", "default", - pyrisJobService.createTokenForJob(token -> new CompetencyExtractionJob(token, course.getId(), user.getLogin())), + pyrisJobService.createTokenForJob(token -> new CompetencyExtractionJob(token, course.getId(), user.getId())), executionDto -> new PyrisCompetencyExtractionPipelineExecutionDTO(executionDto, courseDescription, currentCompetencies, CompetencyTaxonomy.values(), 5), - stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisCompetencyStatusUpdateDTO(stages, null)) + stages -> websocketService.send(user.getLogin(), websocketTopic(course.getId()), new PyrisCompetencyStatusUpdateDTO(stages, null, null)) ); // @formatter:on } @@ -58,12 +72,20 @@ public void executeCompetencyExtractionPipeline(User user, Course course, String /** * Takes a status update from Pyris containing a new competency extraction result and sends it to the client via websocket * - * @param userLogin the login of the user - * @param courseId the id of the course + * @param job Job related to the status update * @param statusUpdate the status update containing the new competency recommendations + * @return the same job that was passed in */ - public void handleStatusUpdate(String userLogin, long courseId, PyrisCompetencyStatusUpdateDTO statusUpdate) { - websocketService.send(userLogin, websocketTopic(courseId), statusUpdate); + public CompetencyExtractionJob handleStatusUpdate(CompetencyExtractionJob job, PyrisCompetencyStatusUpdateDTO statusUpdate) { + Course course = courseRepository.findByIdForUpdateElseThrow(job.courseId()); + if (statusUpdate.tokens() != null && !statusUpdate.tokens().isEmpty()) { + llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> builder.withCourse(course.getId()).withUser(job.userId())); + } + + var user = userRepository.findById(job.userId()).orElseThrow(); + websocketService.send(user.getLogin(), websocketTopic(job.courseId()), statusUpdate); + + return job; } private static String websocketTopic(long courseId) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java index 7933e9e20920..16e8969bc463 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisJobService.java @@ -78,14 +78,14 @@ public String createTokenForJob(Function tokenToJobFunction) { public String addExerciseChatJob(Long courseId, Long exerciseId, Long sessionId) { var token = generateJobIdToken(); - var job = new ExerciseChatJob(token, courseId, exerciseId, sessionId); + var job = new ExerciseChatJob(token, courseId, exerciseId, sessionId, null); jobMap.put(token, job); return token; } public String addCourseChatJob(Long courseId, Long sessionId) { var token = generateJobIdToken(); - var job = new CourseChatJob(token, courseId, sessionId); + var job = new CourseChatJob(token, courseId, sessionId, null); jobMap.put(token, job); return token; } @@ -107,10 +107,19 @@ public String addIngestionWebhookJob() { /** * Remove a job from the job map. * - * @param token the token + * @param job the job to remove + */ + public void removeJob(PyrisJob job) { + jobMap.remove(job.jobId()); + } + + /** + * Store a job in the job map. + * + * @param job the job to store */ - public void removeJob(String token) { - jobMap.remove(token); + public void updateJob(PyrisJob job) { + jobMap.put(job.jobId(), job); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java index 9403da9beb56..cdd398e5c683 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisStatusUpdateService.java @@ -20,7 +20,9 @@ import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.TrackedSessionBasedPyrisJob; import de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService; import de.tum.cit.aet.artemis.iris.service.session.IrisExerciseChatSessionService; import de.tum.cit.aet.artemis.iris.service.session.IrisTextExerciseChatSessionService; @@ -52,15 +54,16 @@ public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseCha } /** - * Handles the status update of a exercise chat job and forwards it to {@link IrisExerciseChatSessionService#handleStatusUpdate(ExerciseChatJob, PyrisChatStatusUpdateDTO)} + * Handles the status update of a exercise chat job and forwards it to + * {@link IrisExerciseChatSessionService#handleStatusUpdate(TrackedSessionBasedPyrisJob, PyrisChatStatusUpdateDTO)} * * @param job the job that is updated * @param statusUpdate the status update */ public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { - irisExerciseChatSessionService.handleStatusUpdate(job, statusUpdate); + var updatedJob = irisExerciseChatSessionService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } /** @@ -71,52 +74,55 @@ public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO sta * @param statusUpdate the status update */ public void handleStatusUpdate(TextExerciseChatJob job, PyrisTextExerciseChatStatusUpdateDTO statusUpdate) { - irisTextExerciseChatSessionService.handleStatusUpdate(job, statusUpdate); + var updatedJob = irisTextExerciseChatSessionService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } /** * Handles the status update of a course chat job and forwards it to - * {@link de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService#handleStatusUpdate(CourseChatJob, PyrisChatStatusUpdateDTO)} + * {@link de.tum.cit.aet.artemis.iris.service.session.IrisCourseChatSessionService#handleStatusUpdate(TrackedSessionBasedPyrisJob, PyrisChatStatusUpdateDTO)} * * @param job the job that is updated * @param statusUpdate the status update */ public void handleStatusUpdate(CourseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { - courseChatSessionService.handleStatusUpdate(job, statusUpdate); + var updatedJob = courseChatSessionService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } /** * Handles the status update of a competency extraction job and forwards it to - * {@link IrisCompetencyGenerationService#handleStatusUpdate(String, long, PyrisCompetencyStatusUpdateDTO)} + * {@link IrisCompetencyGenerationService#handleStatusUpdate(CompetencyExtractionJob, PyrisCompetencyStatusUpdateDTO)} * * @param job the job that is updated * @param statusUpdate the status update */ public void handleStatusUpdate(CompetencyExtractionJob job, PyrisCompetencyStatusUpdateDTO statusUpdate) { - competencyGenerationService.handleStatusUpdate(job.userLogin(), job.courseId(), statusUpdate); + var updatedJob = competencyGenerationService.handleStatusUpdate(job, statusUpdate); - removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); } /** - * Removes the job from the job service if the status update indicates that the job is terminated. - * This is the case if all stages are in a terminal state. + * Removes the job from the job service if the status update indicates that the job is terminated; updates it to distribute changes otherwise. + * A job is terminated if all stages are in a terminal state. *

* * @see PyrisStageState#isTerminal() * * @param stages the stages of the status update - * @param job the job to remove + * @param job the job to remove or to update */ - private void removeJobIfTerminated(List stages, String job) { + private void removeJobIfTerminatedElseUpdate(List stages, PyrisJob job) { var isDone = stages.stream().map(PyrisStageDTO::state).allMatch(PyrisStageState::isTerminal); if (isDone) { pyrisJobService.removeJob(job); } + else { + pyrisJobService.updateJob(job); + } } /** @@ -128,6 +134,6 @@ private void removeJobIfTerminated(List stages, String job) { */ public void handleStatusUpdate(IngestionWebhookJob job, PyrisLectureIngestionStatusUpdateDTO statusUpdate) { statusUpdate.stages().forEach(stage -> log.info(stage.name() + ":" + stage.message())); - removeJobIfTerminated(statusUpdate.stages(), job.jobId()); + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), job); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisChatStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisChatStatusUpdateDTO.java index cbfa0b2d98dd..5a1024c6315b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisChatStatusUpdateDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/PyrisChatStatusUpdateDTO.java @@ -4,8 +4,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.artemis.core.domain.LLMRequest; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisChatStatusUpdateDTO(String result, List stages, List suggestions) { +public record PyrisChatStatusUpdateDTO(String result, List stages, List suggestions, List tokens) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java index 0956a52f26e8..465c8e5edb65 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/competency/PyrisCompetencyStatusUpdateDTO.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.artemis.core.domain.LLMRequest; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; /** @@ -13,7 +14,8 @@ * * @param stages List of stages of the generation process * @param result List of competencies recommendations that have been generated so far + * @param tokens List of token usages send by Pyris for tracking the token usage and cost */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisCompetencyStatusUpdateDTO(List stages, List result) { +public record PyrisCompetencyStatusUpdateDTO(List stages, List result, List tokens) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisLLMCostDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisLLMCostDTO.java new file mode 100644 index 000000000000..43c000a879ae --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisLLMCostDTO.java @@ -0,0 +1,4 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.data; + +public record PyrisLLMCostDTO(String modelInfo, int numInputTokens, float costPerInputToken, int numOutputTokens, float costPerOutputToken, String pipeline) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CompetencyExtractionJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CompetencyExtractionJob.java index 26ab6427a020..b50d8e70b8c9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CompetencyExtractionJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CompetencyExtractionJob.java @@ -7,12 +7,12 @@ /** * A pyris job that extracts competencies from a course description. * - * @param jobId the job id - * @param courseId the course in which the competencies are being extracted - * @param userLogin the user login of the user who started the job + * @param jobId the job id + * @param courseId the course in which the competencies are being extracted + * @param userId the user who started the job */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record CompetencyExtractionJob(String jobId, long courseId, String userLogin) implements PyrisJob { +public record CompetencyExtractionJob(String jobId, long courseId, long userId) implements PyrisJob { @Override public boolean canAccess(Course course) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CourseChatJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CourseChatJob.java index fb4b93a28854..2f389e22ed96 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CourseChatJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/CourseChatJob.java @@ -9,10 +9,15 @@ * This job is used to reference the details of a course chat session when Pyris sends a status update. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record CourseChatJob(String jobId, long courseId, long sessionId) implements PyrisJob { +public record CourseChatJob(String jobId, long courseId, long sessionId, Long traceId) implements TrackedSessionBasedPyrisJob { @Override public boolean canAccess(Course course) { return courseId == course.getId(); } + + @Override + public TrackedSessionBasedPyrisJob withTraceId(long traceId) { + return new CourseChatJob(jobId, courseId, sessionId, traceId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ExerciseChatJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ExerciseChatJob.java index 302ae274d8e2..f74e7360be82 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ExerciseChatJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/ExerciseChatJob.java @@ -10,7 +10,7 @@ * This job is used to reference the details of a exercise chat session when Pyris sends a status update. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record ExerciseChatJob(String jobId, long courseId, long exerciseId, long sessionId) implements PyrisJob { +public record ExerciseChatJob(String jobId, long courseId, long exerciseId, long sessionId, Long traceId) implements TrackedSessionBasedPyrisJob { @Override public boolean canAccess(Course course) { @@ -21,4 +21,9 @@ public boolean canAccess(Course course) { public boolean canAccess(Exercise exercise) { return exercise.getId().equals(exerciseId); } + + @Override + public TrackedSessionBasedPyrisJob withTraceId(long traceId) { + return new ExerciseChatJob(jobId, courseId, exerciseId, sessionId, traceId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/TrackedSessionBasedPyrisJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/TrackedSessionBasedPyrisJob.java new file mode 100644 index 000000000000..bdd180103840 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/TrackedSessionBasedPyrisJob.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +/** + * A Pyris job that has a session id and stored its own LLM usage tracing ID. + * This is used for chat jobs where we need to reference the trace ID later after chat suggestions have been generated. + */ +public interface TrackedSessionBasedPyrisJob extends PyrisJob { + + long sessionId(); + + Long traceId(); + + TrackedSessionBasedPyrisJob withTraceId(long traceId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/AbstractIrisChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/AbstractIrisChatSessionService.java index f732529aae72..6f0b5a9f411a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/AbstractIrisChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/AbstractIrisChatSessionService.java @@ -1,22 +1,43 @@ package de.tum.cit.aet.artemis.iris.service.session; import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import de.tum.cit.aet.artemis.core.domain.LLMServiceType; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; +import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; +import de.tum.cit.aet.artemis.iris.domain.message.IrisMessageSender; +import de.tum.cit.aet.artemis.iris.domain.message.IrisTextMessageContent; import de.tum.cit.aet.artemis.iris.domain.session.IrisChatSession; import de.tum.cit.aet.artemis.iris.repository.IrisSessionRepository; +import de.tum.cit.aet.artemis.iris.service.IrisMessageService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.job.TrackedSessionBasedPyrisJob; +import de.tum.cit.aet.artemis.iris.service.websocket.IrisChatWebsocketService; public abstract class AbstractIrisChatSessionService implements IrisChatBasedFeatureInterface, IrisRateLimitedFeatureInterface { private final IrisSessionRepository irisSessionRepository; + private final IrisMessageService irisMessageService; + + private final IrisChatWebsocketService irisChatWebsocketService; + + private final LLMTokenUsageService llmTokenUsageService; + private final ObjectMapper objectMapper; - public AbstractIrisChatSessionService(IrisSessionRepository irisSessionRepository, ObjectMapper objectMapper) { + public AbstractIrisChatSessionService(IrisSessionRepository irisSessionRepository, ObjectMapper objectMapper, IrisMessageService irisMessageService, + IrisChatWebsocketService irisChatWebsocketService, LLMTokenUsageService llmTokenUsageService) { this.irisSessionRepository = irisSessionRepository; this.objectMapper = objectMapper; + this.irisMessageService = irisMessageService; + this.irisChatWebsocketService = irisChatWebsocketService; + this.llmTokenUsageService = llmTokenUsageService; } /** @@ -40,4 +61,59 @@ protected void updateLatestSuggestions(S session, List latestSuggestions throw new RuntimeException("Could not update latest suggestions for session " + session.getId(), e); } } + + /** + * Handles the status update of a ExerciseChatJob by sending the result to the student via the Websocket. + * + * @param job The job that was executed + * @param statusUpdate The status update of the job + * @return the same job record or a new job record with the same job id if changes were made + */ + public TrackedSessionBasedPyrisJob handleStatusUpdate(TrackedSessionBasedPyrisJob job, PyrisChatStatusUpdateDTO statusUpdate) { + var session = (S) irisSessionRepository.findByIdWithMessagesAndContents(job.sessionId()); + IrisMessage savedMessage; + if (statusUpdate.result() != null) { + var message = new IrisMessage(); + message.addContent(new IrisTextMessageContent(statusUpdate.result())); + savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); + irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); + } + else { + savedMessage = null; + irisChatWebsocketService.sendStatusUpdate(session, statusUpdate.stages(), statusUpdate.suggestions(), statusUpdate.tokens()); + } + + AtomicReference updatedJob = new AtomicReference<>(job); + if (statusUpdate.tokens() != null && !statusUpdate.tokens().isEmpty()) { + if (savedMessage != null) { + // generated message is first sent and generated trace is saved + var llmTokenUsageTrace = llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> { + builder.withIrisMessageID(savedMessage.getId()).withUser(session.getUser().getId()); + this.setLLMTokenUsageParameters(builder, session); + return builder; + }); + + updatedJob.set(job.withTraceId(llmTokenUsageTrace.getId())); + } + else { + // interaction suggestion is sent and appended to the generated trace if it exists + Optional.ofNullable(job.traceId()).flatMap(llmTokenUsageService::findLLMTokenUsageTraceById) + .ifPresentOrElse(trace -> llmTokenUsageService.appendRequestsToTrace(statusUpdate.tokens(), trace), () -> { + var llmTokenUsage = llmTokenUsageService.saveLLMTokenUsage(statusUpdate.tokens(), LLMServiceType.IRIS, builder -> { + builder.withUser(session.getUser().getId()); + this.setLLMTokenUsageParameters(builder, session); + return builder; + }); + + updatedJob.set(job.withTraceId(llmTokenUsage.getId())); + }); + } + } + + updateLatestSuggestions(session, statusUpdate.suggestions()); + + return updatedJob.get(); + } + + protected abstract void setLLMTokenUsageParameters(LLMTokenUsageService.LLMTokenUsageBuilder builder, S session); } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java index 6dea7a728ca6..d2743c2e71a5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisCourseChatSessionService.java @@ -19,9 +19,8 @@ import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; -import de.tum.cit.aet.artemis.iris.domain.message.IrisMessageSender; -import de.tum.cit.aet.artemis.iris.domain.message.IrisTextMessageContent; import de.tum.cit.aet.artemis.iris.domain.session.IrisCourseChatSession; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; import de.tum.cit.aet.artemis.iris.repository.IrisCourseChatSessionRepository; @@ -29,8 +28,6 @@ import de.tum.cit.aet.artemis.iris.service.IrisMessageService; import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; -import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; -import de.tum.cit.aet.artemis.iris.service.pyris.job.CourseChatJob; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.iris.service.websocket.IrisChatWebsocketService; @@ -41,8 +38,6 @@ @Profile(PROFILE_IRIS) public class IrisCourseChatSessionService extends AbstractIrisChatSessionService { - private final IrisMessageService irisMessageService; - private final IrisSettingsService irisSettingsService; private final IrisChatWebsocketService irisChatWebsocketService; @@ -57,11 +52,11 @@ public class IrisCourseChatSessionService extends AbstractIrisChatSessionService private final PyrisPipelineService pyrisPipelineService; - public IrisCourseChatSessionService(IrisMessageService irisMessageService, IrisSettingsService irisSettingsService, IrisChatWebsocketService irisChatWebsocketService, - AuthorizationCheckService authCheckService, IrisSessionRepository irisSessionRepository, IrisRateLimitService rateLimitService, - IrisCourseChatSessionRepository irisCourseChatSessionRepository, PyrisPipelineService pyrisPipelineService, ObjectMapper objectMapper) { - super(irisSessionRepository, objectMapper); - this.irisMessageService = irisMessageService; + public IrisCourseChatSessionService(IrisMessageService irisMessageService, LLMTokenUsageService llmTokenUsageService, IrisSettingsService irisSettingsService, + IrisChatWebsocketService irisChatWebsocketService, AuthorizationCheckService authCheckService, IrisSessionRepository irisSessionRepository, + IrisRateLimitService rateLimitService, IrisCourseChatSessionRepository irisCourseChatSessionRepository, PyrisPipelineService pyrisPipelineService, + ObjectMapper objectMapper) { + super(irisSessionRepository, objectMapper, irisMessageService, irisChatWebsocketService, llmTokenUsageService); this.irisSettingsService = irisSettingsService; this.irisChatWebsocketService = irisChatWebsocketService; this.authCheckService = authCheckService; @@ -126,24 +121,9 @@ private void requestAndHandleResponse(IrisCourseChatSession session, String vari pyrisPipelineService.executeCourseChatPipeline(variant, chatSession, competencyJol); } - /** - * Handles the status update of a CourseChatJob by sending the result to the student via the Websocket. - * - * @param job The job that was executed - * @param statusUpdate The status update of the job - */ - public void handleStatusUpdate(CourseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { - var session = (IrisCourseChatSession) irisSessionRepository.findByIdWithMessagesAndContents(job.sessionId()); - if (statusUpdate.result() != null) { - var message = new IrisMessage(); - message.addContent(new IrisTextMessageContent(statusUpdate.result())); - var savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); - irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); - } - else { - irisChatWebsocketService.sendStatusUpdate(session, statusUpdate.stages(), statusUpdate.suggestions()); - } - updateLatestSuggestions(session, statusUpdate.suggestions()); + @Override + protected void setLLMTokenUsageParameters(LLMTokenUsageService.LLMTokenUsageBuilder builder, IrisCourseChatSession session) { + builder.withCourse(session.getCourse().getId()); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java index d520540a2db4..a51f1730e98c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisExerciseChatSessionService.java @@ -15,18 +15,15 @@ import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.service.LLMTokenUsageService; import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; -import de.tum.cit.aet.artemis.iris.domain.message.IrisMessageSender; -import de.tum.cit.aet.artemis.iris.domain.message.IrisTextMessageContent; import de.tum.cit.aet.artemis.iris.domain.session.IrisExerciseChatSession; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; import de.tum.cit.aet.artemis.iris.repository.IrisSessionRepository; import de.tum.cit.aet.artemis.iris.service.IrisMessageService; import de.tum.cit.aet.artemis.iris.service.IrisRateLimitService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; -import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; -import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.iris.service.websocket.IrisChatWebsocketService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -42,8 +39,6 @@ @Profile(PROFILE_IRIS) public class IrisExerciseChatSessionService extends AbstractIrisChatSessionService implements IrisRateLimitedFeatureInterface { - private final IrisMessageService irisMessageService; - private final IrisSettingsService irisSettingsService; private final IrisChatWebsocketService irisChatWebsocketService; @@ -62,13 +57,12 @@ public class IrisExerciseChatSessionService extends AbstractIrisChatSessionServi private final ProgrammingExerciseRepository programmingExerciseRepository; - public IrisExerciseChatSessionService(IrisMessageService irisMessageService, IrisSettingsService irisSettingsService, IrisChatWebsocketService irisChatWebsocketService, - AuthorizationCheckService authCheckService, IrisSessionRepository irisSessionRepository, + public IrisExerciseChatSessionService(IrisMessageService irisMessageService, LLMTokenUsageService llmTokenUsageService, IrisSettingsService irisSettingsService, + IrisChatWebsocketService irisChatWebsocketService, AuthorizationCheckService authCheckService, IrisSessionRepository irisSessionRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionRepository programmingSubmissionRepository, IrisRateLimitService rateLimitService, PyrisPipelineService pyrisPipelineService, ProgrammingExerciseRepository programmingExerciseRepository, ObjectMapper objectMapper) { - super(irisSessionRepository, objectMapper); - this.irisMessageService = irisMessageService; + super(irisSessionRepository, objectMapper, irisMessageService, irisChatWebsocketService, llmTokenUsageService); this.irisSettingsService = irisSettingsService; this.irisChatWebsocketService = irisChatWebsocketService; this.authCheckService = authCheckService; @@ -158,24 +152,9 @@ private Optional getLatestSubmissionIfExists(ProgrammingE .flatMap(sub -> programmingSubmissionRepository.findWithEagerResultsAndFeedbacksAndBuildLogsById(sub.getId())); } - /** - * Handles the status update of a ExerciseChatJob by sending the result to the student via the Websocket. - * - * @param job The job that was executed - * @param statusUpdate The status update of the job - */ - public void handleStatusUpdate(ExerciseChatJob job, PyrisChatStatusUpdateDTO statusUpdate) { - var session = (IrisExerciseChatSession) irisSessionRepository.findByIdWithMessagesAndContents(job.sessionId()); - if (statusUpdate.result() != null) { - var message = new IrisMessage(); - message.addContent(new IrisTextMessageContent(statusUpdate.result())); - var savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); - irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); - } - else { - irisChatWebsocketService.sendStatusUpdate(session, statusUpdate.stages(), statusUpdate.suggestions()); - } - - updateLatestSuggestions(session, statusUpdate.suggestions()); + @Override + protected void setLLMTokenUsageParameters(LLMTokenUsageService.LLMTokenUsageBuilder builder, IrisExerciseChatSession session) { + var exercise = session.getExercise(); + builder.withCourse(exercise.getCourseViaExerciseGroupOrCourseMember().getId()).withExercise(exercise.getId()); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java index 4520417aad48..8702db7bdf54 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java @@ -115,8 +115,10 @@ public void requestAndHandleResponse(IrisTextExerciseChatSession irisSession) { * * @param job The job that is updated * @param statusUpdate The status update + * @return The same job that was passed in */ - public void handleStatusUpdate(TextExerciseChatJob job, PyrisTextExerciseChatStatusUpdateDTO statusUpdate) { + public TextExerciseChatJob handleStatusUpdate(TextExerciseChatJob job, PyrisTextExerciseChatStatusUpdateDTO statusUpdate) { + // TODO: LLM Token Tracking - or better, make this class a subclass of AbstractIrisChatSessionService var session = (IrisTextExerciseChatSession) irisSessionRepository.findByIdElseThrow(job.sessionId()); if (statusUpdate.result() != null) { var message = session.newMessage(); @@ -127,6 +129,8 @@ public void handleStatusUpdate(TextExerciseChatJob job, PyrisTextExerciseChatSta else { irisChatWebsocketService.sendMessage(session, null, statusUpdate.stages()); } + + return job; } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java index fb8820a4c08b..6047631fb5bf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java @@ -8,8 +8,10 @@ import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.Objects; import java.util.Set; +import java.util.SortedSet; import java.util.TreeSet; import java.util.function.Supplier; @@ -18,6 +20,9 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + 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.AccessForbiddenAlertException; @@ -37,6 +42,10 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; /** * Service for managing {@link IrisSettings}. @@ -55,10 +64,20 @@ public class IrisSettingsService { private final AuthorizationCheckService authCheckService; - public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, AuthorizationCheckService authCheckService) { + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final ObjectMapper objectMapper; + + private final TextExerciseRepository textExerciseRepository; + + public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, AuthorizationCheckService authCheckService, + ProgrammingExerciseRepository programmingExerciseRepository, ObjectMapper objectMapper, TextExerciseRepository textExerciseRepository) { this.irisSettingsRepository = irisSettingsRepository; this.irisSubSettingsService = irisSubSettingsService; this.authCheckService = authCheckService; + this.programmingExerciseRepository = programmingExerciseRepository; + this.objectMapper = objectMapper; + this.textExerciseRepository = textExerciseRepository; } /** @@ -248,6 +267,11 @@ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSetti * @return The updated course Iris settings */ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSettings, IrisCourseSettings settingsUpdate) { + var oldEnabledForCategoriesExerciseChat = existingSettings.getIrisChatSettings() == null ? new TreeSet() + : existingSettings.getIrisChatSettings().getEnabledForCategories(); + var oldEnabledForCategoriesTextExerciseChat = existingSettings.getIrisTextExerciseChatSettings() == null ? new TreeSet() + : existingSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + var parentSettings = getCombinedIrisGlobalSettings(); // @formatter:off existingSettings.setIrisChatSettings(irisSubSettingsService.update( @@ -276,9 +300,125 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti )); // @formatter:on + // Automatically update the exercise settings when the enabledForCategories is changed + var newEnabledForCategoriesExerciseChat = existingSettings.getIrisChatSettings() == null ? new TreeSet() + : existingSettings.getIrisChatSettings().getEnabledForCategories(); + if (!oldEnabledForCategoriesExerciseChat.equals(newEnabledForCategoriesExerciseChat)) { + programmingExerciseRepository.findAllWithCategoriesByCourseId(existingSettings.getCourse().getId()) + .forEach(exercise -> setEnabledForExerciseByCategories(exercise, oldEnabledForCategoriesExerciseChat, newEnabledForCategoriesExerciseChat)); + } + + var newEnabledForCategoriesTextExerciseChat = existingSettings.getIrisTextExerciseChatSettings() == null ? new TreeSet() + : existingSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + if (!Objects.equals(oldEnabledForCategoriesTextExerciseChat, newEnabledForCategoriesTextExerciseChat)) { + textExerciseRepository.findAllWithCategoriesByCourseId(existingSettings.getCourse().getId()) + .forEach(exercise -> setEnabledForExerciseByCategories(exercise, oldEnabledForCategoriesTextExerciseChat, newEnabledForCategoriesTextExerciseChat)); + } + return irisSettingsRepository.save(existingSettings); } + /** + * Set the enabled status for an exercise based on it's categories. + * Compares the old and new enabled categories, reads the exercise categories, + * and updates the Iris chat settings accordingly if the new enabled categories match any of the exercise categories. + * This method is used when the enabled categories of the course settings are updated. + * + * @param exercise The exercise to update the enabled status for + * @param oldEnabledForCategories The old enabled categories + * @param newEnabledForCategories The new enabled categories + */ + public void setEnabledForExerciseByCategories(Exercise exercise, SortedSet oldEnabledForCategories, SortedSet newEnabledForCategories) { + var removedCategories = new TreeSet<>(oldEnabledForCategories); + removedCategories.removeAll(newEnabledForCategories); + var categories = getCategoryNames(exercise.getCategories()); + + if (categories.stream().anyMatch(newEnabledForCategories::contains)) { + setExerciseSettingsEnabled(exercise, true); + } + else if (categories.stream().anyMatch(removedCategories::contains)) { + setExerciseSettingsEnabled(exercise, false); + } + } + + /** + * Set the enabled status for an exercise based on its categories. + * Reads the exercise categories and updates the Iris chat settings accordingly if the enabled categories match any of the exercise categories. + * This method is used when the categories of an exercise are updated. + * + * @param exercise The exercise to update the enabled status for + * @param oldExerciseCategories The old exercise categories + */ + public void setEnabledForExerciseByCategories(Exercise exercise, Set oldExerciseCategories) { + var oldCategories = getCategoryNames(oldExerciseCategories); + var newCategories = getCategoryNames(exercise.getCategories()); + if (oldCategories.isEmpty() && newCategories.isEmpty()) { + return; + } + + var course = exercise.getCourseViaExerciseGroupOrCourseMember(); + var courseSettings = getRawIrisSettingsFor(course); + + Set enabledForCategories; + if (exercise instanceof ProgrammingExercise) { + enabledForCategories = courseSettings.getIrisChatSettings().getEnabledForCategories(); + } + else if (exercise instanceof TextExercise) { + enabledForCategories = courseSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + } + else { + return; + } + if (enabledForCategories == null) { + return; + } + + if (newCategories.stream().anyMatch(enabledForCategories::contains)) { + setExerciseSettingsEnabled(exercise, true); + } + else if (oldCategories.stream().anyMatch(enabledForCategories::contains)) { + setExerciseSettingsEnabled(exercise, false); + } + } + + /** + * Helper method to set the enabled status for an exercise's Iris settings. + * Currently able to handle {@link ProgrammingExercise} and {@link TextExercise} settings. + * + * @param exercise The exercise to update the enabled status for + * @param enabled Whether the Iris settings should be enabled + */ + private void setExerciseSettingsEnabled(Exercise exercise, boolean enabled) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(enabled); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(enabled); + } + irisSettingsRepository.save(exerciseSettings); + } + + /** + * Convert the category JSON strings of an exercise to a set of category names. + * + * @param exerciseCategories The set of category JSON strings + * @return The set of category names + */ + private Set getCategoryNames(Set exerciseCategories) { + var categories = new HashSet(); + for (var categoryJson : exerciseCategories) { + try { + var category = objectMapper.readTree(categoryJson); + categories.add(category.get("category").asText()); + } + catch (JsonProcessingException e) { + return new HashSet<>(); + } + } + return categories; + } + /** * Helper method to update exercise Iris settings. * diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index 4e804701151a..2c284b6ea1f8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCompetencyGenerationSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; @@ -71,6 +72,10 @@ public IrisChatSubSettings update(IrisChatSubSettings currentSettings, IrisChatS if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); } + if (settingsType == IrisSettingsType.COURSE) { + var enabledForCategories = newSettings.getEnabledForCategories(); + currentSettings.setEnabledForCategories(enabledForCategories); + } if (authCheckService.isAdmin()) { currentSettings.setRateLimit(newSettings.getRateLimit()); currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); @@ -104,6 +109,10 @@ public IrisTextExerciseChatSubSettings update(IrisTextExerciseChatSubSettings cu if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); } + if (settingsType == IrisSettingsType.COURSE) { + var enabledForCategories = newSettings.getEnabledForCategories(); + currentSettings.setEnabledForCategories(enabledForCategories); + } if (authCheckService.isAdmin()) { currentSettings.setRateLimit(newSettings.getRateLimit()); currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); @@ -229,7 +238,8 @@ public IrisCombinedTextExerciseChatSubSettingsDTO combineTextExerciseChatSetting var rateLimit = getCombinedRateLimit(settingsList); var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; - return new IrisCombinedTextExerciseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant); + var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; + return new IrisCombinedTextExerciseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); } /** @@ -246,7 +256,8 @@ public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList) { * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. * @return Combined allowedVariants field. */ - private Set getCombinedAllowedVariants(List settingsList, Function subSettingsFunction) { + private SortedSet getCombinedAllowedVariants(List settingsList, Function subSettingsFunction) { return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getAllowedVariants).filter(Objects::nonNull) .filter(models -> !models.isEmpty()).reduce((first, second) -> second).orElse(new TreeSet<>()); } @@ -338,4 +349,10 @@ private String getCombinedSelectedVariant(List settingsList, Funct return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getSelectedVariant) .filter(model -> model != null && !model.isBlank()).reduce((first, second) -> second).orElse(null); } + + private SortedSet getCombinedEnabledForCategories(List settingsList, Function subSettingsFunction) { + return settingsList.stream().filter(Objects::nonNull).filter(settings -> settings instanceof IrisCourseSettings).map(subSettingsFunction).filter(Objects::nonNull) + .map(IrisChatSubSettings::getEnabledForCategories).filter(Objects::nonNull).filter(models -> !models.isEmpty()).reduce((first, second) -> second) + .orElse(new TreeSet<>()); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/websocket/IrisChatWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/websocket/IrisChatWebsocketService.java index 320a3103fe99..d6625dcc6f40 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/websocket/IrisChatWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/websocket/IrisChatWebsocketService.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.core.domain.LLMRequest; import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; import de.tum.cit.aet.artemis.iris.domain.session.IrisChatSession; import de.tum.cit.aet.artemis.iris.dto.IrisChatWebsocketDTO; @@ -41,7 +42,7 @@ public void sendMessage(IrisChatSession session, IrisMessage irisMessage, List

stages) { - this.sendStatusUpdate(session, stages, null); + this.sendStatusUpdate(session, stages, null, null); } /** @@ -61,12 +62,13 @@ public void sendStatusUpdate(IrisChatSession session, List stages * @param session the session to send the status update to * @param stages the stages to send * @param suggestions the suggestions to send + * @param tokens token usage and cost send by Pyris */ - public void sendStatusUpdate(IrisChatSession session, List stages, List suggestions) { + public void sendStatusUpdate(IrisChatSession session, List stages, List suggestions, List tokens) { var user = session.getUser(); var rateLimitInfo = rateLimitService.getRateLimitInformation(user); var topic = "" + session.getId(); // Todo: add more specific topic - var payload = new IrisChatWebsocketDTO(null, rateLimitInfo, stages, suggestions); + var payload = new IrisChatWebsocketDTO(null, rateLimitInfo, stages, suggestions, tokens); websocketService.send(user.getLogin(), topic, payload); } } 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 cfed1218740b..c609a9f7b049 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 @@ -12,13 +12,13 @@ import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import org.hibernate.Hibernate; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @Entity @@ -66,13 +66,15 @@ public void setReleaseDate(ZonedDateTime releaseDate) { } @Override - public Set getCompetencies() { - return exercise == null || !Hibernate.isPropertyInitialized(exercise, "competencies") ? new HashSet<>() : exercise.getCompetencies(); + @JsonIgnore + public Set getCompetencyLinks() { + // Set the links in the associated exercise instead + return new HashSet<>(); } @Override - public void setCompetencies(Set competencies) { - // Should be set in associated exercise + public void setCompetencyLinks(Set competencyLinks) { + // Retrieve the link in the associated exercise instead" } /** @@ -83,7 +85,6 @@ public void setCompetencies(Set competencies) { public void prePersistOrUpdate() { this.name = null; this.releaseDate = null; - this.competencies = new HashSet<>(); } // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/LectureUnit.java b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/LectureUnit.java index 5a46db2a15a5..3d05dabb1b72 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/domain/LectureUnit.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/domain/LectureUnit.java @@ -15,11 +15,8 @@ import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.JoinColumn; -import jakarta.persistence.JoinTable; -import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; -import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.Transient; @@ -34,7 +31,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import de.tum.cit.aet.artemis.atlas.domain.LearningObject; -import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.domain.User; @@ -72,12 +69,10 @@ public abstract class LectureUnit extends DomainObject implements LearningObject @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) private Lecture lecture; - @ManyToMany - @JoinTable(name = "competency_lecture_unit", joinColumns = @JoinColumn(name = "lecture_unit_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "competency_id", referencedColumnName = "id")) - @OrderBy("title") - @JsonIgnoreProperties({ "lectureUnits", "course" }) + @OneToMany(mappedBy = "lectureUnit", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnoreProperties("lectureUnit") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) - protected Set competencies = new HashSet<>(); + protected Set competencyLinks = new HashSet<>(); @OneToMany(mappedBy = "lectureUnit", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) @JsonIgnore // important, so that the completion status of other users do not leak to anyone @@ -108,12 +103,12 @@ public void setReleaseDate(ZonedDateTime releaseDate) { } @Override - public Set getCompetencies() { - return competencies; + public Set getCompetencyLinks() { + return competencyLinks; } - public void setCompetencies(Set competencies) { - this.competencies = competencies; + public void setCompetencyLinks(Set competencyLinks) { + this.competencyLinks = competencyLinks; } @JsonIgnore(false) 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 0fef3fef3512..61f268d75b95 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 @@ -58,7 +58,8 @@ default List findAllByLectureIdAndAttachmentTypeElseThrow(Long l SELECT attachmentUnit FROM AttachmentUnit attachmentUnit LEFT JOIN FETCH attachmentUnit.slides slides - LEFT JOIN FETCH attachmentUnit.competencies + LEFT JOIN FETCH attachmentUnit.competencyLinks cl + LEFT JOIN FETCH cl.competency WHERE attachmentUnit.id = :attachmentUnitId """) AttachmentUnit findOneWithSlidesAndCompetencies(@Param("attachmentUnitId") long attachmentUnitId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/ExerciseUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/ExerciseUnitRepository.java index 7fed45abdec0..b4858f3dd095 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/ExerciseUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/ExerciseUnitRepository.java @@ -29,8 +29,10 @@ public interface ExerciseUnitRepository extends ArtemisJpaRepository findByIdWithCompetenciesBidirectional(@Param("exerciseId") Long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java index 621216563727..d0fd1afbf8a6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java @@ -63,9 +63,11 @@ public interface LectureRepository extends ArtemisJpaRepository { LEFT JOIN FETCH lecture.posts LEFT JOIN FETCH lecture.lectureUnits lu LEFT JOIN FETCH lu.completedUsers cu - LEFT JOIN FETCH lu.competencies - LEFT JOIN FETCH lu.exercise exercise - LEFT JOIN FETCH exercise.competencies + LEFT JOIN FETCH lu.competencyLinks cl + LEFT JOIN FETCH cl.competency + LEFT JOIN FETCH lu.exercise e + LEFT JOIN FETCH e.competencyLinks ecl + LEFT JOIN FETCH ecl.competency WHERE lecture.id = :lectureId """) Optional findByIdWithAttachmentsAndPostsAndLectureUnitsAndCompetenciesAndCompletions(@Param("lectureId") Long lectureId); @@ -74,9 +76,11 @@ public interface LectureRepository extends ArtemisJpaRepository { SELECT lecture FROM Lecture lecture LEFT JOIN FETCH lecture.lectureUnits lu - LEFT JOIN FETCH lu.competencies - LEFT JOIN FETCH lu.exercise exercise - LEFT JOIN FETCH exercise.competencies + LEFT JOIN FETCH lu.competencyLinks cl + LEFT JOIN FETCH cl.competency + LEFT JOIN FETCH lu.exercise e + LEFT JOIN FETCH e.competencyLinks ecl + LEFT JOIN FETCH ecl.competency WHERE lecture.id = :lectureId """) Optional findByIdWithLectureUnitsAndCompetencies(@Param("lectureId") Long lectureId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitCompletionRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitCompletionRepository.java index 7d9dab5f8f6f..c45408c2004a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitCompletionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitCompletionRepository.java @@ -38,14 +38,6 @@ public interface LectureUnitCompletionRepository extends ArtemisJpaRepository findByLectureUnitsAndUserId(@Param("lectureUnits") Collection lectureUnits, @Param("userId") Long userId); - @Query(""" - SELECT COUNT(lectureUnitCompletion) - FROM LectureUnitCompletion lectureUnitCompletion - WHERE lectureUnitCompletion.lectureUnit.id IN :lectureUnitIds - AND lectureUnitCompletion.user.id = :userId - """) - int countByLectureUnitIdsAndUserId(@Param("lectureUnitIds") Collection lectureUnitIds, @Param("userId") Long userId); - @Query(""" SELECT user FROM LectureUnitCompletion lectureUnitCompletion diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java index ee29df83380a..031fdc300e9d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java @@ -24,9 +24,11 @@ public interface LectureUnitRepository extends ArtemisJpaRepository findByIdWithCompetenciesBidirectional(@Param("lectureUnitId") long lectureUnitId); @@ -46,10 +51,13 @@ public interface LectureUnitRepository extends ArtemisJpaRepository findAllByIdWithCompetenciesBidirectional(@Param("lectureUnitIds") Iterable longs); @@ -75,7 +83,8 @@ public interface LectureUnitRepository extends ArtemisJpaRepository findByIdWithCompetencies(@Param("onlineUnitId") long onlineUnitId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/TextUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/TextUnitRepository.java index 38fc336c4642..3cbcd78236ad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/TextUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/TextUnitRepository.java @@ -22,7 +22,8 @@ public interface TextUnitRepository extends ArtemisJpaRepository @Query(""" SELECT tu FROM TextUnit tu - LEFT JOIN FETCH tu.competencies + LEFT JOIN FETCH tu.competencyLinks cl + LEFT JOIN FETCH cl.competency WHERE tu.id = :textUnitId """) Optional findByIdWithCompetencies(@Param("textUnitId") Long textUnitId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/VideoUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/VideoUnitRepository.java index 419e607a1ecc..1435525961a5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/VideoUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/VideoUnitRepository.java @@ -24,7 +24,8 @@ public interface VideoUnitRepository extends ArtemisJpaRepository findByIdWithCompetencies(@Param("videoUnitId") long videoUnitId); 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 44a5811c6020..e86d7c22e7f5 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 @@ -5,6 +5,7 @@ import java.net.URI; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -15,7 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyLectureUnitLink; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.service.FilePathService; @@ -50,9 +51,11 @@ public class AttachmentUnitService { private final CompetencyProgressService competencyProgressService; + private final LectureUnitService lectureUnitService; + public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterService slideSplitterService, AttachmentUnitRepository attachmentUnitRepository, AttachmentRepository attachmentRepository, FileService fileService, Optional pyrisWebhookService, - Optional irisSettingsRepository, CompetencyProgressService competencyProgressService) { + Optional irisSettingsRepository, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { this.attachmentUnitRepository = attachmentUnitRepository; this.attachmentRepository = attachmentRepository; this.fileService = fileService; @@ -61,6 +64,7 @@ public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterServi this.pyrisWebhookService = pyrisWebhookService; this.irisSettingsRepository = irisSettingsRepository; this.competencyProgressService = competencyProgressService; + this.lectureUnitService = lectureUnitService; } /** @@ -76,7 +80,9 @@ public AttachmentUnitService(SlideRepository slideRepository, SlideSplitterServi public AttachmentUnit createAttachmentUnit(AttachmentUnit attachmentUnit, Attachment attachment, Lecture lecture, MultipartFile file, boolean keepFilename) { // persist lecture unit before lecture to prevent "null index column for collection" error attachmentUnit.setLecture(null); - AttachmentUnit savedAttachmentUnit = attachmentUnitRepository.saveAndFlush(attachmentUnit); + + AttachmentUnit savedAttachmentUnit = lectureUnitService.saveWithCompetencyLinks(attachmentUnit, attachmentUnitRepository::saveAndFlush); + attachmentUnit.setLecture(lecture); lecture.addLectureUnit(savedAttachmentUnit); @@ -106,14 +112,13 @@ public AttachmentUnit createAttachmentUnit(AttachmentUnit attachmentUnit, Attach */ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit, AttachmentUnit updateUnit, Attachment updateAttachment, MultipartFile updateFile, boolean keepFilename) { - Set existingCompetencies = existingAttachmentUnit.getCompetencies(); + Set existingCompetencyLinks = new HashSet<>(existingAttachmentUnit.getCompetencyLinks()); existingAttachmentUnit.setDescription(updateUnit.getDescription()); existingAttachmentUnit.setName(updateUnit.getName()); existingAttachmentUnit.setReleaseDate(updateUnit.getReleaseDate()); - existingAttachmentUnit.setCompetencies(updateUnit.getCompetencies()); - AttachmentUnit savedAttachmentUnit = attachmentUnitRepository.saveAndFlush(existingAttachmentUnit); + AttachmentUnit savedAttachmentUnit = lectureUnitService.saveWithCompetencyLinks(existingAttachmentUnit, attachmentUnitRepository::saveAndFlush); Attachment existingAttachment = existingAttachmentUnit.getAttachment(); if (existingAttachment == null) { @@ -147,7 +152,7 @@ public AttachmentUnit updateAttachmentUnit(AttachmentUnit existingAttachmentUnit } // Set the original competencies back to the attachment unit so that the competencyProgressService can determine which competencies changed - existingAttachmentUnit.setCompetencies(existingCompetencies); + existingAttachmentUnit.setCompetencyLinks(existingCompetencyLinks); competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingAttachmentUnit, Optional.of(updateUnit)); return savedAttachmentUnit; diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java index e310bb6c4e5b..3095605139a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureService.java @@ -13,6 +13,7 @@ import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; 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; @@ -47,14 +48,18 @@ public class LectureService { private final CompetencyProgressService competencyProgressService; + private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + public LectureService(LectureRepository lectureRepository, AuthorizationCheckService authCheckService, ChannelRepository channelRepository, ChannelService channelService, - Optional pyrisWebhookService, CompetencyProgressService competencyProgressService) { + Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, + CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { this.lectureRepository = lectureRepository; this.authCheckService = authCheckService; this.channelRepository = channelRepository; this.channelService = channelService; this.pyrisWebhookService = pyrisWebhookService; this.competencyProgressService = competencyProgressService; + this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; } /** @@ -162,6 +167,9 @@ public void delete(Lecture lecture, boolean updateCompetencyProgress) { Channel lectureChannel = channelRepository.findChannelByLectureId(lecture.getId()); channelService.deleteChannel(lectureChannel); + + competencyLectureUnitLinkRepository.deleteAllByLectureId(lecture.getId()); + lectureRepository.deleteById(lecture.getId()); } 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 fd371f07c9e4..793752998f29 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 @@ -8,27 +8,29 @@ import java.net.URL; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.function.Predicate; +import java.util.function.Function; import java.util.stream.Collectors; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.BadRequestException; +import org.hibernate.Hibernate; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +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.repository.CompetencyLectureUnitLinkRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.service.FilePathService; import de.tum.cit.aet.artemis.core.service.FileService; -import de.tum.cit.aet.artemis.exercise.domain.Exercise; -import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; import de.tum.cit.aet.artemis.lecture.domain.AttachmentUnit; import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; @@ -55,26 +57,26 @@ public class LectureUnitService { private final SlideRepository slideRepository; - private final ExerciseRepository exerciseRepository; - private final Optional pyrisWebhookService; private final CompetencyProgressService competencyProgressService; private final CourseCompetencyRepository courseCompetencyRepository; + private final CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository; + public LectureUnitService(LectureUnitRepository lectureUnitRepository, LectureRepository lectureRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - FileService fileService, SlideRepository slideRepository, ExerciseRepository exerciseRepository, Optional pyrisWebhookService, - CompetencyProgressService competencyProgressService, CourseCompetencyRepository courseCompetencyRepository) { + FileService fileService, SlideRepository slideRepository, Optional pyrisWebhookService, CompetencyProgressService competencyProgressService, + CourseCompetencyRepository courseCompetencyRepository, CompetencyLectureUnitLinkRepository competencyLectureUnitLinkRepository) { this.lectureUnitRepository = lectureUnitRepository; this.lectureRepository = lectureRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.fileService = fileService; this.slideRepository = slideRepository; - this.exerciseRepository = exerciseRepository; this.pyrisWebhookService = pyrisWebhookService; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyProgressService = competencyProgressService; + this.competencyLectureUnitLinkRepository = competencyLectureUnitLinkRepository; } /** @@ -156,16 +158,6 @@ private LectureUnitCompletion createLectureUnitCompletion(LectureUnit lectureUni public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { LectureUnit lectureUnitToDelete = lectureUnitRepository.findByIdWithCompetenciesAndSlidesElseThrow(lectureUnit.getId()); - if (!(lectureUnitToDelete instanceof ExerciseUnit)) { - // update associated competencies - Set competencies = lectureUnitToDelete.getCompetencies(); - courseCompetencyRepository.saveAll(competencies.stream().map(competency -> { - competency = courseCompetencyRepository.findByIdWithLectureUnitsElseThrow(competency.getId()); - competency.getLectureUnits().remove(lectureUnitToDelete); - return competency; - }).toList()); - } - if (lectureUnitToDelete instanceof AttachmentUnit attachmentUnit) { fileService.schedulePathForDeletion(FilePathService.actualPathForPublicPathOrThrow(URI.create((attachmentUnit.getAttachment().getLink()))), 5); if (attachmentUnit.getSlides() != null && !attachmentUnit.getSlides().isEmpty()) { @@ -190,42 +182,26 @@ public void removeLectureUnit(@NotNull LectureUnit lectureUnit) { } /** - * Link the competency to a set of lecture units (and exercises if it includes exercise units) + * Link the competency to a set of lecture units * - * @param competency The competency to be linked - * @param lectureUnitsToAdd A set of lecture units to link to the specified competency - * @param lectureUnitsToRemove A set of lecture units to unlink from the specified competency + * @param competency The competency to be linked + * @param lectureUnitLinks New set of lecture unit links to associate with the competency */ - public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitsToAdd, Set lectureUnitsToRemove) { - final Predicate isExerciseUnit = lectureUnit -> lectureUnit instanceof ExerciseUnit; - - // Remove the competency from the old lecture units - var lectureUnitsToRemoveFromDb = lectureUnitRepository.findAllByIdWithCompetenciesBidirectional(lectureUnitsToRemove.stream().map(LectureUnit::getId).toList()); - lectureUnitRepository.saveAll(lectureUnitsToRemoveFromDb.stream().filter(isExerciseUnit.negate()).peek(lectureUnit -> lectureUnit.getCompetencies().remove(competency)) - .collect(Collectors.toSet())); - exerciseRepository.saveAll(lectureUnitsToRemoveFromDb.stream().filter(isExerciseUnit).map(lectureUnit -> ((ExerciseUnit) lectureUnit).getExercise()) - .peek(exercise -> exercise.getCompetencies().remove(competency)).collect(Collectors.toSet())); - - // Add the competency to the new lecture units - var lectureUnitsFromDb = lectureUnitRepository.findAllByIdWithCompetenciesBidirectional(lectureUnitsToAdd.stream().map(LectureUnit::getId).toList()); - var lectureUnitsWithoutExercises = lectureUnitsFromDb.stream().filter(isExerciseUnit.negate()).collect(Collectors.toSet()); - var exercises = lectureUnitsFromDb.stream().filter(isExerciseUnit).map(lectureUnit -> ((ExerciseUnit) lectureUnit).getExercise()).collect(Collectors.toSet()); - lectureUnitsWithoutExercises.stream().map(LectureUnit::getCompetencies).forEach(competencies -> competencies.add(competency)); - exercises.stream().map(Exercise::getCompetencies).forEach(competencies -> competencies.add(competency)); - lectureUnitRepository.saveAll(lectureUnitsWithoutExercises); - exerciseRepository.saveAll(exercises); - competency.setLectureUnits(lectureUnitsToAdd); + public void linkLectureUnitsToCompetency(CourseCompetency competency, Set lectureUnitLinks) { + lectureUnitLinks.forEach(link -> link.setCompetency(competency)); + competency.setLectureUnitLinks(lectureUnitLinks); + courseCompetencyRepository.save(competency); } /** * Removes competency from all lecture units. * - * @param lectureUnits set of lecture units - * @param competency competency to remove + * @param lectureUnitLinks set of lecture unit links + * @param competency competency to remove */ - public void removeCompetency(Set lectureUnits, CourseCompetency competency) { - lectureUnits.forEach(lectureUnit -> lectureUnit.getCompetencies().remove(competency)); - lectureUnitRepository.saveAll(lectureUnits); + public void removeCompetency(Set lectureUnitLinks, CourseCompetency competency) { + competencyLectureUnitLinkRepository.deleteAll(lectureUnitLinks); + competency.getLectureUnitLinks().removeAll(lectureUnitLinks); } /** @@ -243,4 +219,46 @@ public URL validateUrlStringAndReturnUrl(String urlString) { throw new BadRequestException(); } } + + /** + * Disconnects the competency exercise links from the exercise before the cycle is broken by the deserialization. + * + * @param lectureUnit The lecture unit to disconnect the competency links + */ + public void disconnectCompetencyLectureUnitLinks(LectureUnit lectureUnit) { + lectureUnit.getCompetencyLinks().forEach(link -> link.getCompetency().setLectureUnitLinks(null)); + } + + /** + * Reconnects the competency exercise links to the exercise after the cycle was broken by the deserialization. + * + * @param lectureUnit The lecture unit to reconnect the competency links + */ + public void reconnectCompetencyLectureUnitLinks(LectureUnit lectureUnit) { + lectureUnit.getCompetencyLinks().forEach(link -> link.setLectureUnit(lectureUnit)); + } + + /** + * Saves the exercise and links it to the competencies. + * + * @param lectureUnit the lecture unit to save + * @param saveFunction function to save the exercise + * @param type of the lecture unit + * @return saved exercise + */ + public T saveWithCompetencyLinks(T lectureUnit, Function saveFunction) { + // persist lecture Unit before linking it to the competency + Set links = lectureUnit.getCompetencyLinks(); + lectureUnit.setCompetencyLinks(new HashSet<>()); + + T savedLectureUnit = saveFunction.apply(lectureUnit); + + if (Hibernate.isInitialized(links) && !links.isEmpty()) { + savedLectureUnit.setCompetencyLinks(links); + reconnectCompetencyLectureUnitLinks(savedLectureUnit); + savedLectureUnit.setCompetencyLinks(new HashSet<>(competencyLectureUnitLinkRepository.saveAll(links))); + } + + return savedLectureUnit; + } } 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 6ea319ae365a..df85a54b6675 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 @@ -381,7 +381,7 @@ else if (lectureUnit instanceof AttachmentUnit) { // we replace the exercise with one that contains all the information needed for correct display exercisesWithAllInformationNeeded.stream().filter(exercise::equals).findAny().ifPresent(((ExerciseUnit) lectureUnit)::setExercise); // re-add the competencies already loaded with the exercise unit - ((ExerciseUnit) lectureUnit).getExercise().setCompetencies(exercise.getCompetencies()); + ((ExerciseUnit) lectureUnit).getExercise().setCompetencyLinks(exercise.getCompetencyLinks()); } }).toList(); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java index 473d0201c294..c5e8bfa600d1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/OnlineUnitResource.java @@ -80,7 +80,7 @@ public OnlineUnitResource(LectureRepository lectureRepository, AuthorizationChec @EnforceAtLeastEditor public ResponseEntity getOnlineUnit(@PathVariable Long onlineUnitId, @PathVariable Long lectureId) { log.debug("REST request to get onlineUnit : {}", onlineUnitId); - var onlineUnit = onlineUnitRepository.findByIdElseThrow(onlineUnitId); + var onlineUnit = onlineUnitRepository.findByIdWithCompetenciesElseThrow(onlineUnitId); checkOnlineUnitCourseAndLecture(onlineUnit, lectureId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, onlineUnit.getLecture().getCourse(), null); return ResponseEntity.ok().body(onlineUnit); @@ -108,7 +108,7 @@ public ResponseEntity updateOnlineUnit(@PathVariable Long lectureId, authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, onlineUnit.getLecture().getCourse(), null); - OnlineUnit result = onlineUnitRepository.save(onlineUnit); + OnlineUnit result = lectureUnitService.saveWithCompetencyLinks(onlineUnit, onlineUnitRepository::save); competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingOnlineUnit, Optional.of(onlineUnit)); @@ -141,7 +141,8 @@ public ResponseEntity createOnlineUnit(@PathVariable Long lectureId, // persist lecture unit before lecture to prevent "null index column for collection" error onlineUnit.setLecture(null); - OnlineUnit persistedOnlineUnit = onlineUnitRepository.saveAndFlush(onlineUnit); + + OnlineUnit persistedOnlineUnit = lectureUnitService.saveWithCompetencyLinks(onlineUnit, onlineUnitRepository::saveAndFlush); persistedOnlineUnit.setLecture(lecture); lecture.addLectureUnit(persistedOnlineUnit); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java index 02bfaf149839..b48d11ca87d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/TextUnitResource.java @@ -28,6 +28,7 @@ import de.tum.cit.aet.artemis.lecture.domain.TextUnit; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; import de.tum.cit.aet.artemis.lecture.repository.TextUnitRepository; +import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @Profile(PROFILE_CORE) @RestController @@ -46,12 +47,15 @@ public class TextUnitResource { private final CompetencyProgressService competencyProgressService; + private final LectureUnitService lectureUnitService; + public TextUnitResource(LectureRepository lectureRepository, TextUnitRepository textUnitRepository, AuthorizationCheckService authorizationCheckService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService) { this.lectureRepository = lectureRepository; this.textUnitRepository = textUnitRepository; this.authorizationCheckService = authorizationCheckService; this.competencyProgressService = competencyProgressService; + this.lectureUnitService = lectureUnitService; } /** @@ -101,7 +105,8 @@ public ResponseEntity updateTextUnit(@PathVariable Long lectureId, @Re textUnitForm.setId(existingTextUnit.getId()); textUnitForm.setLecture(existingTextUnit.getLecture()); - TextUnit result = textUnitRepository.save(textUnitForm); + + TextUnit result = lectureUnitService.saveWithCompetencyLinks(textUnitForm, textUnitRepository::save); competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingTextUnit, Optional.of(textUnitForm)); @@ -133,7 +138,9 @@ public ResponseEntity createTextUnit(@PathVariable Long lectureId, @Re // persist lecture unit before lecture to prevent "null index column for collection" error textUnit.setLecture(null); - textUnit = textUnitRepository.saveAndFlush(textUnit); + + textUnit = lectureUnitService.saveWithCompetencyLinks(textUnit, textUnitRepository::saveAndFlush); + textUnit.setLecture(lecture); lecture.addLectureUnit(textUnit); Lecture updatedLecture = lectureRepository.save(lecture); @@ -141,6 +148,7 @@ public ResponseEntity createTextUnit(@PathVariable Long lectureId, @Re competencyProgressService.updateProgressByLearningObjectAsync(persistedTextUnit); + lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedTextUnit); return ResponseEntity.created(new URI("/api/text-units/" + persistedTextUnit.getId())).body(persistedTextUnit); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java index ae67b82c02e1..e38d2580f83e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/VideoUnitResource.java @@ -70,7 +70,7 @@ public VideoUnitResource(LectureRepository lectureRepository, AuthorizationCheck @EnforceAtLeastEditor public ResponseEntity getVideoUnit(@PathVariable Long videoUnitId, @PathVariable Long lectureId) { log.debug("REST request to get VideoUnit : {}", videoUnitId); - var videoUnit = videoUnitRepository.findByIdElseThrow(videoUnitId); + var videoUnit = videoUnitRepository.findByIdWithCompetenciesElseThrow(videoUnitId); checkVideoUnitCourseAndLecture(videoUnit, lectureId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, videoUnit.getLecture().getCourse(), null); return ResponseEntity.ok().body(videoUnit); @@ -97,7 +97,7 @@ public ResponseEntity updateVideoUnit(@PathVariable Long lectureId, @ lectureUnitService.validateUrlStringAndReturnUrl(videoUnit.getSource()); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, videoUnit.getLecture().getCourse(), null); - VideoUnit result = videoUnitRepository.save(videoUnit); + VideoUnit result = lectureUnitService.saveWithCompetencyLinks(videoUnit, videoUnitRepository::save); competencyProgressService.updateProgressForUpdatedLearningObjectAsync(existingVideoUnit, Optional.of(videoUnit)); @@ -131,7 +131,9 @@ public ResponseEntity createVideoUnit(@PathVariable Long lectureId, @ // persist lecture unit before lecture to prevent "null index column for collection" error videoUnit.setLecture(null); - videoUnit = videoUnitRepository.saveAndFlush(videoUnit); + + videoUnit = lectureUnitService.saveWithCompetencyLinks(videoUnit, videoUnitRepository::saveAndFlush); + videoUnit.setLecture(lecture); lecture.addLectureUnit(videoUnit); Lecture updatedLecture = lectureRepository.save(lecture); @@ -139,6 +141,7 @@ public ResponseEntity createVideoUnit(@PathVariable Long lectureId, @ competencyProgressService.updateProgressByLearningObjectAsync(persistedVideoUnit); + lectureUnitService.disconnectCompetencyLectureUnitLinks(persistedVideoUnit); return ResponseEntity.created(new URI("/api/video-units/" + persistedVideoUnit.getId())).body(persistedVideoUnit); } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java index 626bb3c86694..5b25bfc46938 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java @@ -36,14 +36,15 @@ public interface ModelingExerciseRepository extends ArtemisJpaRepository findByCourseIdWithCategories(@Param("courseId") Long courseId); - @EntityGraph(type = LOAD, attributePaths = { "exampleSubmissions", "teamAssignmentConfig", "categories", "competencies", "exampleSubmissions.submission.results" }) + @EntityGraph(type = LOAD, attributePaths = { "exampleSubmissions", "teamAssignmentConfig", "categories", "competencyLinks.competency", + "exampleSubmissions.submission.results" }) Optional findWithEagerExampleSubmissionsAndCompetenciesById(Long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "exampleSubmissions", "teamAssignmentConfig", "categories", "competencies", "exampleSubmissions.submission.results", + @EntityGraph(type = LOAD, attributePaths = { "exampleSubmissions", "teamAssignmentConfig", "categories", "competencyLinks.competency", "exampleSubmissions.submission.results", "plagiarismDetectionConfig" }) Optional findWithEagerExampleSubmissionsAndCompetenciesAndPlagiarismDetectionConfigById(Long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies" }) + @EntityGraph(type = LOAD, attributePaths = { "competencyLinks.competency" }) Optional findWithEagerCompetenciesById(Long exerciseId); @Query(""" @@ -100,7 +101,8 @@ public interface ModelingExerciseRepository extends ArtemisJpaRepository gradingInstructionCopyTracker = new HashMap<>(); ModelingExercise newExercise = copyModelingExerciseBasis(importedExercise, gradingInstructionCopyTracker); - ModelingExercise newModelingExercise = modelingExerciseRepository.save(newExercise); + ModelingExercise newModelingExercise = exerciseService.saveWithCompetencyLinks(newExercise, modelingExerciseRepository::save); channelService.createExerciseChannel(newModelingExercise, Optional.ofNullable(importedExercise.getChannelName())); newModelingExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java index da9195b214d5..a2739ed28c76 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingExerciseResource.java @@ -177,7 +177,7 @@ public ResponseEntity createModelingExercise(@RequestBody Mode // Check that the user is authorized to create the exercise authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); - ModelingExercise result = modelingExerciseRepository.save(modelingExercise); + ModelingExercise result = exerciseService.saveWithCompetencyLinks(modelingExercise, modelingExerciseRepository::save); channelService.createExerciseChannel(result, Optional.ofNullable(modelingExercise.getChannelName())); modelingExerciseService.scheduleOperations(result.getId()); @@ -242,7 +242,8 @@ public ResponseEntity updateModelingExercise(@RequestBody Mode channelService.updateExerciseChannel(modelingExerciseBeforeUpdate, modelingExercise); - ModelingExercise updatedModelingExercise = modelingExerciseRepository.save(modelingExercise); + ModelingExercise updatedModelingExercise = exerciseService.saveWithCompetencyLinks(modelingExercise, modelingExerciseRepository::save); + exerciseService.logUpdate(modelingExercise, modelingExercise.getCourseViaExerciseGroupOrCourseMember(), user); exerciseService.updatePointsInRelatedParticipantScores(modelingExerciseBeforeUpdate, updatedModelingExercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/build/BuildJob.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/build/BuildJob.java index 53f3b75305f2..7a6aeafdbd04 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/build/BuildJob.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/build/BuildJob.java @@ -88,7 +88,7 @@ public BuildJob(BuildJobQueueItem queueItem, BuildStatus buildStatus, Result res this.courseId = queueItem.courseId(); this.participationId = queueItem.participationId(); this.result = result; - this.buildAgentAddress = queueItem.buildAgentAddress(); + this.buildAgentAddress = queueItem.buildAgent().memberAddress(); this.buildStartDate = queueItem.jobTimingInfo().buildStartDate(); this.buildCompletionDate = queueItem.jobTimingInfo().buildCompletionDate(); this.repositoryType = queueItem.repositoryInfo().repositoryType(); 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 1a1cd6167bea..9e9c996a8e37 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 @@ -1,17 +1,22 @@ package de.tum.cit.aet.artemis.programming.repository; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.util.Optional; +import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.EntityGraph; 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.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.build.BuildPlan; +@Profile(PROFILE_CORE) +@Repository public interface BuildPlanRepository extends ArtemisJpaRepository { @Query(""" 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 579d714b18a8..ed5e5e710341 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 @@ -73,16 +73,16 @@ public interface ProgrammingExerciseRepository extends DynamicSpecificationRepos "submissionPolicy", "buildConfig" }) Optional findWithTemplateAndSolutionParticipationTeamAssignmentConfigCategoriesAndBuildConfigById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencies", "auxiliaryRepositories", - "submissionPolicy" }) + @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencyLinks.competency", + "auxiliaryRepositories", "submissionPolicy" }) Optional findWithTemplateAndSolutionParticipationTeamAssignmentConfigCategoriesAndCompetenciesById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencies", "auxiliaryRepositories", - "submissionPolicy", "buildConfig" }) + @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencyLinks.competency", + "auxiliaryRepositories", "submissionPolicy", "buildConfig" }) Optional findWithTemplateAndSolutionParticipationTeamAssignmentConfigCategoriesCompetenciesAndBuildConfigById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencies", "auxiliaryRepositories", - "submissionPolicy", "plagiarismDetectionConfig", "buildConfig" }) + @EntityGraph(type = LOAD, attributePaths = { "templateParticipation", "solutionParticipation", "teamAssignmentConfig", "categories", "competencyLinks.competency", + "auxiliaryRepositories", "submissionPolicy", "plagiarismDetectionConfig", "buildConfig" }) Optional findWithTemplateAndSolutionParticipationTeamAssignmentConfigCategoriesAndCompetenciesAndPlagiarismDetectionConfigAndBuildConfigById( long exerciseId); @@ -108,8 +108,8 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign @EntityGraph(type = LOAD, attributePaths = "auxiliaryRepositories") Optional findWithAuxiliaryRepositoriesById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "auxiliaryRepositories", "competencies", "buildConfig" }) - Optional findWithAuxiliaryRepositoriesCompetenciesAndBuildConfigById(long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "auxiliaryRepositories", "competencyLinks.competency", "buildConfig", "categories" }) + Optional findForUpdateById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") Optional findWithSubmissionPolicyById(long exerciseId); @@ -118,9 +118,15 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign List findAllByCourseId(Long courseId); + @EntityGraph(type = LOAD, attributePaths = { "categories" }) + List findAllWithCategoriesByCourseId(Long courseId); + @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") List findWithSubmissionPolicyByProjectKey(String projectKey); + @EntityGraph(type = LOAD, attributePaths = { "buildConfig" }) + Optional findWithBuildConfigById(long exerciseId); + /** * Finds one programming exercise including its submission policy by the exercise's project key. * @@ -547,7 +553,7 @@ SELECT COUNT (DISTINCT p) @Query(""" SELECT e FROM ProgrammingExercise e - LEFT JOIN FETCH e.competencies + LEFT JOIN FETCH e.competencyLinks WHERE e.title = :title AND e.course.id = :courseId """) @@ -556,7 +562,7 @@ SELECT COUNT (DISTINCT p) @Query(""" SELECT e FROM ProgrammingExercise e - LEFT JOIN FETCH e.competencies + LEFT JOIN FETCH e.competencyLinks WHERE e.shortName = :shortName AND e.course.id = :courseId """) @@ -596,8 +602,8 @@ default ProgrammingExercise findByIdWithAuxiliaryRepositoriesElseThrow(long prog * @return The programming exercise related to the given id */ @NotNull - default ProgrammingExercise findByIdWithAuxiliaryRepositoriesCompetenciesAndBuildConfigElseThrow(long programmingExerciseId) throws EntityNotFoundException { - return getValueElseThrow(findWithAuxiliaryRepositoriesCompetenciesAndBuildConfigById(programmingExerciseId), programmingExerciseId); + default ProgrammingExercise findForUpdateByIdElseThrow(long programmingExerciseId) throws EntityNotFoundException { + return getValueElseThrow(findForUpdateById(programmingExerciseId), programmingExerciseId); } /** @@ -743,6 +749,18 @@ default ProgrammingExercise findForCreationByIdElseThrow(long programmingExercis return getValueElseThrow(findForCreationById(programmingExerciseId), programmingExerciseId); } + /** + * Find a programming exercise by its id, with eagerly loaded build config. + * + * @param programmingExerciseId of the programming exercise. + * @return The programming exercise related to the given id + * @throws EntityNotFoundException the programming exercise could not be found. + */ + @NotNull + default ProgrammingExercise findByIdWithBuildConfigElseThrow(long programmingExerciseId) throws EntityNotFoundException { + return getValueElseThrow(findWithBuildConfigById(programmingExerciseId), programmingExerciseId); + } + /** * Saves the given programming exercise to the database. *

@@ -958,7 +976,7 @@ enum ProgrammingExerciseFetchOptions implements FetchOptions { StaticCodeAnalysisCategories(ProgrammingExercise_.STATIC_CODE_ANALYSIS_CATEGORIES), SubmissionPolicy(ProgrammingExercise_.SUBMISSION_POLICY), ExerciseHints(ProgrammingExercise_.EXERCISE_HINTS), - Competencies(ProgrammingExercise_.COMPETENCIES), + CompetencyLinks(ProgrammingExercise_.COMPETENCY_LINKS), Teams(ProgrammingExercise_.TEAMS), TutorParticipations(ProgrammingExercise_.TUTOR_PARTICIPATIONS), ExampleSubmissions(ProgrammingExercise_.EXAMPLE_SUBMISSIONS), @@ -988,4 +1006,15 @@ public String getFetchPath() { default ProgrammingExercise findByIdElseThrow(long programmingExerciseId) { return getValueElseThrow(findById(programmingExerciseId)); } + + /** + * Find a programming exercise by its id, including its test cases, and throw an Exception if it cannot be found. + * + * @param exerciseId of the programming exercise. + * @return The programming exercise with the associated test cases related to the given id. + * @throws EntityNotFoundException if the programming exercise with the given id cannot be found. + */ + default ProgrammingExercise findWithTestCasesByIdElseThrow(Long exerciseId) { + return getArbitraryValueElseThrow(findWithTestCasesById(exerciseId), Long.toString(exerciseId)); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java index 7c78408aedb6..9c4a03766832 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/CodeHintRepository.java @@ -1,12 +1,16 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +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.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -15,6 +19,8 @@ /** * Spring Data repository for the CodeHint entity. */ +@Profile(PROFILE_CORE) +@Repository public interface CodeHintRepository extends ArtemisJpaRepository { Set findByExerciseId(Long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java index 5a24463cdc7f..c827a9b3052b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ExerciseHintActivationRepository.java @@ -1,14 +1,20 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; +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.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.programming.domain.hestia.ExerciseHintActivation; +@Profile(PROFILE_CORE) +@Repository public interface ExerciseHintActivationRepository extends ArtemisJpaRepository { @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java index 839a7d67dc49..14a03ed49c0a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseSolutionEntryRepository.java @@ -1,12 +1,16 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +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.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -15,6 +19,8 @@ /** * Spring Data repository for the ProgrammingExerciseSolutionEntry entity. */ +@Profile(PROFILE_CORE) +@Repository public interface ProgrammingExerciseSolutionEntryRepository extends ArtemisJpaRepository { /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java index 2c8db4544456..778c0c811374 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/hestia/ProgrammingExerciseTaskRepository.java @@ -1,12 +1,17 @@ package de.tum.cit.aet.artemis.programming.repository.hestia; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; import java.util.Optional; import java.util.Set; import jakarta.validation.constraints.NotNull; +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.core.exception.EntityNotFoundException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; @@ -15,37 +20,10 @@ /** * Spring Data repository for the ProgrammingExerciseTask entity. */ +@Profile(PROFILE_CORE) +@Repository public interface ProgrammingExerciseTaskRepository extends ArtemisJpaRepository { - Set findByExerciseId(Long exerciseId); - - /** - * Gets a task with its programming exercise, test cases and solution entries of the test cases - * - * @param entryId The id of the task - * @return The task with the given ID if found - * @throws EntityNotFoundException If no task with the given ID was found - */ - @NotNull - default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow(long entryId) throws EntityNotFoundException { - return getValueElseThrow(findByIdWithTestCaseAndSolutionEntries(entryId), entryId); - } - - /** - * Gets a task with its programming exercise, test cases and solution entries of the test cases - * - * @param entryId The id of the task - * @return The task with the given ID - */ - @Query(""" - SELECT t - FROM ProgrammingExerciseTask t - LEFT JOIN FETCH t.testCases tc - LEFT JOIN FETCH tc.solutionEntries - WHERE t.id = :entryId - """) - Optional findByIdWithTestCaseAndSolutionEntries(@Param("entryId") long entryId); - /** * Gets all tasks with its test cases and solution entries of the test case for a programming exercise * @@ -54,7 +32,7 @@ default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow( * @throws EntityNotFoundException If the exercise with exerciseId does not exist */ @NotNull - default Set findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(long exerciseId) throws EntityNotFoundException { + default List findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(long exerciseId) throws EntityNotFoundException { return getArbitraryValueElseThrow(findByExerciseIdWithTestCaseAndSolutionEntries(exerciseId), Long.toString(exerciseId)); } @@ -72,7 +50,7 @@ default Set findByExerciseIdWithTestCaseAndSolutionEntr WHERE t.exercise.id = :exerciseId AND tc.exercise.id = :exerciseId """) - Optional> findByExerciseIdWithTestCaseAndSolutionEntries(@Param("exerciseId") long exerciseId); + Optional> findByExerciseIdWithTestCaseAndSolutionEntries(@Param("exerciseId") long exerciseId); /** * Gets all tasks with its test cases for a programming exercise 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 bcd23f9ddda3..f0a2191d0e44 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 @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; +import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismDetectionConfig; import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -77,6 +78,8 @@ public class ProgrammingExerciseImportBasicService { private final ChannelService channelService; + private final ExerciseService exerciseService; + public ProgrammingExerciseImportBasicService(ExerciseHintService exerciseHintService, ExerciseHintRepository exerciseHintRepository, Optional versionControlService, ProgrammingExerciseParticipationService programmingExerciseParticipationService, ProgrammingExerciseTestCaseRepository programmingExerciseTestCaseRepository, StaticCodeAnalysisCategoryRepository staticCodeAnalysisCategoryRepository, @@ -84,7 +87,7 @@ public ProgrammingExerciseImportBasicService(ExerciseHintService exerciseHintSer AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, SubmissionPolicyRepository submissionPolicyRepository, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ProgrammingExerciseTaskService programmingExerciseTaskService, ProgrammingExerciseSolutionEntryRepository solutionEntryRepository, ChannelService channelService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, ExerciseService exerciseService) { this.exerciseHintService = exerciseHintService; this.exerciseHintRepository = exerciseHintRepository; this.versionControlService = versionControlService; @@ -101,6 +104,7 @@ public ProgrammingExerciseImportBasicService(ExerciseHintService exerciseHintSer this.solutionEntryRepository = solutionEntryRepository; this.channelService = channelService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; + this.exerciseService = exerciseService; } /** @@ -142,7 +146,8 @@ public ProgrammingExercise importProgrammingExerciseBasis(final ProgrammingExerc final Map newHintIdByOldId = exerciseHintService.copyExerciseHints(originalProgrammingExercise, newProgrammingExercise); newProgrammingExercise.setBuildConfig(programmingExerciseBuildConfigRepository.save(newProgrammingExercise.getBuildConfig())); - final ProgrammingExercise importedExercise = programmingExerciseRepository.save(newProgrammingExercise); + + final ProgrammingExercise importedExercise = exerciseService.saveWithCompetencyLinks(newProgrammingExercise, programmingExerciseRepository::save); final Map newTestCaseIdByOldId = importTestCases(originalProgrammingExercise, importedExercise); final Map newTaskIdByOldId = importTasks(originalProgrammingExercise, importedExercise, newTestCaseIdByOldId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index d4e7de493f52..65a12b87be77 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -272,7 +273,9 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // We save once in order to generate an id for the programming exercise var savedBuildConfig = programmingExerciseBuildConfigRepository.saveAndFlush(programmingExercise.getBuildConfig()); programmingExercise.setBuildConfig(savedBuildConfig); - var savedProgrammingExercise = programmingExerciseRepository.saveForCreation(programmingExercise); + + var savedProgrammingExercise = exerciseService.saveWithCompetencyLinks(programmingExercise, programmingExerciseRepository::saveForCreation); + savedProgrammingExercise.getBuildConfig().setProgrammingExercise(savedProgrammingExercise); programmingExerciseBuildConfigRepository.save(savedProgrammingExercise.getBuildConfig()); // Step 1: Setting constant facts for a programming exercise @@ -338,6 +341,11 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // Step 12d: Update student competency progress competencyProgressService.updateProgressByLearningObjectAsync(savedProgrammingExercise); + // Step 13: Set Iris settings + if (irisSettingsService.isPresent()) { + irisSettingsService.get().setEnabledForExerciseByCategories(savedProgrammingExercise, new HashSet<>()); + } + return programmingExerciseRepository.saveForCreation(savedProgrammingExercise); } @@ -604,7 +612,9 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program String problemStatementWithTestNames = updatedProgrammingExercise.getProblemStatement(); programmingExerciseTaskService.replaceTestNamesWithIds(updatedProgrammingExercise); programmingExerciseBuildConfigRepository.save(updatedProgrammingExercise.getBuildConfig()); - ProgrammingExercise savedProgrammingExercise = programmingExerciseRepository.save(updatedProgrammingExercise); + + ProgrammingExercise savedProgrammingExercise = exerciseService.saveWithCompetencyLinks(updatedProgrammingExercise, programmingExerciseRepository::save); + // The returned value should use test case names since it gets send back to the client savedProgrammingExercise.setProblemStatement(problemStatementWithTestNames); @@ -619,6 +629,9 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program competencyProgressService.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); + irisSettingsService + .ifPresent(settingsService -> settingsService.setEnabledForExerciseByCategories(savedProgrammingExercise, programmingExerciseBeforeUpdate.getCategories())); + return savedProgrammingExercise; } @@ -1004,7 +1017,7 @@ public boolean preCheckProjectExistsOnVCSOrCI(ProgrammingExercise programmingExe * @param exerciseId of the exercise */ public void deleteTasksWithSolutionEntries(Long exerciseId) { - Set tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); + List tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); Set solutionEntries = tasks.stream().map(ProgrammingExerciseTask::getTestCases).flatMap(Collection::stream) .map(ProgrammingExerciseTestCase::getSolutionEntries).flatMap(Collection::stream).collect(Collectors.toSet()); programmingExerciseTaskRepository.deleteAll(tasks); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java index 9292031adba5..e0ec98ed52cf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIService.java @@ -3,18 +3,25 @@ import static de.tum.cit.aet.artemis.core.config.Constants.NEW_RESULT_RESOURCE_API_PATH; import java.net.URL; +import java.time.ZonedDateTime; import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Supplier; +import org.gitlab4j.api.Constants; import org.gitlab4j.api.GitLabApi; import org.gitlab4j.api.GitLabApiException; +import org.gitlab4j.api.GroupApi; import org.gitlab4j.api.ProjectApi; +import org.gitlab4j.api.models.AccessLevel; import org.gitlab4j.api.models.Pipeline; import org.gitlab4j.api.models.PipelineFilter; import org.gitlab4j.api.models.PipelineStatus; import org.gitlab4j.api.models.Project; +import org.gitlab4j.api.models.ProjectAccessToken; import org.gitlab4j.api.models.Variable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +44,7 @@ import de.tum.cit.aet.artemis.programming.dto.CheckoutDirectoriesDTO; import de.tum.cit.aet.artemis.programming.repository.BuildPlanRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.service.UriService; import de.tum.cit.aet.artemis.programming.service.ci.AbstractContinuousIntegrationService; import de.tum.cit.aet.artemis.programming.service.ci.CIPermission; @@ -51,6 +59,8 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private static final String GITLAB_CI_FILE_EXTENSION = ".yml"; + private static final String GITLAB_TEST_TOKEN_NAME = "Artemis Test Token"; + private static final Logger log = LoggerFactory.getLogger(GitLabCIService.class); private static final String VARIABLE_BUILD_DOCKER_IMAGE_NAME = "ARTEMIS_BUILD_DOCKER_IMAGE"; @@ -91,6 +101,8 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private final ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + private final ProgrammingExerciseRepository programmingExerciseRepository; + @Value("${artemis.version-control.url}") private URL gitlabServerUrl; @@ -110,29 +122,30 @@ public class GitLabCIService extends AbstractContinuousIntegrationService { private String gitlabToken; public GitLabCIService(GitLabApi gitlab, UriService uriService, BuildPlanRepository buildPlanRepository, GitLabCIBuildPlanService buildPlanService, - ProgrammingLanguageConfiguration programmingLanguageConfiguration, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository) { + ProgrammingLanguageConfiguration programmingLanguageConfiguration, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, + ProgrammingExerciseRepository programmingExerciseRepository) { this.gitlab = gitlab; this.uriService = uriService; this.buildPlanRepository = buildPlanRepository; this.buildPlanService = buildPlanService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; + this.programmingExerciseRepository = programmingExerciseRepository; } @Override public void createBuildPlanForExercise(ProgrammingExercise exercise, String planKey, VcsRepositoryUri repositoryUri, VcsRepositoryUri testRepositoryUri, VcsRepositoryUri solutionRepositoryUri) { - addBuildPlanToProgrammingExerciseIfUnset(exercise); - setupGitLabCIConfiguration(repositoryUri, exercise, generateBuildPlanId(exercise.getProjectKey(), planKey)); - // TODO: triggerBuild(repositoryUri, exercise.getBranch()); + addBuildPlanToProgrammingExercise(exercise, false); + // This method is called twice when creating an exercise. Once for the template repository and once for the solution repository. + // The second time, we don't want to overwrite the configuration. + setupGitLabCIConfigurationForGroup(exercise, false); + setupGitLabCIConfigurationForRepository(repositoryUri, exercise, generateBuildPlanId(exercise.getProjectKey(), planKey)); } - private void setupGitLabCIConfiguration(VcsRepositoryUri repositoryUri, ProgrammingExercise exercise, String buildPlanId) { + private void setupGitLabCIConfigurationForRepository(VcsRepositoryUri repositoryUri, ProgrammingExercise exercise, String buildPlanId) { final String repositoryPath = uriService.getRepositoryPathFromRepositoryUri(repositoryUri); - ProjectApi projectApi = gitlab.getProjectApi(); - - programmingExerciseBuildConfigRepository.loadAndSetBuildConfig(exercise); - + final ProjectApi projectApi = gitlab.getProjectApi(); try { Project project = projectApi.getProject(repositoryPath); @@ -144,40 +157,99 @@ private void setupGitLabCIConfiguration(VcsRepositoryUri repositoryUri, Programm project.setCiConfigPath(buildPlanUrl); projectApi.updateProject(project); + + setRepositoryVariableIfUnset(repositoryPath, VARIABLE_BUILD_PLAN_ID_NAME, buildPlanId); } catch (GitLabApiException e) { throw new GitLabCIException("Error enabling CI for " + repositoryUri, e); } + } + + private void setupGitLabCIConfigurationForGroup(ProgrammingExercise exercise, boolean overwrite) { + programmingExerciseBuildConfigRepository.loadAndSetBuildConfig(exercise); + + final String projectKey = exercise.getProjectKey(); + final ProgrammingExerciseBuildConfig buildConfig = exercise.getBuildConfig(); + + updateGroupVariable(projectKey, VARIABLE_BUILD_DOCKER_IMAGE_NAME, + programmingLanguageConfiguration.getImage(exercise.getProgrammingLanguage(), Optional.ofNullable(exercise.getProjectType())), overwrite); + updateGroupVariable(projectKey, VARIABLE_BUILD_LOGS_FILE_NAME, "build.log", overwrite); + // TODO: Implement the custom feedback feature + updateGroupVariable(projectKey, VARIABLE_CUSTOM_FEEDBACK_DIR_NAME, "TODO", overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_PLUGIN_DOCKER_IMAGE_NAME, notificationPluginDockerImage, overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_SECRET_NAME, artemisAuthenticationTokenValue, overwrite); + updateGroupVariable(projectKey, VARIABLE_NOTIFICATION_URL_NAME, artemisServerUrl.toExternalForm() + NEW_RESULT_RESOURCE_API_PATH, overwrite); + updateGroupVariable(projectKey, VARIABLE_SUBMISSION_GIT_BRANCH_NAME, buildConfig.getBranch(), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_BRANCH_NAME, buildConfig.getBranch(), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_REPOSITORY_SLUG_NAME, uriService.getRepositorySlugFromRepositoryUriString(exercise.getTestRepositoryUri()), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_TOKEN, () -> generateGitLabTestToken(exercise), overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_GIT_USER, gitlabUser, overwrite); + updateGroupVariable(projectKey, VARIABLE_TEST_RESULTS_DIR_NAME, "target/surefire-reports", overwrite); + } + + private void updateGroupVariable(String projectKey, String key, String value, boolean overwrite) { + updateGroupVariable(projectKey, key, () -> value, overwrite); + } + + private void updateGroupVariable(String projectKey, String key, Supplier value, boolean overwrite) { + final GroupApi groupApi = gitlab.getGroupApi(); + if (groupApi.getOptionalVariable(projectKey, key).isEmpty()) { + try { + String valueString = value.get(); + groupApi.createVariable(projectKey, key, valueString, false, canBeMasked(valueString)); + } + catch (GitLabApiException e) { + log.error("Error creating variable '{}' for group {}", key, projectKey, e); + throw new GitLabCIException("Error creating variable '" + key + "' for group " + projectKey, e); + } + } + else if (overwrite) { + try { + String valueString = value.get(); + groupApi.updateVariable(projectKey, key, valueString, false, canBeMasked(valueString)); + } + catch (GitLabApiException e) { + log.error("Error updating variable '{}' for group {}", key, projectKey, e); + throw new GitLabCIException("Error updating variable '" + key + "' for group " + projectKey, e); + } + } + } + + private String generateGitLabTestToken(ProgrammingExercise programmingExercise) { + String testRepositoryPath = uriService.getRepositoryPathFromRepositoryUri(programmingExercise.getVcsTestRepositoryUri()); + ZonedDateTime courseEndDate = programmingExercise.getCourseViaExerciseGroupOrCourseMember().getEndDate(); + + Date expiryDate; + if (courseEndDate != null && courseEndDate.isAfter(ZonedDateTime.now())) { + expiryDate = Date.from(courseEndDate.toInstant()); + } + else { + expiryDate = Date.from(ZonedDateTime.now().plusMonths(6).toInstant()); + } + ProjectAccessToken projectAccessToken; try { - // TODO: Reduce the number of API calls - ProgrammingExerciseBuildConfig buildConfig = exercise.getBuildConfig(); - updateVariable(repositoryPath, VARIABLE_BUILD_DOCKER_IMAGE_NAME, - programmingLanguageConfiguration.getImage(exercise.getProgrammingLanguage(), Optional.ofNullable(exercise.getProjectType()))); - updateVariable(repositoryPath, VARIABLE_BUILD_LOGS_FILE_NAME, "build.log"); - updateVariable(repositoryPath, VARIABLE_BUILD_PLAN_ID_NAME, buildPlanId); - // TODO: Implement the custom feedback feature - updateVariable(repositoryPath, VARIABLE_CUSTOM_FEEDBACK_DIR_NAME, "TODO"); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_PLUGIN_DOCKER_IMAGE_NAME, notificationPluginDockerImage); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_SECRET_NAME, artemisAuthenticationTokenValue); - updateVariable(repositoryPath, VARIABLE_NOTIFICATION_URL_NAME, artemisServerUrl.toExternalForm() + NEW_RESULT_RESOURCE_API_PATH); - updateVariable(repositoryPath, VARIABLE_SUBMISSION_GIT_BRANCH_NAME, buildConfig.getBranch()); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_BRANCH_NAME, buildConfig.getBranch()); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_REPOSITORY_SLUG_NAME, uriService.getRepositorySlugFromRepositoryUriString(exercise.getTestRepositoryUri())); - // TODO: Use a token that is only valid for the test repository for each programming exercise - updateVariable(repositoryPath, VARIABLE_TEST_GIT_TOKEN, gitlabToken); - updateVariable(repositoryPath, VARIABLE_TEST_GIT_USER, gitlabUser); - updateVariable(repositoryPath, VARIABLE_TEST_RESULTS_DIR_NAME, "target/surefire-reports"); + projectAccessToken = gitlab.getProjectApi().createProjectAccessToken(testRepositoryPath, GITLAB_TEST_TOKEN_NAME, + List.of(Constants.ProjectAccessTokenScope.READ_REPOSITORY), expiryDate, Long.valueOf(AccessLevel.REPORTER.value)); } catch (GitLabApiException e) { - log.error("Error creating variable for {} The variables may already have been created.", repositoryUri, e); + log.error("Error creating project access token for test repository {}", testRepositoryPath, e); + throw new GitLabCIException("Error creating project access token for test repository " + testRepositoryPath, e); } + return projectAccessToken.getToken(); } - private void updateVariable(String repositoryPath, String key, String value) throws GitLabApiException { - // TODO: We can even define the variables on group level - // TODO: If the variable already exists, we should update it - gitlab.getProjectApi().createVariable(repositoryPath, key, value, Variable.Type.ENV_VAR, false, canBeMasked(value)); + private void setRepositoryVariableIfUnset(String repositoryPath, String key, String value) { + final ProjectApi projectApi = gitlab.getProjectApi(); + if (projectApi.getOptionalVariable(repositoryPath, key).isEmpty()) { + try { + projectApi.createVariable(repositoryPath, key, value, Variable.Type.ENV_VAR, false, canBeMasked(value)); + } + catch (GitLabApiException e) { + log.error("Error creating variable '{}' for repository {}", key, repositoryPath, e); + throw new GitLabCIException("Error creating variable '" + key + "' for repository " + repositoryPath, e); + } + } } private boolean canBeMasked(String value) { @@ -185,9 +257,9 @@ private boolean canBeMasked(String value) { return value != null && value.matches("^[a-zA-Z0-9+/=@:.~]{8,}$"); } - private void addBuildPlanToProgrammingExerciseIfUnset(ProgrammingExercise programmingExercise) { + private void addBuildPlanToProgrammingExercise(ProgrammingExercise programmingExercise, boolean overwrite) { Optional optionalBuildPlan = buildPlanRepository.findByProgrammingExercises_IdWithProgrammingExercises(programmingExercise.getId()); - if (optionalBuildPlan.isEmpty()) { + if (optionalBuildPlan.isEmpty() || overwrite) { var defaultBuildPlan = buildPlanService.generateDefaultBuildPlan(programmingExercise); buildPlanRepository.setBuildPlanForExercise(defaultBuildPlan, programmingExercise); } @@ -195,15 +267,15 @@ private void addBuildPlanToProgrammingExerciseIfUnset(ProgrammingExercise progra @Override public void recreateBuildPlansForExercise(ProgrammingExercise exercise) { - addBuildPlanToProgrammingExerciseIfUnset(exercise); + addBuildPlanToProgrammingExercise(exercise, true); + // When recreating the build plan for the exercise, we want to overwrite the configuration. + setupGitLabCIConfigurationForGroup(exercise, true); - VcsRepositoryUri templateUrl = exercise.getVcsTemplateRepositoryUri(); - setupGitLabCIConfiguration(templateUrl, exercise, exercise.getTemplateBuildPlanId()); - // TODO: triggerBuild(templateUrl, exercise.getBranch()); + VcsRepositoryUri templateUri = exercise.getVcsTemplateRepositoryUri(); + setupGitLabCIConfigurationForRepository(templateUri, exercise, exercise.getTemplateBuildPlanId()); - VcsRepositoryUri solutionUrl = exercise.getVcsSolutionRepositoryUri(); - setupGitLabCIConfiguration(solutionUrl, exercise, exercise.getSolutionBuildPlanId()); - // TODO: triggerBuild(solutionUrl, exercise.getBranch()); + VcsRepositoryUri solutionUri = exercise.getVcsSolutionRepositoryUri(); + setupGitLabCIConfigurationForRepository(solutionUri, exercise, exercise.getSolutionBuildPlanId()); } @Override @@ -223,19 +295,20 @@ private String generateBuildPlanId(String projectKey, String planKey) { @Override public void configureBuildPlan(ProgrammingExerciseParticipation participation, String defaultBranch) { - setupGitLabCIConfiguration(participation.getVcsRepositoryUri(), participation.getProgrammingExercise(), participation.getBuildPlanId()); + ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdWithBuildConfigElseThrow(participation.getProgrammingExercise().getId()); + setupGitLabCIConfigurationForRepository(participation.getVcsRepositoryUri(), programmingExercise, participation.getBuildPlanId()); } @Override public void deleteProject(String projectKey) { - log.error("Unsupported action: GitLabCIService.deleteBuildPlan()"); - log.error("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); + log.debug("Unsupported action: GitLabCIService.deleteBuildPlan()"); + log.debug("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); } @Override public void deleteBuildPlan(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.deleteBuildPlan()"); - log.error("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); + log.debug("Unsupported action: GitLabCIService.deleteBuildPlan()"); + log.debug("Please refer to the repository for deleting the project. The build plan can not be deleted separately."); } @Override @@ -278,46 +351,46 @@ private Optional getLatestPipeline(final ProgrammingExerciseParticipat @Override public boolean checkIfBuildPlanExists(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.checkIfBuildPlanExists()"); + log.debug("Unsupported action: GitLabCIService.checkIfBuildPlanExists()"); return true; } @Override public ResponseEntity retrieveLatestArtifact(ProgrammingExerciseParticipation participation) { - log.error("Unsupported action: GitLabCIService.retrieveLatestArtifact()"); + log.debug("Unsupported action: GitLabCIService.retrieveLatestArtifact()"); return null; } @Override public String checkIfProjectExists(String projectKey, String projectName) { - log.error("Unsupported action: GitLabCIService.checkIfProjectExists()"); + log.debug("Unsupported action: GitLabCIService.checkIfProjectExists()"); return null; } @Override public void enablePlan(String projectKey, String planKey) { - log.error("Unsupported action: GitLabCIService.enablePlan()"); + log.debug("Unsupported action: GitLabCIService.enablePlan()"); } @Override public void updatePlanRepository(String buildProjectKey, String buildPlanKey, String ciRepoName, String repoProjectKey, String newRepoUri, String existingRepoUri, String newDefaultBranch) { - log.error("Unsupported action: GitLabCIService.updatePlanRepository()"); + log.debug("Unsupported action: GitLabCIService.updatePlanRepository()"); } @Override public void giveProjectPermissions(String projectKey, List groups, List permissions) { - log.error("Unsupported action: GitLabCIService.giveProjectPermissions()"); + log.debug("Unsupported action: GitLabCIService.giveProjectPermissions()"); } @Override public void givePlanPermissions(ProgrammingExercise programmingExercise, String planName) { - log.error("Unsupported action: GitLabCIService.givePlanPermissions()"); + log.debug("Unsupported action: GitLabCIService.givePlanPermissions()"); } @Override public void removeAllDefaultProjectPermissions(String projectKey) { - log.error("Unsupported action: GitLabCIService.removeAllDefaultProjectPermissions()"); + log.debug("Unsupported action: GitLabCIService.removeAllDefaultProjectPermissions()"); } @Override @@ -327,12 +400,12 @@ public ConnectorHealth health() { @Override public void createProjectForExercise(ProgrammingExercise programmingExercise) throws ContinuousIntegrationException { - log.error("Unsupported action: GitLabCIService.createProjectForExercise()"); + log.debug("Unsupported action: GitLabCIService.createProjectForExercise()"); } @Override public Optional getWebHookUrl(String projectKey, String buildPlanId) { - log.error("Unsupported action: GitLabCIService.getWebHookUrl()"); + log.debug("Unsupported action: GitLabCIService.getWebHookUrl()"); return Optional.empty(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java index 19f37bed1334..10bec4e5981f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIUserManagementService.java @@ -63,6 +63,6 @@ public void updateCoursePermissions(Course updatedCourse, String oldInstructorGr } private void logUnsupportedAction() { - log.error("Please refer to the repository for user management."); + log.debug("Please refer to the repository for user management."); } } 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 24ed52858fe1..1684cf52c018 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 @@ -189,8 +189,8 @@ public Set getTasksWithoutInactiveTestCases(long exerci * @param exerciseId of the programming exercise * @return Set of all tasks including one for not manually assigned tests */ - public Set getTasksWithUnassignedTestCases(long exerciseId) { - Set tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); + public List getTasksWithUnassignedTestCases(long exerciseId) { + List tasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCaseAndSolutionEntriesElseThrow(exerciseId); Set testsWithTasks = tasks.stream().flatMap(task -> task.getTestCases().stream()).collect(Collectors.toSet()); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index bfd04f5ba49d..5a805ff54d03 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -84,7 +84,7 @@ private void sendBuildAgentSummaryOverWebsocket() { } private void sendBuildAgentDetailsOverWebsocket(String agentName) { - sharedQueueManagementService.getBuildAgentInformation().stream().filter(agent -> agent.name().equals(agentName)).findFirst() + sharedQueueManagementService.getBuildAgentInformation().stream().filter(agent -> agent.buildAgent().name().equals(agentName)).findFirst() .ifPresent(localCIWebsocketMessagingService::sendBuildAgentDetails); } @@ -127,19 +127,19 @@ private class BuildAgentListener @Override public void entryAdded(com.hazelcast.core.EntryEvent event) { log.debug("Build agent added: {}", event.getValue()); - sendBuildAgentInformationOverWebsocket(event.getValue().name()); + sendBuildAgentInformationOverWebsocket(event.getValue().buildAgent().name()); } @Override public void entryRemoved(com.hazelcast.core.EntryEvent event) { log.debug("Build agent removed: {}", event.getOldValue()); - sendBuildAgentInformationOverWebsocket(event.getOldValue().name()); + sendBuildAgentInformationOverWebsocket(event.getOldValue().buildAgent().name()); } @Override public void entryUpdated(com.hazelcast.core.EntryEvent event) { log.debug("Build agent updated: {}", event.getValue()); - sendBuildAgentInformationOverWebsocket(event.getValue().name()); + sendBuildAgentInformationOverWebsocket(event.getValue().buildAgent().name()); } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java index a450fc545886..71edf64a3fa8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java @@ -225,8 +225,8 @@ public void processResult() { */ private void addResultToBuildAgentsRecentBuildJobs(BuildJobQueueItem buildJob, Result result) { try { - buildAgentInformation.lock(buildJob.buildAgentAddress()); - BuildAgentInformation buildAgent = buildAgentInformation.get(buildJob.buildAgentAddress()); + buildAgentInformation.lock(buildJob.buildAgent().memberAddress()); + BuildAgentInformation buildAgent = buildAgentInformation.get(buildJob.buildAgent().memberAddress()); if (buildAgent != null) { List recentBuildJobs = buildAgent.recentBuildJobs(); for (int i = 0; i < recentBuildJobs.size(); i++) { @@ -235,11 +235,11 @@ private void addResultToBuildAgentsRecentBuildJobs(BuildJobQueueItem buildJob, R break; } } - buildAgentInformation.put(buildJob.buildAgentAddress(), new BuildAgentInformation(buildAgent, recentBuildJobs)); + buildAgentInformation.put(buildJob.buildAgent().memberAddress(), new BuildAgentInformation(buildAgent, recentBuildJobs)); } } finally { - buildAgentInformation.unlock(buildJob.buildAgentAddress()); + buildAgentInformation.unlock(buildJob.buildAgent().memberAddress()); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java index 5da2860ba799..cb4e894c90f7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java @@ -22,6 +22,7 @@ import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentDTO; import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.JobTimingInfo; @@ -196,8 +197,10 @@ else if (triggeredByPushTo.equals(RepositoryType.TESTS)) { BuildConfig buildConfig = getBuildConfig(participation, commitHashToBuild, assignmentCommitHash, testCommitHash, programmingExerciseBuildConfig); - BuildJobQueueItem buildJobQueueItem = new BuildJobQueueItem(buildJobId, participation.getBuildPlanId(), null, participation.getId(), courseId, programmingExercise.getId(), - 0, priority, null, repositoryInfo, jobTimingInfo, buildConfig, null); + BuildAgentDTO buildAgent = new BuildAgentDTO(null, null, null); + + BuildJobQueueItem buildJobQueueItem = new BuildJobQueueItem(buildJobId, participation.getBuildPlanId(), buildAgent, participation.getId(), courseId, + programmingExercise.getId(), 0, priority, null, repositoryInfo, jobTimingInfo, buildConfig, null); queue.add(buildJobQueueItem); log.info("Added build job {} to the queue", buildJobId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java index 7c527a155b49..e27ec440d5aa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIWebsocketMessagingService.java @@ -100,7 +100,7 @@ public void sendBuildAgentSummary(List buildAgentInfo) { } public void sendBuildAgentDetails(BuildAgentInformation buildAgentDetails) { - String channel = "/topic/admin/build-agent/" + buildAgentDetails.name(); + String channel = "/topic/admin/build-agent/" + buildAgentDetails.buildAgent().name(); log.debug("Sending message on topic {}: {}", channel, buildAgentDetails); websocketMessagingService.sendMessage(channel, buildAgentDetails); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index 9af5fe3b45c1..87b44d4872ba 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -137,7 +137,7 @@ public List getBuildAgentInformation() { } public List getBuildAgentInformationWithoutRecentBuildJobs() { - return buildAgentInformation.values().stream().map(agent -> new BuildAgentInformation(agent.name(), agent.maxNumberOfConcurrentBuildJobs(), + return buildAgentInformation.values().stream().map(agent -> new BuildAgentInformation(agent.buildAgent(), agent.maxNumberOfConcurrentBuildJobs(), agent.numberOfCurrentBuildJobs(), agent.runningBuildJobs(), agent.status(), null, null)).toList(); } @@ -208,7 +208,7 @@ public void cancelAllRunningBuildJobs() { * @param agentName name of the agent */ public void cancelAllRunningBuildJobsForAgent(String agentName) { - processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgentAddress(), agentName)).forEach(job -> cancelBuildJob(job.id())); + processingJobs.values().stream().filter(job -> Objects.equals(job.buildAgent().name(), agentName)).forEach(job -> cancelBuildJob(job.id())); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java index f4bcf1ea2e89..f5fa8b2c9243 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java @@ -436,7 +436,11 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, localVCRepositoryUri.toString()); + participation = programmingExerciseParticipationService.retrieveParticipationForRepository(exercise, repositoryTypeOrUserName, + localVCRepositoryUri.isPracticeRepository(), true); + + // TODO Add this back in when we have figured out what is incorrect in the playwright configuration for (MySQL, Local) + // participation = programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, localVCRepositoryUri.toString()); } catch (EntityNotFoundException e) { throw new LocalVCInternalException( diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 957f7435ce0d..9ef7a05508e9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -298,8 +298,7 @@ public ResponseEntity updateProgrammingExercise(@RequestBod checkProgrammingExerciseForError(updatedProgrammingExercise); - var programmingExerciseBeforeUpdate = programmingExerciseRepository - .findByIdWithAuxiliaryRepositoriesCompetenciesAndBuildConfigElseThrow(updatedProgrammingExercise.getId()); + var programmingExerciseBeforeUpdate = programmingExerciseRepository.findForUpdateByIdElseThrow(updatedProgrammingExercise.getId()); if (!Objects.equals(programmingExerciseBeforeUpdate.getShortName(), updatedProgrammingExercise.getShortName())) { throw new BadRequestAlertException("The programming exercise short name cannot be changed", ENTITY_NAME, "shortNameCannotChange"); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java index 8618c9be7c1c..379ebfacb035 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/ProgrammingExerciseTaskResource.java @@ -2,6 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import java.util.List; import java.util.Set; import org.slf4j.Logger; @@ -74,13 +75,13 @@ public ResponseEntity> getTasks(@PathVariable Long */ @GetMapping("programming-exercises/{exerciseId}/tasks-with-unassigned-test-cases") @EnforceAtLeastTutor - public ResponseEntity> getTasksWithUnassignedTask(@PathVariable Long exerciseId) { + public ResponseEntity> getTasksWithUnassignedTask(@PathVariable Long exerciseId) { log.debug("REST request to retrieve ProgrammingExerciseTasks for ProgrammingExercise with id : {}", exerciseId); // Reload the exercise from the database as we can't trust data from the client ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, exercise, null); - Set tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + List tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); return ResponseEntity.ok(tasks); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java index 6f31cc005d1d..3d695cd8af56 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java @@ -62,14 +62,14 @@ public interface QuizExerciseRepository extends ArtemisJpaRepository findWithEagerQuestionsAndStatisticsById(Long quizExerciseId); - @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "quizPointStatistic", "quizQuestions.quizQuestionStatistic", "categories", "competencies", "quizBatches", - "gradingCriteria" }) + @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "quizPointStatistic", "quizQuestions.quizQuestionStatistic", "categories", "competencyLinks.competency", + "quizBatches", "gradingCriteria" }) Optional findWithEagerQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaById(Long quizExerciseId); @EntityGraph(type = LOAD, attributePaths = { "quizQuestions" }) Optional findWithEagerQuestionsById(Long quizExerciseId); - @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "competencies" }) + @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "competencyLinks.competency" }) Optional findWithEagerQuestionsAndCompetenciesById(Long quizExerciseId); @EntityGraph(type = LOAD, attributePaths = { "quizBatches" }) @@ -78,7 +78,8 @@ public interface QuizExerciseRepository extends ArtemisJpaRepository findWithEagerSubmittedAnswersByParticipationId(long participationId); + List findWithEagerSubmittedAnswersByParticipationId(long participationId); + + @Query(""" + SELECT submission + FROM QuizSubmission submission + LEFT JOIN FETCH submission.submittedAnswers + JOIN submission.results r + WHERE r.id = :resultId + """) + Optional findWithEagerSubmittedAnswersByResultId(@Param("resultId") long resultId); /** * Retrieve QuizSubmission for given quiz batch and studentLogin 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 8071c2c1eeb8..274718a9b382 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,6 +33,7 @@ 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; @@ -44,6 +45,7 @@ import de.tum.cit.aet.artemis.core.service.messaging.InstanceMessageSendService; import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; +import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.exercise.service.ExerciseSpecificationService; import de.tum.cit.aet.artemis.quiz.domain.DragAndDropQuestion; import de.tum.cit.aet.artemis.quiz.domain.DragItem; @@ -83,10 +85,14 @@ public class QuizExerciseService extends QuizService { private final FileService fileService; + 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) { + ShortAnswerMappingRepository shortAnswerMappingRepository, ExerciseService exerciseService, CompetencyExerciseLinkRepository competencyExerciseLinkRepository) { super(dragAndDropMappingRepository, shortAnswerMappingRepository); this.quizExerciseRepository = quizExerciseRepository; this.resultRepository = resultRepository; @@ -96,6 +102,8 @@ public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, Result this.quizBatchService = quizBatchService; this.exerciseSpecificationService = exerciseSpecificationService; this.fileService = fileService; + this.exerciseService = exerciseService; + this.competencyExerciseLinkRepository = competencyExerciseLinkRepository; } /** @@ -429,7 +437,7 @@ public QuizExercise save(QuizExercise quizExercise) { // make sure the pointers in the statistics are correct quizExercise.recalculatePointCounters(); - QuizExercise savedQuizExercise = super.save(quizExercise); + QuizExercise savedQuizExercise = exerciseService.saveWithCompetencyLinks(quizExercise, super::save); if (savedQuizExercise.isCourseExercise()) { // only schedule quizzes for course exercises, not for exam exercises diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java index 3892a0e8e44e..481648fb9636 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java @@ -147,7 +147,7 @@ public void calculateAllResults(long quizExerciseId) { log.info("Calculating results for quiz {}", quizExercise.getId()); studentParticipationRepository.findByExerciseId(quizExercise.getId()).forEach(participation -> { participation.setExercise(quizExercise); - Optional quizSubmissionOptional = quizSubmissionRepository.findWithEagerSubmittedAnswersByParticipationId(participation.getId()); + Optional quizSubmissionOptional = quizSubmissionRepository.findWithEagerSubmittedAnswersByParticipationId(participation.getId()).stream().findFirst(); if (quizSubmissionOptional.isEmpty()) { return; diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java index 118c5554a1f9..d9d276bb3bd4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java @@ -300,6 +300,8 @@ public ResponseEntity updateQuizExercise(@PathVariable Long exerci Channel updatedChannel = channelService.updateExerciseChannel(originalQuiz, quizExercise); + exerciseService.reconnectCompetencyExerciseLinks(quizExercise); + quizExercise = quizExerciseService.save(quizExercise); exerciseService.logUpdate(quizExercise, quizExercise.getCourseViaExerciseGroupOrCourseMember(), user); groupNotificationScheduleService.checkAndCreateAppropriateNotificationsWhenUpdatingExercise(originalQuiz, quizExercise, notificationText); 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 41c6fc8173c9..fc2b4d3b3c94 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 @@ -69,6 +69,8 @@ public QuizParticipationResource(QuizExerciseRepository quizExerciseRepository, /** * POST /quiz-exercises/{exerciseId}/start-participation : start the quiz exercise participation + * TODO: This endpoint is also called when viewing the result of a quiz exercise. + * TODO: This does not make any sense, as the participation is already started. * * @param exerciseId the id of the quiz exercise * @return The created participation @@ -92,7 +94,14 @@ public ResponseEntity startParticipation(@PathVariable Long // NOTE: starting exercise prevents that two participation will exist, but ensures that a submission is created var result = resultRepository.findFirstByParticipationIdAndRatedOrderByCompletionDateDesc(participation.getId(), true).orElse(new Result()); - result.setSubmission(quizSubmissionRepository.findWithEagerSubmittedAnswersByParticipationId(participation.getId()).orElseThrow()); + if (result.getId() == null) { + // Load the live submission of the participation + result.setSubmission(quizSubmissionRepository.findWithEagerSubmittedAnswersByParticipationId(participation.getId()).stream().findFirst().orElseThrow()); + } + else { + // Load the actual submission of the result + result.setSubmission(quizSubmissionRepository.findWithEagerSubmittedAnswersByResultId(result.getId()).orElseThrow()); + } participation.setResults(Set.of(result)); participation.setExercise(exercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java index 3171fa825b63..004af3facb04 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java @@ -35,13 +35,13 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findByCourseIdWithCategories(@Param("courseId") long courseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies" }) - Optional findWithEagerCompetenciesById(long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "competencyLinks.competency", "categories" }) + Optional findWithEagerCompetenciesAndCategoriesById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencies" }) + @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencyLinks.competency" }) Optional findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencies", "plagiarismDetectionConfig" }) + @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencyLinks.competency", "plagiarismDetectionConfig" }) Optional findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesAndPlagiarismDetectionConfigById(long exerciseId); @Query(""" @@ -82,7 +82,8 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findAllWithCategoriesByCourseId(Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java index 14a2558f6c9d..d95a76755e6c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java @@ -3,8 +3,11 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; @@ -21,11 +24,10 @@ import de.tum.cit.aet.artemis.assessment.web.ResultWebsocketService; import de.tum.cit.aet.artemis.athena.service.AthenaFeedbackSuggestionsService; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; -import de.tum.cit.aet.artemis.core.exception.InternalServerErrorException; -import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.service.ParticipationService; import de.tum.cit.aet.artemis.exercise.service.SubmissionService; +import de.tum.cit.aet.artemis.text.domain.TextBlock; import de.tum.cit.aet.artemis.text.domain.TextExercise; import de.tum.cit.aet.artemis.text.domain.TextSubmission; @@ -47,14 +49,18 @@ public class TextExerciseFeedbackService { private final ResultRepository resultRepository; + private final TextBlockService textBlockService; + public TextExerciseFeedbackService(Optional athenaFeedbackSuggestionsService, SubmissionService submissionService, - ResultService resultService, ResultRepository resultRepository, ResultWebsocketService resultWebsocketService, ParticipationService participationService) { + ResultService resultService, ResultRepository resultRepository, ResultWebsocketService resultWebsocketService, ParticipationService participationService, + TextBlockService textBlockService) { this.athenaFeedbackSuggestionsService = athenaFeedbackSuggestionsService; this.submissionService = submissionService; this.resultService = resultService; this.resultRepository = resultRepository; this.resultWebsocketService = resultWebsocketService; this.participationService = participationService; + this.textBlockService = textBlockService; } private void checkRateLimitOrThrow(StudentParticipation participation) { @@ -64,7 +70,7 @@ private void checkRateLimitOrThrow(StudentParticipation participation) { long countOfAthenaResults = athenaResults.size(); if (countOfAthenaResults >= 10) { - throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met"); + throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "maxAthenaResultsReached", true); } } @@ -72,12 +78,11 @@ private void checkRateLimitOrThrow(StudentParticipation participation) { * Handles the request for generating feedback for a text exercise. * Unlike programming exercises a tutor is not notified if Athena is not available. * - * @param exerciseId the id of the text exercise. * @param participation the student participation associated with the exercise. * @param textExercise the text exercise object. * @return StudentParticipation updated text exercise for an AI assessment */ - public StudentParticipation handleNonGradedFeedbackRequest(Long exerciseId, StudentParticipation participation, TextExercise textExercise) { + public StudentParticipation handleNonGradedFeedbackRequest(StudentParticipation participation, TextExercise textExercise) { if (this.athenaFeedbackSuggestionsService.isPresent()) { this.checkRateLimitOrThrow(participation); CompletableFuture.runAsync(() -> this.generateAutomaticNonGradedFeedback(participation, textExercise)); @@ -101,50 +106,81 @@ public void generateAutomaticNonGradedFeedback(StudentParticipation participatio if (submissionOptional.isEmpty()) { throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission"); } - var submission = submissionOptional.get(); + TextSubmission textSubmission = (TextSubmission) submissionOptional.get(); Result automaticResult = new Result(); automaticResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); automaticResult.setRated(true); automaticResult.setScore(0.0); automaticResult.setSuccessful(null); - automaticResult.setSubmission(submission); + automaticResult.setSubmission(textSubmission); automaticResult.setParticipation(participation); try { - this.resultWebsocketService.broadcastNewResult((Participation) participation, automaticResult); + // This broadcast signals the client that feedback is being generated, does not save empty result + this.resultWebsocketService.broadcastNewResult(participation, automaticResult); + + log.debug("Submission id: {}", textSubmission.getId()); - log.debug("Submission id: {}", submission.getId()); + var athenaResponse = this.athenaFeedbackSuggestionsService.orElseThrow().getTextFeedbackSuggestions(textExercise, textSubmission, true); - var athenaResponse = this.athenaFeedbackSuggestionsService.orElseThrow().getTextFeedbackSuggestions(textExercise, (TextSubmission) submission, false); + Set textBlocks = new HashSet<>(); + List feedbacks = new ArrayList<>(); - List feedbacks = athenaResponse.stream().filter(individualFeedbackItem -> individualFeedbackItem.description() != null).map(individualFeedbackItem -> { + athenaResponse.stream().filter(individualFeedbackItem -> individualFeedbackItem.description() != null).forEach(individualFeedbackItem -> { + var textBlock = new TextBlock(); var feedback = new Feedback(); + feedback.setText(individualFeedbackItem.title()); feedback.setDetailText(individualFeedbackItem.description()); feedback.setHasLongFeedbackText(false); feedback.setType(FeedbackType.AUTOMATIC); feedback.setCredits(individualFeedbackItem.credits()); - return feedback; - }).toList(); + + if (textSubmission.getText() != null && individualFeedbackItem.indexStart() != null && individualFeedbackItem.indexEnd() != null) { + textBlock.setStartIndex(individualFeedbackItem.indexStart()); + textBlock.setEndIndex(individualFeedbackItem.indexEnd()); + textBlock.setSubmission(textSubmission); + textBlock.setTextFromSubmission(); + textBlock.automatic(); + textBlock.computeId(); + feedback.setReference(textBlock.getId()); + textBlock.setFeedback(feedback); + log.debug(textBlock.toString()); + + textBlocks.add(textBlock); + } + feedbacks.add(feedback); + }); double totalFeedbacksScore = 0.0; for (Feedback feedback : feedbacks) { totalFeedbacksScore += feedback.getCredits(); } totalFeedbacksScore = totalFeedbacksScore / textExercise.getMaxPoints() * 100; - automaticResult.setSuccessful(true); automaticResult.setCompletionDate(ZonedDateTime.now()); - automaticResult.setScore(Math.clamp(totalFeedbacksScore, 0, 100)); + // For Athena automatic results successful = true will mean that the generation was successful + // undefined in progress and false it failed + automaticResult.setSuccessful(true); + automaticResult = this.resultRepository.save(automaticResult); resultService.storeFeedbackInResult(automaticResult, feedbacks, true); - submissionService.saveNewResult(submission, automaticResult); - this.resultWebsocketService.broadcastNewResult((Participation) participation, automaticResult); + textBlockService.saveAll(textBlocks); + textSubmission.setBlocks(textBlocks); + submissionService.saveNewResult(textSubmission, automaticResult); + // This broadcast signals the client that feedback generation succeeded, result is saved in this case only + this.resultWebsocketService.broadcastNewResult(participation, automaticResult); } catch (Exception e) { log.error("Could not generate feedback", e); - throw new InternalServerErrorException("Something went wrong... AI Feedback could not be generated"); + // Broadcast the failed result but don't save, note that successful = false is normally used to indicate a score < 100 + // but since we do not differentiate for athena feedback we use it to indicate a failed generation + automaticResult.setSuccessful(false); + automaticResult.setCompletionDate(null); + participation.addResult(automaticResult); // for proper change detection + // This broadcast signals the client that feedback generation failed, does not save empty result + this.resultWebsocketService.broadcastNewResult(participation, automaticResult); } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java index 249d395c295b..161c6426589b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseImportService.java @@ -33,6 +33,7 @@ import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.exercise.repository.SubmissionRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseImportService; +import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.text.domain.TextBlock; import de.tum.cit.aet.artemis.text.domain.TextBlockType; import de.tum.cit.aet.artemis.text.domain.TextExercise; @@ -58,10 +59,12 @@ public class TextExerciseImportService extends ExerciseImportService { private final CompetencyProgressService competencyProgressService; + private final ExerciseService exerciseService; + public TextExerciseImportService(TextExerciseRepository textExerciseRepository, ExampleSubmissionRepository exampleSubmissionRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, TextBlockRepository textBlockRepository, FeedbackRepository feedbackRepository, - TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, - CompetencyProgressService competencyProgressService) { + TextSubmissionRepository textSubmissionRepository, ChannelService channelService, FeedbackService feedbackService, CompetencyProgressService competencyProgressService, + ExerciseService exerciseService) { super(exampleSubmissionRepository, submissionRepository, resultRepository, feedbackService); this.textBlockRepository = textBlockRepository; this.textExerciseRepository = textExerciseRepository; @@ -69,6 +72,7 @@ public TextExerciseImportService(TextExerciseRepository textExerciseRepository, this.textSubmissionRepository = textSubmissionRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; + this.exerciseService = exerciseService; } /** @@ -91,7 +95,7 @@ public TextExercise importTextExercise(final TextExercise templateExercise, Text newExercise.setFeedbackSuggestionModule(null); } - TextExercise newTextExercise = textExerciseRepository.save(newExercise); + TextExercise newTextExercise = exerciseService.saveWithCompetencyLinks(newExercise, textExerciseRepository::save); channelService.createExerciseChannel(newTextExercise, Optional.ofNullable(importedExercise.getChannelName())); newExercise.setExampleSubmissions(copyExampleSubmission(templateExercise, newExercise, gradingInstructionCopyTracker)); diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java index 8f98cd911c51..0040e183e1e8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java @@ -11,6 +11,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,6 +78,7 @@ import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService; import de.tum.cit.aet.artemis.exercise.service.ExerciseDeletionService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; +import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.plagiarism.domain.text.TextPlagiarismResult; import de.tum.cit.aet.artemis.plagiarism.dto.PlagiarismResultDTO; import de.tum.cit.aet.artemis.plagiarism.repository.PlagiarismResultRepository; @@ -154,6 +156,8 @@ public class TextExerciseResource { private final CompetencyProgressService competencyProgressService; + private final Optional irisSettingsService; + public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextExerciseService textExerciseService, FeedbackRepository feedbackRepository, ExerciseDeletionService exerciseDeletionService, PlagiarismResultRepository plagiarismResultRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, CourseService courseService, StudentParticipationRepository studentParticipationRepository, @@ -162,7 +166,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, InstanceMessageSendService instanceMessageSendService, PlagiarismDetectionService plagiarismDetectionService, CourseRepository courseRepository, ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressService competencyProgressService, Optional irisSettingsService) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; this.plagiarismResultRepository = plagiarismResultRepository; @@ -188,6 +192,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.channelRepository = channelRepository; this.athenaModuleService = athenaModuleService; this.competencyProgressService = competencyProgressService; + this.irisSettingsService = irisSettingsService; } /** @@ -222,13 +227,15 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise // Check that only allowed athena modules are used athenaModuleService.ifPresentOrElse(ams -> ams.checkHasAccessToAthenaModule(textExercise, course, ENTITY_NAME), () -> textExercise.setFeedbackSuggestionModule(null)); - TextExercise result = textExerciseRepository.save(textExercise); + TextExercise result = exerciseService.saveWithCompetencyLinks(textExercise, textExerciseRepository::save); channelService.createExerciseChannel(result, Optional.ofNullable(textExercise.getChannelName())); instanceMessageSendService.sendTextExerciseSchedule(result.getId()); groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(textExercise); competencyProgressService.updateProgressByLearningObjectAsync(result); + irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(result, new HashSet<>())); + return ResponseEntity.created(new URI("/api/text-exercises/" + result.getId())).body(result); } @@ -259,7 +266,7 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise // Check that the user is authorized to update the exercise var user = userRepository.getUserWithGroupsAndAuthorities(); // Important: use the original exercise for permission check - final TextExercise textExerciseBeforeUpdate = textExerciseRepository.findWithEagerCompetenciesByIdElseThrow(textExercise.getId()); + final TextExercise textExerciseBeforeUpdate = textExerciseRepository.findWithEagerCompetenciesAndCategoriesByIdElseThrow(textExercise.getId()); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, textExerciseBeforeUpdate, user); // Forbid changing the course the exercise belongs to. @@ -278,7 +285,8 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise channelService.updateExerciseChannel(textExerciseBeforeUpdate, textExercise); - TextExercise updatedTextExercise = textExerciseRepository.save(textExercise); + TextExercise updatedTextExercise = exerciseService.saveWithCompetencyLinks(textExercise, textExerciseRepository::save); + exerciseService.logUpdate(updatedTextExercise, updatedTextExercise.getCourseViaExerciseGroupOrCourseMember(), user); exerciseService.updatePointsInRelatedParticipantScores(textExerciseBeforeUpdate, updatedTextExercise); participationRepository.removeIndividualDueDatesIfBeforeDueDate(updatedTextExercise, textExerciseBeforeUpdate.getDueDate()); @@ -288,6 +296,8 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise competencyProgressService.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); + irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(textExercise, textExerciseBeforeUpdate.getCategories())); + return ResponseEntity.ok(updatedTextExercise); } @@ -413,44 +423,49 @@ public ResponseEntity getDataForTextEditor(@PathVariable L participation.setResults(new HashSet<>(results)); } - Optional optionalSubmission = participation.findLatestSubmission(); + if (!ExerciseDateService.isAfterAssessmentDueDate(textExercise)) { + // We want to have the preliminary feedback before the assessment due date too + Set athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA) + .collect(Collectors.toSet()); + participation.setResults(athenaResults); + } + + Set submissions = participation.getSubmissions(); participation.setSubmissions(new HashSet<>()); - if (optionalSubmission.isPresent()) { - TextSubmission textSubmission = (TextSubmission) optionalSubmission.get(); + for (Submission submission : submissions) { + if (submission != null) { + TextSubmission textSubmission = (TextSubmission) submission; - // set reference to participation to null, since we are already inside a participation - textSubmission.setParticipation(null); + // set reference to participation to null, since we are already inside a participation + textSubmission.setParticipation(null); - if (!ExerciseDateService.isAfterAssessmentDueDate(textExercise)) { - // We want to have the preliminary feedback before the assessment due date too - List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); - textSubmission.setResults(athenaResults); - Set athenaResultsSet = new HashSet(athenaResults); - participation.setResults(athenaResultsSet); - } + if (!ExerciseDateService.isAfterAssessmentDueDate(textExercise)) { + // We want to have the preliminary feedback before the assessment due date too + List athenaResults = submission.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); + textSubmission.setResults(athenaResults); + } - Result result = textSubmission.getLatestResult(); - if (result != null) { - // Load TextBlocks for the Submission. They are needed to display the Feedback in the client. - final var textBlocks = textBlockRepository.findAllBySubmissionId(textSubmission.getId()); - textSubmission.setBlocks(textBlocks); + Result result = textSubmission.getLatestResult(); + if (result != null) { + // Load TextBlocks for the Submission. They are needed to display the Feedback in the client. + final var textBlocks = textBlockRepository.findAllBySubmissionId(textSubmission.getId()); + textSubmission.setBlocks(textBlocks); - if (textSubmission.isSubmitted() && result.getCompletionDate() != null) { - List assessments = feedbackRepository.findByResult(result); - result.setFeedbacks(assessments); - } + if (textSubmission.isSubmitted() && result.getCompletionDate() != null) { + List assessments = feedbackRepository.findByResult(result); + result.setFeedbacks(assessments); + } - if (!authCheckService.isAtLeastTeachingAssistantForExercise(textExercise, user)) { - result.filterSensitiveInformation(); - } + if (!authCheckService.isAtLeastTeachingAssistantForExercise(textExercise, user)) { + result.filterSensitiveInformation(); + } - // only send the one latest result to the client - textSubmission.setResults(List.of(result)); - participation.setResults(Set.of(result)); + // only send the one latest result to the client + textSubmission.setResults(List.of(result)); + } + participation.addSubmission(textSubmission); } - - participation.addSubmission(textSubmission); } if (!(authCheckService.isAtLeastInstructorForExercise(textExercise, user) || participation.isOwnedBy(user))) { diff --git a/src/main/resources/config/application-buildagent.yml b/src/main/resources/config/application-buildagent.yml index 1439567b2cc9..fc3e4847f25e 100644 --- a/src/main/resources/config/application-buildagent.yml +++ b/src/main/resources/config/application-buildagent.yml @@ -33,6 +33,7 @@ artemis: container-cleanup: expiry-minutes: 5 cleanup-schedule-minutes: 60 + pause-grace-period-seconds: 60 git: name: Artemis email: artemis@xcit.tum.de diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 85965bb400e0..e51e9f84e749 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -36,8 +36,8 @@ artemis: batch-size: 50 # wait the time below after 50 requests batch-waiting-time: 30000 # in ms = 30s iosAppId: "2J3C6P6X3N.de.tum.cit.artemis" - androidAppPackage: "de.tum.informatics.www1.artemis.native_app.android" - androidSha256CertFingerprints: "1F:EB:DD:BA:A1:72:BF:A8:23:DF:72:A0:96:41:5E:10:75:2D:88:90:00:F3:EE:AC:CF:B7:3C:9C:21:86:EC:CF" + androidAppPackage: "de.tum.cit.aet.artemis" + androidSha256CertFingerprints: "D2:E1:A6:6F:8C:00:55:97:9F:30:2F:3D:79:A9:5D:78:85:1F:C5:21:5A:7F:81:B3:BF:60:22:71:EF:6F:60:24" # activate the following line if you want to support push notifications for the mobile clients. # More information about the TUM hosted hermes service can be found here: https://github.com/ls1intum/Hermes @@ -98,6 +98,15 @@ artemis: typescript: 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. + build-agent: + # Name of the build agent. Only lowercase letters, numbers and hyphens are allowed. ([a-z0-9-]+) + short-name: "artemis-build-agent-1" + display-name: "Artemis Build Agent 1" + + + management: endpoints: web: diff --git a/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml b/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml new file mode 100644 index 000000000000..9bbfc1d0b383 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20241018053210_changelog.xml b/src/main/resources/config/liquibase/changelog/20241018053210_changelog.xml new file mode 100644 index 000000000000..e514ec8e5f58 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241018053210_changelog.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/changelog/20241018120000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241018120000_changelog.xml new file mode 100644 index 000000000000..e144476788c5 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241018120000_changelog.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20241023456789_changelog.xml b/src/main/resources/config/liquibase/changelog/20241023456789_changelog.xml new file mode 100644 index 000000000000..8606c28d3bee --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241023456789_changelog.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 0de803d892e2..d120ce098157 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -29,6 +29,10 @@ + + + + diff --git a/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml index 3a16b66b7143..1c5857d1767a 100644 --- a/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/empty/regularRuns/.gitlab-ci.yml @@ -8,13 +8,14 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none MAVEN_OPTS: -Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat=[yyyy-MM-dd'T'HH:mm:ssX] -Dorg.slf4j.simpleLogger.logFile=${ARTEMIS_BUILD_LOGS_FILE} script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] # TODO: Install dependencies not provided by the Docker image @@ -41,6 +42,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml index 7e5b429dd5f8..e71e7961d5bd 100644 --- a/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/java/maven/regularRuns/.gitlab-ci.yml @@ -8,13 +8,14 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none MAVEN_OPTS: -Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat=[yyyy-MM-dd'T'HH:mm:ssX] -Dorg.slf4j.simpleLogger.logFile=${ARTEMIS_BUILD_LOGS_FILE} script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] - mvn test -B && echo "ARTEMIS_BUILD_STATUS=success" > .env || echo "ARTEMIS_BUILD_STATUS=failed" > .env @@ -38,6 +39,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml index 39e416158a9c..d5f4a007ca2d 100644 --- a/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml +++ b/src/main/resources/templates/gitlabci/rust/regularRuns/.gitlab-ci.yml @@ -9,12 +9,13 @@ test-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH - allow_failure: true + refs: + - triggers variables: GIT_STRATEGY: none script: - git clone --branch ${ARTEMIS_TEST_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${ARTEMIS_TEST_GIT_REPOSITORY_SLUG} . - - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://${ARTEMIS_TEST_GIT_USER}:${ARTEMIS_TEST_GIT_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment + - git clone --branch ${ARTEMIS_SUBMISSION_GIT_BRANCH} ${CI_SERVER_PROTOCOL}://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} assignment - export ARTEMIS_NOTIFICATION_SECRET=[hidden] # Workaround for overwriting the secret - export ARTEMIS_TEST_GIT_TOKEN=[hidden] - cargo nextest run --profile ci | tee -a "${ARTEMIS_BUILD_LOGS_FILE}" && echo "ARTEMIS_BUILD_STATUS=success" > .env || echo "ARTEMIS_BUILD_STATUS=failed" > .env @@ -39,6 +40,8 @@ upload-job: only: variables: - $CI_COMMIT_BRANCH == $ARTEMIS_SUBMISSION_GIT_BRANCH + refs: + - triggers variables: GIT_STRATEGY: none script: diff --git a/src/main/webapp/app/admin/admin.module.ts b/src/main/webapp/app/admin/admin.module.ts index 92118d36ea07..28283dd58512 100644 --- a/src/main/webapp/app/admin/admin.module.ts +++ b/src/main/webapp/app/admin/admin.module.ts @@ -47,6 +47,7 @@ import { KnowledgeAreaTreeComponent } from 'app/shared/standardized-competencies import { StandardizedCompetencyFilterComponent } from 'app/shared/standardized-competencies/standardized-competency-filter.component'; import { StandardizedCompetencyDetailComponent } from 'app/shared/standardized-competencies/standardized-competency-detail.component'; import { DeleteUsersButtonComponent } from 'app/admin/user-management/delete-users-button.component'; +import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; const ENTITY_STATES = [...adminState]; @@ -73,6 +74,7 @@ const ENTITY_STATES = [...adminState]; StandardizedCompetencyFilterComponent, StandardizedCompetencyDetailComponent, DeleteUsersButtonComponent, + ProfilePictureComponent, ], declarations: [ AuditsComponent, diff --git a/src/main/webapp/app/admin/admin.route.ts b/src/main/webapp/app/admin/admin.route.ts index 0c2099494d4a..81c3a096f66f 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 { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; export const adminState: Routes = [ { @@ -116,6 +117,7 @@ export const adminState: Routes = [ data: { pageTitle: 'artemisApp.standardizedCompetency.title', }, + canDeactivate: [PendingChangesGuard], }, { // Create a new path without a component defined to prevent the StandardizedCompetencyManagementComponent from being always rendered diff --git a/src/main/webapp/app/admin/standardized-competencies/standardized-competency-management.component.ts b/src/main/webapp/app/admin/standardized-competencies/standardized-competency-management.component.ts index fb73da984d1b..d23fe7213f6d 100644 --- a/src/main/webapp/app/admin/standardized-competencies/standardized-competency-management.component.ts +++ b/src/main/webapp/app/admin/standardized-competencies/standardized-competency-management.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { faChevronRight, faDownLeftAndUpRightToCenter, faEye, faFileExport, faFileImport, faPlus, faUpRightAndDownLeftFromCenter } from '@fortawesome/free-solid-svg-icons'; import { KnowledgeAreaDTO, @@ -576,14 +576,4 @@ export class StandardizedCompetencyManagementComponent extends StandardizedCompe get canDeactivateWarning(): string { return this.translateService.instant('pendingChanges'); } - - /** - * Displays the alert for confirming refreshing or closing the page if there are unsaved changes - */ - @HostListener('window:beforeunload', ['$event']) - unloadNotification(event: any) { - if (!this.canDeactivate()) { - event.returnValue = this.canDeactivateWarning; - } - } } diff --git a/src/main/webapp/app/admin/user-management/user-management-update.component.html b/src/main/webapp/app/admin/user-management/user-management-update.component.html index 71f70e3752db..bc55e3a13acb 100644 --- a/src/main/webapp/app/admin/user-management/user-management-update.component.html +++ b/src/main/webapp/app/admin/user-management/user-management-update.component.html @@ -1,7 +1,11 @@

-

+ @if (user.id === undefined) { +

+ } @else { +

+ }
@@ -83,7 +87,7 @@

@@ -255,15 +259,7 @@

diff --git a/src/main/webapp/app/admin/user-management/user-management-update.component.ts b/src/main/webapp/app/admin/user-management/user-management-update.component.ts index 2c4760d74f09..2d7e318f9038 100644 --- a/src/main/webapp/app/admin/user-management/user-management-update.component.ts +++ b/src/main/webapp/app/admin/user-management/user-management-update.component.ts @@ -16,7 +16,6 @@ import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { AlertService, AlertType } from 'app/core/util/alert.service'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { AdminUserService } from 'app/core/user/admin-user.service'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { CourseAdminService } from 'app/course/manage/course-admin.service'; @@ -59,7 +58,6 @@ export class UserManagementUpdateComponent implements OnInit { constructor( private languageHelper: JhiLanguageHelper, private userService: AdminUserService, - private courseManagementService: CourseManagementService, private courseAdminService: CourseAdminService, private route: ActivatedRoute, private organizationService: OrganizationManagementService, @@ -232,11 +230,17 @@ export class UserManagementUpdateComponent implements OnInit { passwordInput: ['', [Validators.minLength(PASSWORD_MIN_LENGTH), Validators.maxLength(PASSWORD_MAX_LENGTH)]], emailInput: ['', [Validators.required, Validators.minLength(this.EMAIL_MIN_LENGTH), Validators.maxLength(this.EMAIL_MAX_LENGTH)]], registrationNumberInput: ['', [Validators.maxLength(this.REGISTRATION_NUMBER_MAX_LENGTH)]], - activatedInput: ['', []], + activatedInput: [{ value: this.user.activated }], langKeyInput: ['', []], authorityInput: ['', []], - internalInput: [{ value: this.user.internal, disabled: true }], + internalInput: [{ value: this.user.internal, disabled: true }], // initially disabled, will be enabled if user.id is undefined }); + // Conditionally enable or disable 'internalInput' based on user.id + if (this.user.id !== undefined) { + this.editForm.get('internalInput')?.disable(); // Artemis does not support to edit the internal flag for existing users + } else { + this.editForm.get('internalInput')?.enable(); // New users can either be internal or external + } } /** diff --git a/src/main/webapp/app/admin/user-management/user-management.component.html b/src/main/webapp/app/admin/user-management/user-management.component.html index 129f30d67feb..de3a8d206669 100644 --- a/src/main/webapp/app/admin/user-management/user-management.component.html +++ b/src/main/webapp/app/admin/user-management/user-management.component.html @@ -27,13 +27,13 @@

name="searchTerm" id="field_searchTerm" formControlName="searchControl" - [(ngModel)]="searchTerm" - (focusout)="loadAll()" + (blur)="loadAll()" + (keydown)="onKeydown($event)" /> - @if (searchControl.invalid && (searchControl.dirty || searchControl.touched)) { + @if (searchInvalid) {
@@ -91,6 +91,9 @@

+ + + @@ -147,6 +150,18 @@

{{ user.id }} + + + + diff --git a/src/main/webapp/app/admin/user-management/user-management.component.ts b/src/main/webapp/app/admin/user-management/user-management.component.ts index 5ce57b8d4ed1..f7870f5369cf 100644 --- a/src/main/webapp/app/admin/user-management/user-management.component.ts +++ b/src/main/webapp/app/admin/user-management/user-management.component.ts @@ -7,8 +7,8 @@ import { User } from 'app/core/user/user.model'; import { AccountService } from 'app/core/auth/account.service'; import { AlertService } from 'app/core/util/alert.service'; import { SortingOrder } from 'app/shared/table/pageable-table'; -import { debounceTime, switchMap, tap } from 'rxjs/operators'; -import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; +import { switchMap, tap } from 'rxjs/operators'; +import { FormControl, FormGroup } from '@angular/forms'; import { EventManager } from 'app/core/util/event-manager.service'; import { ASC, DESC, ITEMS_PER_PAGE, SORT } from 'app/shared/constants/pagination.constants'; import { faEye, faFilter, faPlus, faSort, faTimes, faWrench } from '@fortawesome/free-solid-svg-icons'; @@ -17,7 +17,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { AdminUserService } from 'app/core/user/admin-user.service'; -import { UserService } from 'app/core/user/user.service'; export class UserFilter { authorityFilter: Set = new Set(); @@ -103,6 +102,7 @@ export class UserManagementComponent implements OnInit, OnDestroy { predicate!: string; ascending!: boolean; searchTermString = ''; + searchInvalid = false; isLdapProfileActive: boolean; // filters @@ -129,7 +129,6 @@ export class UserManagementComponent implements OnInit, OnDestroy { constructor( private adminUserService: AdminUserService, - private userService: UserService, private alertService: AlertService, private accountService: AccountService, private activatedRoute: ActivatedRoute, @@ -148,7 +147,6 @@ export class UserManagementComponent implements OnInit, OnDestroy { this.search .pipe( tap(() => (this.loadingSearchResult = true)), - debounceTime(1000), switchMap(() => this.adminUserService.query( { @@ -175,7 +173,7 @@ export class UserManagementComponent implements OnInit, OnDestroy { }); this.userSearchForm = new FormGroup({ - searchControl: new FormControl('', { validators: [this.validateUserSearch], updateOn: 'blur' }), + searchControl: new FormControl('', { updateOn: 'change' }), }); this.accountService.identity().then((user) => { this.currentAccount = user!; @@ -443,17 +441,21 @@ export class UserManagementComponent implements OnInit, OnDestroy { * Retrieve the list of users from the user service for a single page in the user management based on the page, size and sort configuration */ loadAll() { + this.searchTerm = this.searchControl.value; if (this.searchTerm.length >= 3 || this.searchTerm.length === 0) { + this.searchInvalid = false; this.search.next(); + } else { + this.searchInvalid = true; } } /** * Returns the unique identifier for items in the collection - * @param index of a user in the collection + * @param _index of a user in the collection * @param item current user */ - trackIdentity(index: number, item: User) { + trackIdentity(_index: number, item: User) { return item.id ?? -1; } @@ -520,14 +522,14 @@ export class UserManagementComponent implements OnInit, OnDestroy { return this.searchTermString; } - validateUserSearch(control: AbstractControl) { - if (control.value.length >= 1 && control.value.length <= 2) { - return { searchControl: true }; - } - return null; - } - get searchControl() { return this.userSearchForm.get('searchControl')!; } + + onKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault(); // Prevent the default form submission behavior + this.loadAll(); // Trigger the search logic + } + } } diff --git a/src/main/webapp/app/admin/user-management/user-management.route.ts b/src/main/webapp/app/admin/user-management/user-management.route.ts index e98972a5c3f0..df2178c4a68b 100644 --- a/src/main/webapp/app/admin/user-management/user-management.route.ts +++ b/src/main/webapp/app/admin/user-management/user-management.route.ts @@ -51,7 +51,7 @@ export const userManagementRoute: Route[] = [ path: 'edit', component: UserManagementUpdateComponent, data: { - pageTitle: 'artemisApp.userManagement.home.createOrEditLabel', + pageTitle: 'artemisApp.userManagement.home.editLabel', }, }, ], diff --git a/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html b/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html index fbf4de107932..47c600031279 100644 --- a/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html +++ b/src/main/webapp/app/assessment/assessment-header/assessment-header.component.html @@ -115,7 +115,7 @@

} diff --git a/src/main/webapp/app/complaints/form/complaints-form.component.ts b/src/main/webapp/app/complaints/form/complaints-form.component.ts index 5ebe167df7bb..015b05f95a95 100644 --- a/src/main/webapp/app/complaints/form/complaints-form.component.ts +++ b/src/main/webapp/app/complaints/form/complaints-form.component.ts @@ -19,8 +19,7 @@ export class ComplaintsFormComponent implements OnInit { @Input() examId?: number; @Input() complaintType: ComplaintType; @Input() isCurrentUserSubmissionAuthor = false; - // eslint-disable-next-line @angular-eslint/no-output-native - @Output() submit: EventEmitter = new EventEmitter(); + @Output() onSubmit: EventEmitter = new EventEmitter(); maxComplaintsPerCourse = 1; maxComplaintTextLimit: number; complaintText?: string; @@ -63,7 +62,7 @@ export class ComplaintsFormComponent implements OnInit { this.complaintService.create(complaintRequest).subscribe({ next: () => { - this.submit.emit(); + this.onSubmit.emit(); }, error: (err: HttpErrorResponse) => { if (err?.error?.errorKey === 'tooManyComplaints') { diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 8c22990b2a17..a89944954c05 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -41,6 +41,7 @@ export class AccountService implements IAccountService { private websocketService = inject(JhiWebsocketService); private featureToggleService = inject(FeatureToggleService); + // cached value of the user to avoid unnecessary requests to the server private userIdentityValue?: User; private authenticated = false; private authenticationState = new BehaviorSubject(undefined); diff --git a/src/main/webapp/app/core/core.module.ts b/src/main/webapp/app/core/core.module.ts index a371ea41e9b1..30c0442557b2 100644 --- a/src/main/webapp/app/core/core.module.ts +++ b/src/main/webapp/app/core/core.module.ts @@ -20,6 +20,7 @@ import { NgbDateDayjsAdapter } from 'app/core/config/datepicker-adapter'; import { JhiLanguageHelper } from 'app/core/language/language.helper'; import { TraceService } from '@sentry/angular'; import { Router } from '@angular/router'; +import isMobile from 'ismobilejs-es5'; @NgModule({ imports: [ @@ -109,5 +110,8 @@ export class ArtemisCoreModule { const languageKey = sessionStorageService.retrieve('locale') || languageHelper.determinePreferredLanguage(); translateService.use(languageKey); tooltipConfig.container = 'body'; + if (isMobile(window.navigator.userAgent).any ?? false) { + tooltipConfig.disableTooltip = true; + } } } diff --git a/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.html b/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.html index 86b706cec05b..21ad25ca8389 100644 --- a/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.html +++ b/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.html @@ -1,11 +1,13 @@ - @if (competencies && competencies.length > 0) { + @if (competencyLinks?.length) {
diff --git a/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.ts b/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.ts index 8eee4ffa5a23..bdc5747b4ed3 100644 --- a/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.ts +++ b/src/main/webapp/app/course/competencies/competencies-popover/competencies-popover.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { faFlag } from '@fortawesome/free-solid-svg-icons'; -import { Competency } from 'app/entities/competency.model'; +import { CompetencyLectureUnitLink } from 'app/entities/competency.model'; @Component({ selector: 'jhi-competencies-popover', @@ -12,7 +12,7 @@ export class CompetenciesPopoverComponent implements OnInit { @Input() courseId: number; @Input() - competencies: Competency[] = []; + competencyLinks: CompetencyLectureUnitLink[] = []; @Input() navigateTo: 'competencyManagement' | 'courseCompetencies' = 'courseCompetencies'; diff --git a/src/main/webapp/app/course/competencies/course-competency.service.ts b/src/main/webapp/app/course/competencies/course-competency.service.ts index 04403d48db93..e7a17d6d49ea 100644 --- a/src/main/webapp/app/course/competencies/course-competency.service.ts +++ b/src/main/webapp/app/course/competencies/course-competency.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Competency, + CompetencyExerciseLink, CompetencyJol, CompetencyProgress, CompetencyRelation, @@ -17,7 +18,6 @@ import { convertDateFromClient, convertDateFromServer } from 'app/utils/date.uti import { CompetencyPageableSearch, SearchResult } from 'app/shared/table/pageable-table'; import { HttpParams } from '@angular/common/http'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; -import { Prerequisite } from 'app/entities/prerequisite.model'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AccountService } from 'app/core/auth/account.service'; import { CompetencyRecommendation } from 'app/course/competencies/generate-competencies/generate-competencies.component'; @@ -192,36 +192,52 @@ export class CourseCompetencyService { if (res.body?.softDueDate) { res.body.softDueDate = convertDateFromServer(res.body.softDueDate); } - if (res.body?.lectureUnits) { - res.body.lectureUnits = this.lectureUnitService.convertLectureUnitArrayDatesFromServer(res.body.lectureUnits); - } + res.body?.lectureUnitLinks?.forEach((lectureUnitLink) => { + if (lectureUnitLink.lectureUnit) { + lectureUnitLink.lectureUnit = this.lectureUnitService.convertLectureUnitDateFromServer(lectureUnitLink.lectureUnit); + } + }); if (res.body?.course) { this.accountService.setAccessRightsForCourse(res.body.course); } - if (res.body?.exercises) { - res.body.exercises = ExerciseService.convertExercisesDateFromServer(res.body.exercises); - res.body.exercises.forEach((exercise) => { - ExerciseService.parseExerciseCategories(exercise); - this.accountService.setAccessRightsForExercise(exercise); - }); - } + this.convertExerciseLinksFromServer(res.body?.exerciseLinks); return res; } - protected convertCompetencyFromClient(prerequisite: Prerequisite): Prerequisite { - const copy = Object.assign({}, prerequisite, { - softDueDate: convertDateFromClient(prerequisite.softDueDate), + private convertExerciseLinksFromServer(exerciseLinks: CompetencyExerciseLink[] | undefined) { + exerciseLinks?.forEach((exerciseLink) => { + exerciseLink.exercise = ExerciseService.convertExerciseDatesFromServer(exerciseLink.exercise); + ExerciseService.parseExerciseCategories(exerciseLink.exercise); + if (exerciseLink.exercise) { + this.accountService.setAccessRightsForExercise(exerciseLink.exercise); + } }); - if (copy.lectureUnits) { - copy.lectureUnits = this.lectureUnitService.convertLectureUnitArrayDatesFromClient(copy.lectureUnits); - } - if (copy.exercises) { - copy.exercises = copy.exercises.map((exercise) => ExerciseService.convertExerciseFromClient(exercise)); - } + } + + protected convertCompetencyFromClient(courseCompetency: CourseCompetency): CourseCompetency { + const copy = Object.assign({}, courseCompetency, { + softDueDate: convertDateFromClient(courseCompetency.softDueDate), + }); + + this.convertCompetencyLinksFromClient(copy); + return copy; } + private convertCompetencyLinksFromClient(courseCompetency: CourseCompetency) { + courseCompetency.lectureUnitLinks?.forEach((lectureUnitLink) => { + if (lectureUnitLink.lectureUnit) { + lectureUnitLink.lectureUnit = this.lectureUnitService.convertLectureUnitDatesFromClient(lectureUnitLink.lectureUnit); + } + }); + courseCompetency.exerciseLinks?.forEach((exerciseLink) => { + if (exerciseLink.exercise) { + exerciseLink.exercise = ExerciseService.convertExerciseFromClient(exerciseLink.exercise); + } + }); + } + /** * Helper methods for date conversion from server and client */ diff --git a/src/main/webapp/app/course/competencies/create/create-competency.component.html b/src/main/webapp/app/course/competencies/create/create-competency.component.html index 1aadd38f47a2..3d9c3790d40c 100644 --- a/src/main/webapp/app/course/competencies/create/create-competency.component.html +++ b/src/main/webapp/app/course/competencies/create/create-competency.component.html @@ -10,6 +10,12 @@

- +
} diff --git a/src/main/webapp/app/course/competencies/create/create-competency.component.ts b/src/main/webapp/app/course/competencies/create/create-competency.component.ts index c9faea9de7fe..267d9016631c 100644 --- a/src/main/webapp/app/course/competencies/create/create-competency.component.ts +++ b/src/main/webapp/app/course/competencies/create/create-competency.component.ts @@ -38,7 +38,7 @@ export class CreateCompetencyComponent extends CreateCourseCompetencyComponent { return; } - const { title, description, softDueDate, taxonomy, masteryThreshold, optional, connectedLectureUnits } = formData; + const { title, description, softDueDate, taxonomy, masteryThreshold, optional } = formData; this.competencyToCreate.title = title; this.competencyToCreate.description = description; @@ -46,7 +46,6 @@ export class CreateCompetencyComponent extends CreateCourseCompetencyComponent { this.competencyToCreate.taxonomy = taxonomy; this.competencyToCreate.masteryThreshold = masteryThreshold; this.competencyToCreate.optional = optional; - this.competencyToCreate.lectureUnits = connectedLectureUnits; this.isLoading = true; diff --git a/src/main/webapp/app/course/competencies/create/create-prerequisite.component.html b/src/main/webapp/app/course/competencies/create/create-prerequisite.component.html index 59e1065435c9..db7bbbec9b00 100644 --- a/src/main/webapp/app/course/competencies/create/create-prerequisite.component.html +++ b/src/main/webapp/app/course/competencies/create/create-prerequisite.component.html @@ -15,6 +15,7 @@

(formSubmitted)="createPrerequisite($event)" [courseId]="courseId" [lecturesOfCourseWithLectureUnits]="lecturesWithLectureUnits" + [prerequisite]="prerequisiteToCreate" /> } diff --git a/src/main/webapp/app/course/competencies/create/create-prerequisite.component.ts b/src/main/webapp/app/course/competencies/create/create-prerequisite.component.ts index 75c6413f84e7..befbdfb3478e 100644 --- a/src/main/webapp/app/course/competencies/create/create-prerequisite.component.ts +++ b/src/main/webapp/app/course/competencies/create/create-prerequisite.component.ts @@ -37,7 +37,7 @@ export class CreatePrerequisiteComponent extends CreateCourseCompetencyComponent return; } - const { title, description, softDueDate, taxonomy, masteryThreshold, optional, connectedLectureUnits } = formData; + const { title, description, softDueDate, taxonomy, masteryThreshold, optional } = formData; this.prerequisiteToCreate.title = title; this.prerequisiteToCreate.description = description; @@ -45,12 +45,11 @@ export class CreatePrerequisiteComponent extends CreateCourseCompetencyComponent this.prerequisiteToCreate.taxonomy = taxonomy; this.prerequisiteToCreate.masteryThreshold = masteryThreshold; this.prerequisiteToCreate.optional = optional; - this.prerequisiteToCreate.lectureUnits = connectedLectureUnits; this.isLoading = true; this.prerequisiteService - .create(this.prerequisiteToCreate!, this.courseId) + .create(this.prerequisiteToCreate, this.courseId) .pipe( finalize(() => { this.isLoading = false; diff --git a/src/main/webapp/app/course/competencies/edit/edit-competency.component.html b/src/main/webapp/app/course/competencies/edit/edit-competency.component.html index 7e166c011d7a..31423fd5b73c 100644 --- a/src/main/webapp/app/course/competencies/edit/edit-competency.component.html +++ b/src/main/webapp/app/course/competencies/edit/edit-competency.component.html @@ -14,6 +14,7 @@

[courseId]="courseId" [lecturesOfCourseWithLectureUnits]="lecturesWithLectureUnits" [averageStudentScore]="competency?.courseProgress?.averageStudentScore ?? 0" + [competency]="competency" /> } diff --git a/src/main/webapp/app/course/competencies/edit/edit-competency.component.ts b/src/main/webapp/app/course/competencies/edit/edit-competency.component.ts index 66687b025022..7ea6f4b22b76 100644 --- a/src/main/webapp/app/course/competencies/edit/edit-competency.component.ts +++ b/src/main/webapp/app/course/competencies/edit/edit-competency.component.ts @@ -56,10 +56,6 @@ export class EditCompetencyComponent extends EditCourseCompetencyComponent imple if (courseProgressResult.body) { this.competency.courseProgress = courseProgressResult.body; } - // server will send undefined instead of empty array, therefore we set it here as it is easier to handle - if (!this.competency.lectureUnits) { - this.competency.lectureUnits = []; - } } this.formData = { @@ -67,7 +63,6 @@ export class EditCompetencyComponent extends EditCourseCompetencyComponent imple title: this.competency.title, description: this.competency.description, softDueDate: this.competency.softDueDate, - connectedLectureUnits: this.competency.lectureUnits, taxonomy: this.competency.taxonomy, masteryThreshold: this.competency.masteryThreshold, optional: this.competency.optional, @@ -78,7 +73,7 @@ export class EditCompetencyComponent extends EditCourseCompetencyComponent imple } updateCompetency(formData: CourseCompetencyFormData) { - const { title, description, softDueDate, taxonomy, masteryThreshold, optional, connectedLectureUnits } = formData; + const { title, description, softDueDate, taxonomy, masteryThreshold, optional } = formData; this.competency.title = title; this.competency.description = description; @@ -86,7 +81,6 @@ export class EditCompetencyComponent extends EditCourseCompetencyComponent imple this.competency.taxonomy = taxonomy; this.competency.masteryThreshold = masteryThreshold; this.competency.optional = optional; - this.competency.lectureUnits = connectedLectureUnits; this.isLoading = true; diff --git a/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.html b/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.html index 8969dc814117..9d3f5968be1c 100644 --- a/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.html +++ b/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.html @@ -14,6 +14,7 @@

[courseId]="courseId" [lecturesOfCourseWithLectureUnits]="lecturesWithLectureUnits" [averageStudentScore]="prerequisite?.courseProgress?.averageStudentScore ?? 0" + [prerequisite]="prerequisite" /> } diff --git a/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.ts b/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.ts index 1ef4bbb39c9c..d11ecd71c7fb 100644 --- a/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.ts +++ b/src/main/webapp/app/course/competencies/edit/edit-prerequisite.component.ts @@ -57,10 +57,6 @@ export class EditPrerequisiteComponent extends EditCourseCompetencyComponent imp if (courseProgressResult.body) { this.prerequisite.courseProgress = courseProgressResult.body; } - // server will send undefined instead of empty array, therefore we set it here as it is easier to handle - if (!this.prerequisite.lectureUnits) { - this.prerequisite.lectureUnits = []; - } } this.formData = { @@ -68,7 +64,6 @@ export class EditPrerequisiteComponent extends EditCourseCompetencyComponent imp title: this.prerequisite.title, description: this.prerequisite.description, softDueDate: this.prerequisite.softDueDate, - connectedLectureUnits: this.prerequisite.lectureUnits, taxonomy: this.prerequisite.taxonomy, masteryThreshold: this.prerequisite.masteryThreshold, optional: this.prerequisite.optional, @@ -79,7 +74,7 @@ export class EditPrerequisiteComponent extends EditCourseCompetencyComponent imp } updateCompetency(formData: CourseCompetencyFormData) { - const { title, description, softDueDate, taxonomy, masteryThreshold, optional, connectedLectureUnits } = formData; + const { title, description, softDueDate, taxonomy, masteryThreshold, optional } = formData; this.prerequisite.title = title; this.prerequisite.description = description; @@ -87,7 +82,6 @@ export class EditPrerequisiteComponent extends EditCourseCompetencyComponent imp this.prerequisite.taxonomy = taxonomy; this.prerequisite.masteryThreshold = masteryThreshold; this.prerequisite.optional = optional; - this.prerequisite.lectureUnits = connectedLectureUnits; this.isLoading = true; 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 77a39956b91c..afd6368b570d 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 @@ -1,21 +1,24 @@ @if (!isInConnectMode) {
- + @if (titleControl?.invalid && (titleControl?.dirty || titleControl?.touched)) {
@if (titleControl?.errors?.required) { -
+
} @if (titleControl?.errors?.maxlength) { -
+
} @if (titleControl?.errors?.titleUnique) {
@@ -38,7 +41,7 @@
@if (descriptionControl?.errors?.maxlength) {
} @@ -49,8 +52,8 @@
@@ -78,58 +81,7 @@ }
- - -
-
- - @if (lecturesOfCourseWithLectureUnits && lecturesOfCourseWithLectureUnits.length > 0) { -
- -
- @for (lecture of lecturesOfCourseWithLectureUnits; track lecture) { - - } -
-
- } @else { -
- } - @if (selectedLectureInDropdown) { -
- - - - - - - - - - - @for (lectureUnit of selectedLectureInDropdown.lectureUnits; track lectureUnit) { - - - - - - - } - -
id
{{ lectureUnit.id ? lectureUnit.id : '' }}{{ lectureUnit.type ? lectureUnit.type : '' }}{{ lectureUnitService.getLectureUnitName(lectureUnit) ? lectureUnitService.getLectureUnitName(lectureUnit) : '' }} - {{ - lectureUnitService.getLectureUnitReleaseDate(lectureUnit) - ? lectureUnitService.getLectureUnitReleaseDate(lectureUnit)!.format('MMM DD YYYY, HH:mm:ss') - : '' - }} -
-
- } + +
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 eff8620b4cf7..cd512a1d12f6 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 @@ -1,11 +1,9 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { Lecture } from 'app/entities/lecture.model'; -import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; import { TranslateService } from '@ngx-translate/core'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; -import { intersection } from 'lodash-es'; -import { CompetencyTaxonomy, CourseCompetencyType, CourseCompetencyValidators, DEFAULT_MASTERY_THRESHOLD } from 'app/entities/competency.model'; +import { CompetencyTaxonomy, CourseCompetency, CourseCompetencyValidators, DEFAULT_MASTERY_THRESHOLD } from 'app/entities/competency.model'; import { faQuestionCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; import { CourseCompetencyFormData } from 'app/course/competencies/forms/course-competency-form.component'; import { ArtemisSharedModule } from 'app/shared/shared.module'; @@ -50,17 +48,13 @@ export class CommonCourseCompetencyFormComponent implements OnInit, OnChanges { @Input() form: FormGroup; @Input() - competencyType: CourseCompetencyType; + courseCompetency: CourseCompetency; - @Output() - onLectureUnitSelectionChange = new EventEmitter(); @Output() onTitleOrDescriptionChange = new EventEmitter(); protected readonly competencyValidators = CourseCompetencyValidators; - selectedLectureInDropdown: Lecture; - selectedLectureUnitsInTable: LectureUnit[] = []; suggestedTaxonomies: string[] = []; // Icons @@ -120,44 +114,7 @@ export class CommonCourseCompetencyFormComponent implements OnInit, OnChanges { private setFormValues(formData: CourseCompetencyFormData) { this.form.patchValue(formData); - if (formData.connectedLectureUnits) { - this.selectedLectureUnitsInTable = formData.connectedLectureUnits; - this.onLectureUnitSelectionChange.next(this.selectedLectureUnitsInTable); - } - } - - selectLectureInDropdown(lecture: Lecture) { - this.selectedLectureInDropdown = lecture; - } - - selectLectureUnitInTable(lectureUnit: LectureUnit) { - if (this.isLectureUnitAlreadySelectedInTable(lectureUnit)) { - this.selectedLectureUnitsInTable.forEach((selectedLectureUnit, index) => { - if (selectedLectureUnit.id === lectureUnit.id) { - this.selectedLectureUnitsInTable.splice(index, 1); - } - }); - } else { - this.selectedLectureUnitsInTable.push(lectureUnit); - } - this.onLectureUnitSelectionChange.next(this.selectedLectureUnitsInTable); - } - - isLectureUnitAlreadySelectedInTable(lectureUnit: LectureUnit) { - return this.selectedLectureUnitsInTable.map((selectedLectureUnit) => selectedLectureUnit.id).includes(lectureUnit.id); } - - getLectureTitleForDropdown(lecture: Lecture) { - const noOfSelectedUnitsInLecture = intersection( - this.selectedLectureUnitsInTable.map((unit) => unit.id), - lecture.lectureUnits?.map((unit) => unit.id), - ).length; - return this.translateService.instant('artemisApp.courseCompetency.create.dropdown', { - lectureTitle: lecture.title, - noOfConnectedUnits: noOfSelectedUnitsInLecture, - }); - } - /** * Suggest some taxonomies based on keywords used in the title or description. * Triggered after the user changes the title or description input field. diff --git a/src/main/webapp/app/course/competencies/forms/competency/competency-form.component.html b/src/main/webapp/app/course/competencies/forms/competency/competency-form.component.html index e47e06508731..3c6066444220 100644 --- a/src/main/webapp/app/course/competencies/forms/competency/competency-form.component.html +++ b/src/main/webapp/app/course/competencies/forms/competency/competency-form.component.html @@ -10,8 +10,7 @@ [lecturesOfCourseWithLectureUnits]="lecturesOfCourseWithLectureUnits" [averageStudentScore]="averageStudentScore" [form]="form" - [competencyType]="CourseCompetencyType.COMPETENCY" - (onLectureUnitSelectionChange)="onLectureUnitSelectionChange($event)" + [courseCompetency]="competency" />
@@ -13,18 +25,16 @@
@@ -170,7 +183,7 @@ type="checkbox" name="recreateBuildPlans" id="field_recreateBuildPlans" - [(ngModel)]="importOptions.recreateBuildPlans" + [(ngModel)]="importOptions().recreateBuildPlans" (change)="programmingExerciseCreationConfig.recreateBuildPlanOrUpdateTemplateChange()" /> @@ -180,8 +193,8 @@ } @if ( programmingExerciseCreationConfig.isImportFromExistingExercise && - programmingExercise.projectType !== ProjectType.PLAIN_GRADLE && - programmingExercise.projectType !== ProjectType.GRADLE_GRADLE + programmingExercise().projectType !== ProjectType.PLAIN_GRADLE && + programmingExercise().projectType !== ProjectType.GRADLE_GRADLE ) {
}
- @if (!programmingExerciseCreationConfig.isExamMode) { + @if (!programmingExerciseCreationConfig.isExamMode && isEditFieldDisplayedRecord().categories) {
@@ -213,4 +226,7 @@ />
} + @if (isSimpleMode()) { + + } diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss b/src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.scss similarity index 50% rename from src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss rename to src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.scss index 76bf7863f5c5..47462abdee4e 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-information.component.scss +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.scss @@ -1,3 +1,7 @@ #creation-config-selector { position: relative; } + +::ng-deep .tooltip-inner { + max-width: 600px; +} diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.ts b/src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.ts new file mode 100644 index 000000000000..78158c26e1ce --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/information/programming-exercise-information.component.ts @@ -0,0 +1,318 @@ +import { + AfterViewInit, + Component, + Input, + OnChanges, + OnDestroy, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren, + effect, + inject, + input, + model, + signal, + viewChild, +} from '@angular/core'; +import { NgModel } from '@angular/forms'; +import { ProgrammingExercise, ProjectType } from 'app/entities/programming/programming-exercise.model'; +import { ProgrammingExerciseCreationConfig } from 'app/exercises/programming/manage/update/programming-exercise-creation-config'; +import { ExerciseTitleChannelNameComponent } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component'; +import { Subject, Subscription } from 'rxjs'; +import { TableEditableFieldComponent } from 'app/shared/table/table-editable-field.component'; +import { every } from 'lodash-es'; +import { ImportOptions } from 'app/types/programming-exercises'; +import { ProgrammingExerciseInputField } from 'app/exercises/programming/manage/update/programming-exercise-update.helper'; +import { removeSpecialCharacters } from 'app/shared/util/utils'; +import { CourseExistingExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { ProgrammingExerciseEditCheckoutDirectoriesComponent } from 'app/exercises/programming/shared/build-details/programming-exercise-edit-checkout-directories/programming-exercise-edit-checkout-directories.component'; +import { BuildPlanCheckoutDirectoriesDTO } from 'app/entities/programming/build-plan-checkout-directories-dto'; + +const MAXIMUM_TRIES_TO_GENERATE_UNIQUE_SHORT_NAME = 200; + +@Component({ + selector: 'jhi-programming-exercise-info', + templateUrl: './programming-exercise-information.component.html', + styleUrls: ['../../../programming-exercise-form.scss', 'programming-exercise-information.component.scss'], +}) +export class ProgrammingExerciseInformationComponent implements AfterViewInit, OnChanges, OnDestroy { + protected readonly ProjectType = ProjectType; + protected readonly ButtonType = ButtonType; + protected readonly ButtonSize = ButtonSize; + protected readonly faPlus = faPlus; + + @Input({ required: true }) programmingExerciseCreationConfig: ProgrammingExerciseCreationConfig; + isImport = input.required(); + isExamMode = input.required(); + programmingExercise = input.required(); + isLocal = input.required(); + importOptions = input.required(); + isSimpleMode = input.required(); + isEditFieldDisplayedRecord = input.required>(); + courseId = input(); + isAuxiliaryRepositoryInputValid = model.required(); + + exerciseTitleChannelComponent = viewChild('titleChannelNameComponent'); + @ViewChildren(TableEditableFieldComponent) tableEditableFields?: QueryList; + + shortNameField = viewChild('shortName'); + @ViewChild('checkoutSolutionRepository') checkoutSolutionRepositoryField?: NgModel; + @ViewChild('recreateBuildPlans') recreateBuildPlansField?: NgModel; + @ViewChild('updateTemplateFiles') updateTemplateFilesField?: NgModel; + @ViewChild('titleChannelNameComponent') titleComponent?: ExerciseTitleChannelNameComponent; + @ViewChild(ProgrammingExerciseEditCheckoutDirectoriesComponent) programmingExerciseEditCheckoutDirectories?: ProgrammingExerciseEditCheckoutDirectoriesComponent; + + private readonly exerciseService: ExerciseService = inject(ExerciseService); + private readonly alertService: AlertService = inject(AlertService); + + isShortNameFieldValid = signal(false); + isShortNameFromAdvancedMode = signal(false); + + formValid: boolean; + formValidChanges = new Subject(); + + inputFieldSubscriptions: (Subscription | undefined)[] = []; + + alreadyUsedExerciseNames = signal>(new Set()); + alreadyUsedShortNames = signal>(new Set()); + + exerciseTitle = signal(undefined); + + editRepositoryCheckoutPath: boolean = false; + submissionBuildPlanCheckoutRepositories: BuildPlanCheckoutDirectoriesDTO; + + constructor() { + effect( + () => { + this.defineShortNameOnEditModeChangeIfNotDefinedInAdvancedMode(); + }, + { allowSignalWrites: true }, + ); + + effect( + () => { + this.generateShortNameWhenInSimpleMode(); + }, + { allowSignalWrites: true }, + ); + + effect(() => { + this.registerInputFieldsWhenChildComponentsAreReady(); + }); + + effect(() => { + this.fetchAndInitializeTakenTitlesAndShortNames(); + }); + } + + ngAfterViewInit() { + this.registerInputFields(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.programmingExercise) { + this.exerciseTitle.set(this.programmingExercise().title); + } + } + + ngOnDestroy(): void { + for (const subscription of this.inputFieldSubscriptions) { + subscription?.unsubscribe(); + } + } + + registerInputFields() { + this.inputFieldSubscriptions.forEach((subscription) => subscription?.unsubscribe()); + + this.inputFieldSubscriptions.push(this.exerciseTitleChannelComponent()?.titleChannelNameComponent?.formValidChanges.subscribe(() => this.calculateFormValid())); + this.inputFieldSubscriptions.push(this.shortNameField()?.valueChanges?.subscribe(() => this.calculateFormValid())); + this.inputFieldSubscriptions.push(this.checkoutSolutionRepositoryField?.valueChanges?.subscribe(() => this.calculateFormValid())); + this.inputFieldSubscriptions.push(this.recreateBuildPlansField?.valueChanges?.subscribe(() => this.calculateFormValid())); + this.inputFieldSubscriptions.push(this.updateTemplateFilesField?.valueChanges?.subscribe(() => this.calculateFormValid())); + this.inputFieldSubscriptions.push(this.programmingExerciseEditCheckoutDirectories?.formValidChanges.subscribe(() => this.calculateFormValid())); + this.tableEditableFields?.changes.subscribe((fields: QueryList) => { + fields.toArray().forEach((field) => this.inputFieldSubscriptions.push(field.editingInput.valueChanges?.subscribe(() => this.calculateFormValid()))); + }); + + this.titleComponent?.titleChannelNameComponent?.field_title?.valueChanges?.subscribe((newTitle: string) => { + if (this.isSimpleMode()) { + this.updateShortName(newTitle); + } + }); + + this.shortNameField()?.valueChanges?.subscribe(() => { + this.updateIsShortNameValid(); + }); + } + + updateShortName(newTitle: string) { + this.exerciseTitle.set(newTitle); + } + + calculateFormValid() { + const isCheckoutSolutionRepositoryValid = this.isCheckoutSolutionRepositoryValid(); + const isRecreateBuildPlansValid = this.isRecreateBuildPlansValid(); + const isUpdateTemplateFilesValid = this.isUpdateTemplateFilesValid(); + const areAuxiliaryRepositoriesValid = this.areAuxiliaryRepositoriesValid(); + const areCheckoutPathsValid = this.areCheckoutPathsValid(); + this.formValid = Boolean( + this.exerciseTitleChannelComponent()?.titleChannelNameComponent?.formValidSignal() && + this.getIsShortNameFieldValid() && + isCheckoutSolutionRepositoryValid && + isRecreateBuildPlansValid && + isUpdateTemplateFilesValid && + areAuxiliaryRepositoriesValid && + areCheckoutPathsValid, + ); + this.formValidChanges.next(this.formValid); + } + + areAuxiliaryRepositoriesValid(): boolean { + const areAuxiliaryRepositoriesValid = + (every( + this.tableEditableFields?.map((field) => field.editingInput.valid), + Boolean, + ) && + !this.programmingExerciseCreationConfig.auxiliaryRepositoryDuplicateDirectories && + !this.programmingExerciseCreationConfig.auxiliaryRepositoryDuplicateNames) || + !this.programmingExercise().auxiliaryRepositories?.length; + + const isAuxRepoEditingPossibleInCurrentEditMode = !this.isSimpleMode() || this.isEditFieldDisplayedRecord().addAuxiliaryRepository; + if (isAuxRepoEditingPossibleInCurrentEditMode) { + // if editing is not possible the field will not be displayed and validity checks will evaluate to true, + // even if the actual current setting is invalid + this.isAuxiliaryRepositoryInputValid.set(areAuxiliaryRepositoriesValid); + } + return areAuxiliaryRepositoriesValid; + } + + isUpdateTemplateFilesValid(): boolean { + return ( + this.updateTemplateFilesField?.valid || + !this.programmingExerciseCreationConfig.isImportFromExistingExercise || + this.programmingExercise().projectType === ProjectType.PLAIN_GRADLE || + this.programmingExercise().projectType === ProjectType.GRADLE_GRADLE + ); + } + + isRecreateBuildPlansValid(): boolean { + return this.recreateBuildPlansField?.valid || !this.programmingExerciseCreationConfig.isImportFromExistingExercise; + } + + isCheckoutSolutionRepositoryValid(): boolean { + return Boolean( + this.checkoutSolutionRepositoryField?.valid || + this.programmingExercise().id || + !this.programmingExercise().programmingLanguage || + !this.programmingExerciseCreationConfig.checkoutSolutionRepositoryAllowed, + ); + } + + areCheckoutPathsValid(): boolean { + return Boolean( + !this.programmingExerciseEditCheckoutDirectories || + (this.programmingExerciseEditCheckoutDirectories.formValid && + this.programmingExerciseEditCheckoutDirectories.areValuesUnique([ + this.programmingExercise().buildConfig?.assignmentCheckoutPath, + this.programmingExercise().buildConfig?.testCheckoutPath, + this.programmingExercise().buildConfig?.solutionCheckoutPath, + ])), + ); + } + + toggleEditRepositoryCheckoutPath() { + this.editRepositoryCheckoutPath = !this.editRepositoryCheckoutPath; + } + + updateSubmissionBuildPlanCheckoutDirectories(buildPlanCheckoutDirectoriesDTO: BuildPlanCheckoutDirectoriesDTO) { + this.submissionBuildPlanCheckoutRepositories = buildPlanCheckoutDirectoriesDTO; + } + + onAssigmentRepositoryCheckoutPathChange(event: string) { + this.programmingExercise().buildConfig!.assignmentCheckoutPath = event; + // We need to create a new object to trigger the change detection + this.programmingExercise().buildConfig = { ...this.programmingExercise().buildConfig }; + } + + onTestRepositoryCheckoutPathChange(event: string) { + this.programmingExercise().buildConfig!.testCheckoutPath = event; + // We need to create a new object to trigger the change detection + this.programmingExercise().buildConfig = { ...this.programmingExercise().buildConfig }; + } + + onSolutionRepositoryCheckoutPathChange(event: string) { + this.programmingExercise().buildConfig!.solutionCheckoutPath = event; + // We need to create a new object to trigger the change detection + this.programmingExercise().buildConfig = { ...this.programmingExercise().buildConfig }; + } + + private registerInputFieldsWhenChildComponentsAreReady() { + this.shortNameField(); // triggers effect + this.registerInputFields(); + } + + private fetchAndInitializeTakenTitlesAndShortNames() { + const courseId = this.courseId() ?? this.programmingExercise().course?.id; + if (courseId) { + this.exerciseService.getExistingExerciseDetailsInCourse(courseId, ExerciseType.PROGRAMMING).subscribe((exerciseDetails: CourseExistingExerciseDetailsType) => { + this.alreadyUsedExerciseNames.set(exerciseDetails.exerciseTitles ?? new Set()); + this.alreadyUsedShortNames.set(exerciseDetails.shortNames ?? new Set()); + }); + } + } + + private generateShortNameWhenInSimpleMode() { + const shouldNotGenerateShortName = !this.isSimpleMode() || this.programmingExerciseCreationConfig.isEdit; + if (shouldNotGenerateShortName) { + this.isShortNameFromAdvancedMode.set(this.isShortNameFieldValid()); + return; + } + let newShortName = this.exerciseTitle() ?? this.programmingExercise().title; + if (this.isImport() || this.isShortNameFromAdvancedMode()) { + newShortName = this.programmingExercise().shortName; + } + + if (newShortName && newShortName.length > 3) { + const sanitizedShortName = removeSpecialCharacters(newShortName ?? ''); + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const uniqueShortName = this.ensureShortNameIsUnique(sanitizedShortName); + this.programmingExercise().shortName = uniqueShortName; + } + + this.updateIsShortNameValid(); + } + + private defineShortNameOnEditModeChangeIfNotDefinedInAdvancedMode() { + if (this.isSimpleMode()) { + this.updateIsShortNameValid(); + this.calculateFormValid(); + } + } + + private updateIsShortNameValid() { + this.isShortNameFieldValid.set(this.getIsShortNameFieldValid()); + } + + private getIsShortNameFieldValid() { + return this.shortNameField() === undefined || this.shortNameField()?.control?.status === 'VALID' || this.shortNameField()?.control?.status === 'DISABLED'; + } + + private ensureShortNameIsUnique(shortName: string): string { + let newShortName = shortName; + let counter = 1; + while (this.alreadyUsedShortNames().has(newShortName)) { + if (counter > MAXIMUM_TRIES_TO_GENERATE_UNIQUE_SHORT_NAME) { + this.alertService.error('artemisApp.error.shortNameGenerationFailed'); + break; + } + newShortName = `${shortName}${counter}`; + counter++; + } + return newShortName; + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.html new file mode 100644 index 000000000000..7b60308b7091 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.html @@ -0,0 +1,240 @@ +
+

+

+ @if (isEditFieldDisplayedRecord().programmingLanguage) { +
+ + +
+ } + @if (programmingExercise.programmingLanguage && programmingExerciseCreationConfig.projectTypes?.length && programmingExerciseCreationConfig.modePickerOptions) { +
+ @if (isEditFieldDisplayedRecord().projectType) { + + + } + @if ( + isEditFieldDisplayedRecord().withExemplaryDependency && + !programmingExerciseCreationConfig.isImportFromExistingExercise && + !programmingExerciseCreationConfig.isImportFromFile && + !programmingExercise.id && + programmingExercise.programmingLanguage === ProgrammingLanguage.JAVA && + programmingExercise.projectType !== ProjectType.MAVEN_BLACKBOX + ) { +
+ + + +
+ } +
+ } + @if (isEditFieldDisplayedRecord().packageName) { + @if (programmingExercise.programmingLanguage === ProgrammingLanguage.EMPTY) { +
+

+ + +

+
    +
  1. +
  2. +

    +
      +
    1. +
    2. +
    3. +
    +
  3. +
  4. +
  5. +
  6. +
+
+ } + @if (programmingExercise.programmingLanguage && programmingExerciseCreationConfig.packageNameRequired && programmingExercise.projectType !== ProjectType.XCODE) { +
+ + + @for (e of packageName.errors! | keyvalue | removekeys: ['required']; track e) { + @if (packageName.invalid && (packageName.dirty || packageName.touched)) { +
+ @if (programmingExercise.projectType === ProjectType.MAVEN_BLACKBOX) { +
+ } + @if (programmingExercise.projectType !== ProjectType.MAVEN_BLACKBOX) { +
+ } +
+ } + } +
+ } + @if (programmingExercise.programmingLanguage === ProgrammingLanguage.SWIFT && programmingExercise.projectType === ProjectType.XCODE) { +
+ + + @for (e of packageName.errors! | keyvalue | removekeys: ['required']; track e) { + @if (packageName.invalid && (packageName.dirty || packageName.touched)) { +
+
+
+ } + } +
+ } + } + + + @if (programmingExercise.allowOnlineIde && programmingExercise.programmingLanguage) { + + + } + + + @if (isEditFieldDisplayedRecord().enableStaticCodeAnalysis && programmingExercise.programmingLanguage && programmingExerciseCreationConfig.staticCodeAnalysisAllowed) { +
+
+ + + +
+
+ } + + @if (isEditFieldDisplayedRecord().sequentialTestRuns && programmingExerciseCreationConfig.sequentialTestRunsAllowed) { +
+
+ + + +
+
+ } + + @if (programmingExerciseCreationConfig.testwiseCoverageAnalysisSupported) { +
+ + + +
+ } + @if (!programmingExercise.id && programmingExercise.programmingLanguage && programmingExerciseCreationConfig.checkoutSolutionRepositoryAllowed) { +
+ +
+ } + @if (isEditFieldDisplayedRecord().customizeBuildScript) { + @if (programmingExerciseCreationConfig.customBuildPlansSupported === PROFILE_LOCALCI) { + + } @else if (programmingExerciseCreationConfig.customBuildPlansSupported === PROFILE_AEOLUS) { + + } + } +
diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-language.component.ts b/src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.ts similarity index 92% rename from src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-language.component.ts rename to src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.ts index 7514543ec6e9..5f9bae769c84 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-language.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/language/programming-exercise-language.component.ts @@ -1,4 +1,4 @@ -import { AfterViewChecked, AfterViewInit, Component, EventEmitter, Input, OnDestroy, ViewChild } from '@angular/core'; +import { AfterViewChecked, AfterViewInit, Component, EventEmitter, Input, OnDestroy, ViewChild, input } from '@angular/core'; import { ProgrammingExercise, ProgrammingLanguage, ProjectType } from 'app/entities/programming/programming-exercise.model'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { ProgrammingExerciseCreationConfig } from 'app/exercises/programming/manage/update/programming-exercise-creation-config'; @@ -8,18 +8,20 @@ import { Subject, Subscription } from 'rxjs'; import { ProgrammingExerciseCustomAeolusBuildPlanComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-aeolus-build-plan.component'; import { ProgrammingExerciseCustomBuildPlanComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component'; import { ProgrammingExerciseTheiaComponent } from 'app/exercises/programming/manage/update/update-components/theia/programming-exercise-theia.component'; +import { ProgrammingExerciseInputField } from 'app/exercises/programming/manage/update/programming-exercise-update.helper'; @Component({ selector: 'jhi-programming-exercise-language', templateUrl: './programming-exercise-language.component.html', - styleUrls: ['../../programming-exercise-form.scss'], + styleUrls: ['../../../programming-exercise-form.scss'], }) export class ProgrammingExerciseLanguageComponent implements AfterViewChecked, AfterViewInit, OnDestroy { readonly ProgrammingLanguage = ProgrammingLanguage; readonly ProjectType = ProjectType; - @Input() programmingExercise: ProgrammingExercise; - @Input() programmingExerciseCreationConfig: ProgrammingExerciseCreationConfig; + @Input({ required: true }) programmingExercise: ProgrammingExercise; + @Input({ required: true }) programmingExerciseCreationConfig: ProgrammingExerciseCreationConfig; + isEditFieldDisplayedRecord = input.required>(); @ViewChild('select') selectLanguageField: NgModel; @ViewChild('packageName') packageNameField?: NgModel; diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/mode/programming-exercise-mode.component.html similarity index 60% rename from src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html rename to src/main/webapp/app/exercises/programming/manage/update/update-components/mode/programming-exercise-mode.component.html index 59cc94f7c1ba..aa2b6df8bc7d 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/mode/programming-exercise-mode.component.html @@ -4,50 +4,51 @@ id="artemisApp.programmingExercise.wizardMode.detailedSteps.difficultyStepTitle" >

-
- -
- + @if (isEditFieldDisplayedRecord().difficulty) { + + } + @if (isEditFieldDisplayedRecord().participationMode) { +
+
-
+ }
- -
-
-
- +
+ } + @if (ProjectType.XCODE !== programmingExerciseCreationConfig.selectedProjectType && isEditFieldDisplayedRecord().allowOnlineCodeEditor) {