diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2561dae05690..08e009547a4a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,7 +9,7 @@ tone_instructions: '' early_access: true enable_free_tier: true reviews: - profile: assertive + profile: chill request_changes_workflow: true high_level_summary: true high_level_summary_placeholder: '@coderabbitai summary' @@ -192,7 +192,7 @@ reviews: - TYPOGRAPHY - CASING enabled_only: false - level: picky + level: default enabled_rules: [] enabled_categories: [] biome: diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml new file mode 100644 index 000000000000..b2201cc36358 --- /dev/null +++ b/.github/issue-labeler.yml @@ -0,0 +1,144 @@ +assessment: + - assessment + - bonus + - complaint + - submission + - grading + - grade + - score + - caseSensitive: false + +athena: + - athena + - caseSensitive: false + +atlas: + - atlas + - competency + - competencies + - knowledge area + - learning path + - learner profile + - science event + - adaptive learning + - caseSensitive: false + +buildagent: + - buildagent + - build agent + - buildjob + - build job + - build result + - caseSensitive: false + +communication: + - communication + - conversation + - notification + - agreement + - faq + - post + - reaction + - chat + - message + - caseSensitive: false + +core: + - user-management + - authority + - data export + - migration + - user + - group + - caseSensitive: false + +exam: + - exam + - exercisegroup + - student exam + - suspicious behavior + - suspicious behaviour + - caseSensitive: false + +exercise: + - exercise + - participation + - participant + - difficulty + - lifecycle + - team + - assignment + - caseSensitive: false + +fileupload: + - fileupload + - upload + - caseSensitive: false + +iris: + - iris + - llm + - chatbot + - ai + - caseSensitive: false + +lecture: + - lecture + - attachment + - online + - slide + - video + - text unit + - caseSensitive: false + +lti: + - lti + - online course + - caseSensitive: false + +modeling: + - modeling + - diagram + - uml + - caseSensitive: false + +plagiarism: + - plagiarism + - caseSensitive: false + +programming: + - programming + - build + - build plan + - code hint + - git + - testwise coverage + - ide + - submission policy + - aeolus + - penalty + - auxilary + - commit + - project + - static code analysis + - caseSensitive: false + +quiz: + - quiz + - drag + - drop + - single choice + - multiple choice + - batch + - short answer + - caseSensitive: false + +text: + - text + - block + - caseSensitive: false + +tutorialgroup: + - tutorialgroup + - session + - caseSensitive: false diff --git a/.github/workflows/analysis-of-endpoint-connections.yml b/.github/workflows/analysis-of-endpoint-connections.yml index f5f2dd4edb71..f74dff1b7b95 100644 --- a/.github/workflows/analysis-of-endpoint-connections.yml +++ b/.github/workflows/analysis-of-endpoint-connections.yml @@ -79,12 +79,16 @@ jobs: path: supporting_scripts/analysis-of-endpoint-connections/ - name: Analyze endpoints - run: + run: | ./gradlew :supporting_scripts:analysis-of-endpoint-connections:runEndpointAnalysis + continue-on-error: true + id: endpointAnalysis - name: Analyze rest calls - run: + run: | ./gradlew :supporting_scripts:analysis-of-endpoint-connections:runRestCallAnalysis + continue-on-error: true + id: restCallAnalysis - name: Upload analysis results uses: actions/upload-artifact@v4 @@ -93,3 +97,21 @@ jobs: path: | supporting_scripts/analysis-of-endpoint-connections/endpointAnalysisResult.json supporting_scripts/analysis-of-endpoint-connections/restCallAnalysisResult.json + + - name: Check if any step failed + run: | + if [ "${{ steps.endpointAnalysis.outcome }}" != "success" ] && + [ "${{ steps.restCallAnalysis.outcome }}" != "success" ]; then + echo "Endpoints and REST calls could not be matched." + exit 1 + fi + if [ "${{ steps.endpointAnalysis.outcome }}" == "success" ] && + [ "${{ steps.restCallAnalysis.outcome }}" != "success" ]; then + echo "REST calls could not be matched." + exit 1 + fi + if [ "${{ steps.endpointAnalysis.outcome }}" != "success" ] && + [ "${{ steps.restCallAnalysis.outcome }}" == "success" ]; then + echo "Endpoints could not be matched." + exit 1 + fi diff --git a/.github/workflows/issue-labler.yml b/.github/workflows/issue-labler.yml new file mode 100644 index 000000000000..10908c7bc2ee --- /dev/null +++ b/.github/workflows/issue-labler.yml @@ -0,0 +1,17 @@ +name: "Issue Labeler" +on: + issues: + types: [opened, edited] + +permissions: + issues: write + contents: read + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: MaximilianAnzinger/issue-labeler@1.0.1 + with: + configuration-path: .github/issue-labeler.yml + repo-token: ${{ github.token }} diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml new file mode 100644 index 000000000000..37e280ee2017 --- /dev/null +++ b/.github/workflows/validate-pr-title.yml @@ -0,0 +1,14 @@ +name: Validate PR Title + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, edited] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - uses: Slashgear/action-check-pr-title@v4.3.0 + with: + regexp: '^`(Programming exercises|Integrated code lifecycle|Quiz exercises|Modeling exercises|Text exercises|File upload exercises|Exam mode|Grading|Assessment|Communication|Notifications|Team exercises|Lectures|Integrated markdown editor|Plagiarism checks|Learning analytics|Adaptive learning|Learning path|Tutorial groups|Iris|Scalability|Usability|Performance|Infrastructure|Mobile apps|Development|General)`:\s[A-Z].*$' \ No newline at end of file diff --git a/README.md b/README.md index 08742f5d89b5..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.0.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 e5543ff2ce60..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/", @@ -113,7 +114,7 @@ }, { "glob": "**/*", - "input": "./node_modules/monaco-editor/min/vs", + "input": "./node_modules/monaco-editor/bundles/vs", "output": "vs" } ], diff --git a/build.gradle b/build.gradle index 29da90bf3674..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.0" +version = "7.6.5" description = "Interactive Learning with Individual Feedback" java { @@ -243,25 +243,28 @@ dependencies { exclude module: "jaxb-api" } - implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.5" + implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.6" implementation "de.jplag:jplag:${jplag_version}" - implementation "de.jplag:java:${jplag_version}" - implementation "de.jplag:kotlin:${jplag_version}" + implementation "de.jplag:c:${jplag_version}" - implementation "de.jplag:swift:${jplag_version}" + implementation "de.jplag:cpp:${jplag_version}" implementation "de.jplag:java:${jplag_version}" + implementation "de.jplag:javascript:${jplag_version}" + implementation "de.jplag:kotlin:${jplag_version}" implementation "de.jplag:python-3:${jplag_version}" + implementation "de.jplag:rlang:${jplag_version}" implementation "de.jplag:rust:${jplag_version}" - implementation "de.jplag:javascript:${jplag_version}" + implementation "de.jplag:swift:${jplag_version}" implementation "de.jplag:text:${jplag_version}" + implementation "de.jplag:typescript:${jplag_version}" // those are transitive dependencies of JPlag Text --> Stanford NLP // Note: ideally we would exclude them, but for some reason this does not work 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}" @@ -328,7 +331,7 @@ dependencies { // implementation "org.springdoc:springdoc-openapi-ui:1.8.0" // use the latest version to avoid security vulnerabilities - implementation "org.springframework:spring-webmvc:6.1.13" + implementation "org.springframework:spring-webmvc:6.1.14" implementation "com.vdurmont:semver4j:3.1.0" @@ -344,7 +347,7 @@ dependencies { implementation "tech.jhipster:jhipster-framework:${jhipster_dependencies_version}" implementation "org.springframework.boot:spring-boot-starter-cache:${spring_boot_version}" - implementation "io.micrometer:micrometer-registry-prometheus:1.13.5" + implementation "io.micrometer:micrometer-registry-prometheus:1.13.6" implementation "net.logstash.logback:logstash-logback-encoder:8.0" // Defines low-level streaming API, and includes JSON-specific implementations @@ -396,8 +399,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-oauth2-client:${spring_boot_version}" - implementation "org.springframework.ldap:spring-ldap-core:3.2.6" - implementation "org.springframework.data:spring-data-ldap:3.3.4" + implementation "org.springframework.ldap:spring-ldap-core:3.2.7" + implementation "org.springframework.data:spring-data-ldap:3.3.5" implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:4.1.3") { // NOTE: these modules contain security vulnerabilities and are not needed @@ -408,9 +411,9 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-commons:4.1.4" implementation "io.netty:netty-all:4.1.114.Final" - implementation "io.projectreactor.netty:reactor-netty:1.1.22" - implementation "org.springframework:spring-messaging:6.1.13" - implementation "org.springframework.retry:spring-retry:2.0.9" + 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.10" implementation "org.springframework.security:spring-security-config:${spring_security_version}" implementation "org.springframework.security:spring-security-data:${spring_security_version}" @@ -439,7 +442,7 @@ dependencies { implementation "org.bouncycastle:bcpkix-jdk18on:1.78.1" implementation "org.bouncycastle:bcprov-jdk18on:1.78.1" - implementation "com.mysql:mysql-connector-j:9.0.0" + implementation "com.mysql:mysql-connector-j:9.1.0" implementation "org.postgresql:postgresql:42.7.4" implementation "org.zalando:problem-spring-web:0.29.1" @@ -447,10 +450,9 @@ dependencies { implementation "com.ibm.icu:icu4j-charset:75.1" implementation "com.github.seancfoley:ipaddress:5.5.1" implementation "org.apache.maven:maven-model:3.9.9" - // NOTE: 3.0.2 is broken for splitting lecture specific PDFs - implementation "org.apache.pdfbox:pdfbox:3.0.1" + 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" @@ -466,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") { @@ -534,7 +536,7 @@ dependencies { testImplementation "org.awaitility:awaitility:4.2.2" testImplementation "org.apache.maven.shared:maven-invoker:3.3.0" testImplementation "org.gradle:gradle-tooling-api:8.10.2" - testImplementation "org.apache.maven.surefire:surefire-report-parser:3.5.0" + testImplementation "org.apache.maven.surefire:surefire-report-parser:3.5.1" testImplementation "com.opencsv:opencsv:5.9" testImplementation("io.zonky.test:embedded-database-spring-test:2.5.1") { exclude group: "org.testcontainers", module: "mariadb" 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/dev/guidelines/database.rst b/docs/dev/guidelines/database.rst index 65365549f10a..ea4e436b53d4 100644 --- a/docs/dev/guidelines/database.rst +++ b/docs/dev/guidelines/database.rst @@ -295,7 +295,7 @@ Best Practices // IrisSubSettings.java @Column(name = "allowed_models") @Convert(converter = IrisModelListConverter.class) - private TreeSet allowedModels = new TreeSet<>(); + private TreeSet allowedVariants = new TreeSet<>(); * **Ordered Collection with duplicates**: When you want to order the collection of (potentially duplicated) objects of the relationship, then always use a ``List``. It is important to note here that there is no inherent order in a database table. One could argue that you can use the ``id`` field for the ordering, but there are edge cases where this can lead to problems. Therefore, for an ordered collection with duplicates, **always** annotate it with ``@OrderColumn``. An order column indicates to Hibernate that we want to order our collection based on a specific column of our data table. By default, the column name it expects is *tablenameS\_order*. For ordered collections, we also recommend that you annotate them with ``cascade = CascadeType.ALL`` and ``orphanRemoval = true``. E.g.: diff --git a/docs/index.rst b/docs/index.rst index 32c130434e4f..f4a7bc4cf449 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,6 +37,7 @@ All these exercises are supposed to be run either live in the lecture with insta user/grading user/courses/customizable user/markdown-support + user/integrated-code-lifecycle user/exports user/mobile-applications user/lti diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index 7bccf1596315..19eb1e02a680 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -37,6 +37,12 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | JavaScript | yes | yes | +----------------------+----------+---------+ + | R | yes | yes | + +----------------------+----------+---------+ + | C++ | yes | yes | + +----------------------+----------+---------+ + | TypeScript | yes | yes | + +----------------------+----------+---------+ - Not all ``templates`` support the same feature set and supported features can also change depending on the continuous integration system setup. Depending on the feature set, some options might not be available during the creation of the programming exercise. @@ -71,6 +77,12 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ | JavaScript | no | no | yes | no | n/a | no | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ + | R | no | no | yes | no | n/a | no | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ + | C++ | no | no | yes | no | n/a | no | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ + | TypeScript | no | no | yes | no | n/a | no | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ - *Sequential Test Runs*: ``Artemis`` can generate a build plan which first executes structural and then behavioral tests. This feature can help students to better concentrate on the immediate challenge at hand. - *Static Code Analysis*: ``Artemis`` can generate a build plan which additionally executes static code analysis tools. diff --git a/docs/user/exercises/programming-exercise-setup.inc b/docs/user/exercises/programming-exercise-setup.inc index 08d03e9f6290..0d1adbff6297 100644 --- a/docs/user/exercises/programming-exercise-setup.inc +++ b/docs/user/exercises/programming-exercise-setup.inc @@ -351,6 +351,8 @@ Update exercise code in repositories - In case of a |build_failed| result, some configuration is wrong, please check the build errors on the corresponding build plan. - **Hints:** Test cases should only reference code, that is available in the template repository. In case this is **not** possible, please try out the option **Sequential Test Runs** +.. _adapt_build_script: + Adapt the build script ^^^^^^^^^^^^^^^^^^^^^^ @@ -360,8 +362,7 @@ You can activate the option `Customize Build Script` in the programming exercise All changes in the configuration will be considered for all builds (template, solution, student submissions). There are predefined build scripts in bash for all programming languages, project types and configurations (e.g. with or without static code analysis). -Notice that the checkout paths for the test and the assignment (template, solution or student) repo cannot be customized at the moment and are determined -by the chosen programming language. Most programming languages clone the test repos into the root folder and the assignment repo into the `assignment` folder. +Most programming languages clone the test repos into the root folder and the assignment repo into the `assignment` folder. This means that build files in the test repo (e.g. Gradle, Maven) typically refer to the `assignment` folder. You can also use a custom docker image for the build. Make sure to publish the docker image in a publicly available repository (e.g. DockerHub). Ideally build it @@ -372,8 +373,45 @@ The default Java Docker image can be found on https://github.com/ls1intum/artemi Hint: Try out the build of a custom programming exercise locally before you publish a custom docker image and before you upload the code to Artemis, because the development and debugging experience is much better. +Edit Repositories Checkout Paths +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**This option is only available when using** :ref:`integrated code lifecycle` + +This section is optional. In most cases, the preconfigured checkout paths do not need to be changed. +The checkout paths depend on the chosen programming language and project type. The paths are shown in the preview: + +.. figure:: programming/checkout-paths-preview.png + :align: center + +By checkout paths, we mean the paths where the repositories are cloned during the build process. For example, in Java exercises, the assignment repository is cloned into the `assignment` folder, the test repository is cloned into the root folder. All paths are relative to the working directory of the build plan. +If you want to change the checkout paths, you can do so by clicking on the `edit repositories checkout path` button. The following dialog will open: + +.. figure:: programming/checkout-paths-edit.png + :align: center + +You must then change the paths in the build script if necessary. Please refer to the :ref:`adapt_build_script` section on how to do this. + +.. warning:: + - Changing the checkout paths can only be done in the exercise creation process. After the exercise has been created, the checkout paths cannot be changed. + - Depending on the programming language and project type, the checkout paths are predefined and cannot be changed. For example, for Java exercises, only the assignment repository path can be changed. For Ocaml exercises, the assignment, test, and solution repository paths can be changed. + - Changing the checkout paths can lead to build errors if the build script is not adapted accordingly. + - For C programming exercises, if used with the default docker image, changing the checkout paths will lead to build errors. The default docker image is configured to work with the default checkout paths. + .. _configure_static_code_analysis_tools: +Edit Maximum Build Duration +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**This option is only available when using** :ref:`integrated code lifecycle` +This section is optional. In most cases, the preconfigured build script does not need to be changed. + +The maximum build duration is the time limit for the build plan to execute. If the build plan exceeds this time limit, it will be terminated. The default value is 120 seconds. +You can change the maximum build duration by using the slider. + +.. figure:: programming/timeout-slider.png + :align: center + Configure static code analysis ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/user/exercises/programming-repository-access.inc b/docs/user/exercises/programming-repository-access.inc index 45323fced7dd..c4e201d0f4b6 100644 --- a/docs/user/exercises/programming-repository-access.inc +++ b/docs/user/exercises/programming-repository-access.inc @@ -1,3 +1,5 @@ +If you are a student, and want to know how to use the Artemis Version Control for checking out repositories locally, :ref:`checkout this guide.` + The following tables lists the different types of repositories and gives an overview of the access rights different users have. To gain these access rights, a user must assume the indicated role in the course the repository belongs to. diff --git a/docs/user/exercises/programming.rst b/docs/user/exercises/programming.rst index 6f8a8e00782c..61f1731c4dff 100644 --- a/docs/user/exercises/programming.rst +++ b/docs/user/exercises/programming.rst @@ -224,19 +224,5 @@ Each one represents a single test case feedback and should have the format: Integrated Code Lifecycle ------------------------- -The following sections describe programming exercise features that are part of the integrated code lifecycle system. - -Repository View -^^^^^^^^^^^^^^^ - -.. include:: programming-integrated-code-lifecycle-repository-view.inc - -Build Agent View -^^^^^^^^^^^^^^^^ - -.. include:: programming-integrated-code-lifecycle-build-agent-view.inc - -Build Overview View -^^^^^^^^^^^^^^^^^^^ - -.. include:: programming-integrated-code-lifecycle-build-queue-view.inc +The Artemis Integrated Code Lifecycle system allows you to use programming exercises fully integrated within Artemis, without the need of any external tools. +Find more information on it :ref:`here`. diff --git a/docs/user/exercises/programming/build-agent-details.png b/docs/user/exercises/programming/build-agent-details.png deleted file mode 100644 index ae9d8f2561f4..000000000000 Binary files a/docs/user/exercises/programming/build-agent-details.png and /dev/null differ diff --git a/docs/user/exercises/programming/build-agent-summary.png b/docs/user/exercises/programming/build-agent-summary.png deleted file mode 100644 index 414451407f71..000000000000 Binary files a/docs/user/exercises/programming/build-agent-summary.png and /dev/null differ diff --git a/docs/user/exercises/programming/buildQueueCourseManagement.png b/docs/user/exercises/programming/buildQueueCourseManagement.png deleted file mode 100644 index dc16f6dd6a32..000000000000 Binary files a/docs/user/exercises/programming/buildQueueCourseManagement.png and /dev/null differ diff --git a/docs/user/exercises/programming/buildQueueSystemAdministration.png b/docs/user/exercises/programming/buildQueueSystemAdministration.png deleted file mode 100644 index 1a18244e443a..000000000000 Binary files a/docs/user/exercises/programming/buildQueueSystemAdministration.png and /dev/null differ diff --git a/docs/user/exercises/programming/checkout-paths-edit.png b/docs/user/exercises/programming/checkout-paths-edit.png new file mode 100644 index 000000000000..ef4e3244cb31 Binary files /dev/null and b/docs/user/exercises/programming/checkout-paths-edit.png differ diff --git a/docs/user/exercises/programming/checkout-paths-preview.png b/docs/user/exercises/programming/checkout-paths-preview.png new file mode 100644 index 000000000000..611892d7d3cc Binary files /dev/null and b/docs/user/exercises/programming/checkout-paths-preview.png differ diff --git a/docs/user/exercises/programming/commit-diff-view.png b/docs/user/exercises/programming/commit-diff-view.png deleted file mode 100644 index cfd5cda349cc..000000000000 Binary files a/docs/user/exercises/programming/commit-diff-view.png and /dev/null differ diff --git a/docs/user/exercises/programming/commit-history-view.png b/docs/user/exercises/programming/commit-history-view.png deleted file mode 100644 index ac691c9c3a60..000000000000 Binary files a/docs/user/exercises/programming/commit-history-view.png and /dev/null differ diff --git a/docs/user/exercises/programming/course-management-repositories.png b/docs/user/exercises/programming/course-management-repositories.png deleted file mode 100644 index 47e842da25a5..000000000000 Binary files a/docs/user/exercises/programming/course-management-repositories.png and /dev/null differ diff --git a/docs/user/exercises/programming/current-repository-content-view.png b/docs/user/exercises/programming/current-repository-content-view.png deleted file mode 100644 index 041b6411f6a1..000000000000 Binary files a/docs/user/exercises/programming/current-repository-content-view.png and /dev/null differ diff --git a/docs/user/exercises/programming/finished-build-jobs.png b/docs/user/exercises/programming/finished-build-jobs.png deleted file mode 100644 index 149a7f98819b..000000000000 Binary files a/docs/user/exercises/programming/finished-build-jobs.png and /dev/null differ diff --git a/docs/user/exercises/programming/open-repository-button.png b/docs/user/exercises/programming/open-repository-button.png deleted file mode 100644 index a758454dead7..000000000000 Binary files a/docs/user/exercises/programming/open-repository-button.png and /dev/null differ diff --git a/docs/user/exercises/programming/open-repository-instructor-participations.png b/docs/user/exercises/programming/open-repository-instructor-participations.png deleted file mode 100644 index 03e9e66e3910..000000000000 Binary files a/docs/user/exercises/programming/open-repository-instructor-participations.png and /dev/null differ diff --git a/docs/user/exercises/programming/open-repository-student.png b/docs/user/exercises/programming/open-repository-student.png deleted file mode 100644 index 5685e134d177..000000000000 Binary files a/docs/user/exercises/programming/open-repository-student.png and /dev/null differ diff --git a/docs/user/exercises/programming/queued-build-jobs.png b/docs/user/exercises/programming/queued-build-jobs.png deleted file mode 100644 index eb7165c01436..000000000000 Binary files a/docs/user/exercises/programming/queued-build-jobs.png and /dev/null differ diff --git a/docs/user/exercises/programming/running-build-jobs.png b/docs/user/exercises/programming/running-build-jobs.png deleted file mode 100644 index 08ccfa8856fd..000000000000 Binary files a/docs/user/exercises/programming/running-build-jobs.png and /dev/null differ diff --git a/docs/user/exercises/programming/timeout-slider.png b/docs/user/exercises/programming/timeout-slider.png new file mode 100644 index 000000000000..c502166c9e5e Binary files /dev/null and b/docs/user/exercises/programming/timeout-slider.png differ diff --git a/docs/user/icl/general.rst b/docs/user/icl/general.rst new file mode 100644 index 000000000000..bf0132e6d20e --- /dev/null +++ b/docs/user/icl/general.rst @@ -0,0 +1,12 @@ +.. _icl-general-information: + +General Information +=================== + +Artemis offers the Integrated Code Lifecycle (ICL), a comprehensive feature designed to streamline the development process for programming exercises. +ICL combines version control, secure communication, and continuous integration to provide a seamless experience for students and instructors. +Understanding these components is crucial for effectively using Artemis, especially if you're new to concepts like SSH and Git. The key components of ICL are: + +- :ref:`Local Version Control`: Use the Local Version Control to interact with the repositories of programming exercises. +- :ref:`SSH`: Use SSH to perform Git operations on repositories. +- :ref:`Local Continuous Integration`: Exercise submissions are built and tested by the Local CI system. diff --git a/docs/user/exercises/programming-integrated-code-lifecycle-build-agent-view.inc b/docs/user/icl/local-ci-build-agent-view.inc similarity index 95% rename from docs/user/exercises/programming-integrated-code-lifecycle-build-agent-view.inc rename to docs/user/icl/local-ci-build-agent-view.inc index a59068be8589..b211e879b252 100644 --- a/docs/user/exercises/programming-integrated-code-lifecycle-build-agent-view.inc +++ b/docs/user/icl/local-ci-build-agent-view.inc @@ -5,7 +5,7 @@ The build agent view consists of two parts: the *Build Agent Summary View* and t The **Build Agent Summary View** shows a list of all build agents in the system, along with their status (idle and running), maximum number of concurrent builds, and running builds. The running builds can be canceled individually by clicking the red cancellation button next to the build. An Administrator can also cancel all running builds on a build agent by clicking the '*Cancel All*' button. -.. figure:: programming/build-agent-summary.png +.. figure:: local-ci/build-agent-summary.png :alt: Build Agent Summary View :align: center @@ -20,7 +20,7 @@ the submission time, start time, end time, the duration of the build job, the co The administrator can click on the participation ID to navigate to the participation's submission page. The administrator can also click on the commit hash to navigate to the commits details page and the course ID to navigate to the course management page. -.. figure:: programming/build-agent-details.png +.. figure:: local-ci/build-agent-details.png :alt: Build Agent Details :align: center diff --git a/docs/user/exercises/programming-integrated-code-lifecycle-build-queue-view.inc b/docs/user/icl/local-ci-build-queue-view.inc similarity index 90% rename from docs/user/exercises/programming-integrated-code-lifecycle-build-queue-view.inc rename to docs/user/icl/local-ci-build-queue-view.inc index 2c2470d9561a..7c865758f98c 100644 --- a/docs/user/exercises/programming-integrated-code-lifecycle-build-queue-view.inc +++ b/docs/user/icl/local-ci-build-queue-view.inc @@ -1,7 +1,7 @@ Artemis provides a build overview view that displays all queued, running, and finished build jobs. Access to the system-wide build overview is exclusively available to **Administrators** through the *System Administration* menu. -.. figure:: programming/buildQueueSystemAdministration.png +.. figure:: local-ci/buildQueueSystemAdministration.png :alt: Build Overview System Administration :align: center @@ -10,7 +10,7 @@ exclusively available to **Administrators** through the *System Administration* **Instructors** can access the build overview for a specific course through the *Build Overview* button located in *Course Management*. This view displays only the queued, running, and finished build jobs associated with the selected course. -.. figure:: programming/buildQueueCourseManagement.png +.. figure:: local-ci/buildQueueCourseManagement.png :alt: Build Overview Course Management :align: center @@ -27,7 +27,7 @@ Jobs are dynamically added to and removed from the queue in real-time. Users have the ability to cancel any job in progress. The table provides the following information: -.. figure:: programming/running-build-jobs.png +.. figure:: local-ci/running-build-jobs.png :alt: Running Build Jobs :align: center @@ -43,7 +43,7 @@ Users also have the option to cancel any job that is queued. The table below displays the following information: -.. figure:: programming/queued-build-jobs.png +.. figure:: local-ci/queued-build-jobs.png :alt: Queued Build Jobs :align: center @@ -59,7 +59,7 @@ Instructors can also access build logs with detailed information about the build The table provides the following information: -.. figure:: programming/finished-build-jobs.png +.. figure:: local-ci/finished-build-jobs.png :alt: Finished Build Jobs :align: center diff --git a/docs/user/icl/local-ci/build-agent-details.png b/docs/user/icl/local-ci/build-agent-details.png new file mode 100644 index 000000000000..4b5b8474781a Binary files /dev/null and b/docs/user/icl/local-ci/build-agent-details.png differ diff --git a/docs/user/icl/local-ci/build-agent-summary.png b/docs/user/icl/local-ci/build-agent-summary.png new file mode 100644 index 000000000000..ec11e28a0d1d Binary files /dev/null and b/docs/user/icl/local-ci/build-agent-summary.png differ diff --git a/docs/user/icl/local-ci/buildQueueCourseManagement.png b/docs/user/icl/local-ci/buildQueueCourseManagement.png new file mode 100644 index 000000000000..d4e255f39988 Binary files /dev/null and b/docs/user/icl/local-ci/buildQueueCourseManagement.png differ diff --git a/docs/user/icl/local-ci/buildQueueSystemAdministration.png b/docs/user/icl/local-ci/buildQueueSystemAdministration.png new file mode 100644 index 000000000000..9a857a317ce8 Binary files /dev/null and b/docs/user/icl/local-ci/buildQueueSystemAdministration.png differ diff --git a/docs/user/icl/local-ci/finished-build-jobs.png b/docs/user/icl/local-ci/finished-build-jobs.png new file mode 100644 index 000000000000..eb7b3dda190d Binary files /dev/null and b/docs/user/icl/local-ci/finished-build-jobs.png differ diff --git a/docs/user/icl/local-ci/queued-build-jobs.png b/docs/user/icl/local-ci/queued-build-jobs.png new file mode 100644 index 000000000000..acd382dfe1f8 Binary files /dev/null and b/docs/user/icl/local-ci/queued-build-jobs.png differ diff --git a/docs/user/icl/local-ci/running-build-jobs.png b/docs/user/icl/local-ci/running-build-jobs.png new file mode 100644 index 000000000000..8cc66a2e5c63 Binary files /dev/null and b/docs/user/icl/local-ci/running-build-jobs.png differ diff --git a/docs/user/icl/local-continuous-integration.rst b/docs/user/icl/local-continuous-integration.rst new file mode 100644 index 000000000000..6c3cdd9b8e65 --- /dev/null +++ b/docs/user/icl/local-continuous-integration.rst @@ -0,0 +1,27 @@ +.. _local-ci: + +Continuous Integration +====================== + +Continuous Integration (CI) is a software development practice where developers frequently merge their code changes into a shared repository. Each change is automatically built and tested, which helps to: + +- Detect and address integration issues early +- Ensure code quality and consistency +- Streamline the development process + +Artemis Local CI is our implementation of these CI principles, tailored to support the Artemis learning platform. +It provides tools and views to help users build and test in programming exercises effectively. + +This document will introduce you to the key components of Artemis Local CI: the Build Agent View and the Build Overview View. +These tools will assist you throughout your development workflow on the Artemis platform. + +Build Agent View +^^^^^^^^^^^^^^^^ + +.. include:: local-ci-build-agent-view.inc + + +Build Overview View +^^^^^^^^^^^^^^^^^^^ + +.. include:: local-ci-build-queue-view.inc diff --git a/docs/user/icl/local-vc-authentication.inc b/docs/user/icl/local-vc-authentication.inc new file mode 100644 index 000000000000..6735cd2743df --- /dev/null +++ b/docs/user/icl/local-vc-authentication.inc @@ -0,0 +1,67 @@ +Cloning a repository +^^^^^^^^^^^^^^^^^^^^ + +You can use Sourcetree, git from the terminal, or any client you like to clone your Git repository. +These instructions show you how to clone your repository using Git from the terminal. + +From the exercise view, click the code button to display the Clone dialog. +Copy the clone URL (either the SSH format or the HTTPS, with or without token). +If you are using the SSH protocol, ensure your public key is stored in your Artemis account settings and loaded on the local system to which you are cloning. +From a terminal window, change to the local directory where you want to clone your repository. + +Paste the command you copied from Bitbucket, for example: + +Clone over HTTPS: + +.. code-block:: bash + + git clone https://username@artemis.cit.tum.de/course/documentation-tests.git + + +Clone over HTTPS with access token: + +.. code-block:: bash + + git clone https://username:accessToken@artemis.cit.tum.de/course/documentation-tests.git + + +Clone over SSH: + +.. code-block:: bash + + git clone ssh://git@artemis.cit.tum.de/course/documentation-tests.git + + +If the clone was successful, a new sub-directory appears on your local drive. +This directory has the same name as the repository that you cloned. +The clone contains the files and metadata that Git requires to maintain the changes you make to the source files. + +Choosing between HTTPS and SSH: + +- HTTPS: Easier to set up initially, works through firewalls, but requires entering credentials more frequently. +- SSH: More secure, doesn't require entering passwords for each operation once set up, but initial setup can be more complex. + +Choose HTTPS if you're new to Git or working in an environment with strict firewall rules. +Choose SSH for enhanced security and convenience in long-term development. + +HTTPS access tokens +^^^^^^^^^^^^^^^^^^^ + +Instructors can create HTTP access tokens for repository access in Artemis. +They are created in the account settings and are used in place of passwords for Git over HTTPS. +For every student's exercise, Artemis automatically generates an access token, only associated with the repository of this particular exercise. +You can use these to authenticate to the Artemis Local Version Control. + +Token Creation +"""""""""""""" + +1. Go to Profile > Settings > VCS token. +2. Create a new token + +Using SSH keys to secure Git operations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Artemis provides a simple way for users to connect securely to repositories, using SSH to perform Git operations. +Next is a :ref:`small and basic introduction to SSH`, and if you already know it, :ref:`here is a guide on how to create SSH keys`. +If you already have an SSH key, :ref:`learn how to add it to your Artemis account here`. + diff --git a/docs/user/exercises/programming-integrated-code-lifecycle-repository-view.inc b/docs/user/icl/local-vc-repository-view.inc similarity index 88% rename from docs/user/exercises/programming-integrated-code-lifecycle-repository-view.inc rename to docs/user/icl/local-vc-repository-view.inc index 5273befacd08..aef46936e99a 100644 --- a/docs/user/exercises/programming-integrated-code-lifecycle-repository-view.inc +++ b/docs/user/icl/local-vc-repository-view.inc @@ -3,13 +3,13 @@ These changes are stored in a repository. A repository is a collection of files How to navigate through the repository and view the code is explained in the following sections. This guide is divided into two parts: one for students and one for instructors. It is demonstrated using course programming exercises, but the same principles apply to exams as well. -For Students -"""""""""""" +Access Repositories as a Student +"""""""""""""""""""""""""""""""" As a student, you can use the repository to view your course submissions. To access your submissions, you need to navigate to the exercise, press the '*Code*' button, and click on |open-repository-button|. -.. figure:: programming/open-repository-student.png +.. figure:: local-vc/open-repository-student.png :alt: Opening the repository in the student view :align: center @@ -22,7 +22,7 @@ To the right side of the screen, above the problem statement, you can see the re the '*Code*' button to clone the repository to your local machine and see the full commit history by pressing the '*Open Commit History*' button. Finally, you can download the repository as a ZIP file by pressing the '*Download Repository*' button. -.. figure:: programming/current-repository-content-view.png +.. figure:: local-vc/current-repository-content-view.png :alt: Current Repository Content :align: center @@ -33,7 +33,7 @@ You can see the commit message, the author of the commit, the date of the commit If you created a submission with multiple commits, you can see all of them here but only the last commit has the result of the submission. Lastly, you can also view the code of the commit by pressing on the commit hash. -.. figure:: programming/commit-history-view.png +.. figure:: local-vc/commit-history-view.png :alt: Commit History View :align: center @@ -44,20 +44,20 @@ You can see the changes in the code, the files that have been added, the files t Green color indicates the lines that have been added and red color indicates the lines that have been deleted. You can also see the commit message, the author of the commit, the date of the commit and the commit hash. -.. figure:: programming/commit-diff-view.png +.. figure:: local-vc/commit-diff-view.png :alt: Commit Diff View :align: center Commit Diff View -For Instructors -""""""""""""""" +Access Repositories as an Instructor +"""""""""""""""""""""""""""""""""""" As an instructor, you can use the repository to view the submissions of your students. For this purpose, you need to navigate to the exercise participations page. Here you can see all the students’ participations for the exercise and navigate to the repository of a student by pressing the '*Code*' button and clicking on |open-repository-button|. This will show the repository of the student just like it is shown to the student. -.. figure:: programming/open-repository-instructor-participations.png +.. figure:: local-vc/open-repository-instructor-participations.png :alt: Open Repository Button in Participations Page :align: center @@ -66,11 +66,11 @@ This will show the repository of the student just like it is shown to the studen You can also see the repositories for an exercises **solution**, **template** and **test** repositories on the exercise management page. You can navigate to these repositories by pressing the '*Code*' button and clicking |open-repository-button| as described above. -.. figure:: programming/course-management-repositories.png +.. figure:: local-vc/course-management-repositories.png :alt: Solution, Template and Test Repositories in Exercise Management Page :align: center Solution, Template and Test Repositories in Exercise Management Page -.. |open-repository-button| image:: programming/open-repository-button.png +.. |open-repository-button| image:: local-vc/open-repository-button.png :scale: 50% diff --git a/docs/user/icl/local-vc/commit-diff-view.png b/docs/user/icl/local-vc/commit-diff-view.png new file mode 100644 index 000000000000..19ebb58be3ab Binary files /dev/null and b/docs/user/icl/local-vc/commit-diff-view.png differ diff --git a/docs/user/icl/local-vc/commit-history-view.png b/docs/user/icl/local-vc/commit-history-view.png new file mode 100644 index 000000000000..2dd208d0de16 Binary files /dev/null and b/docs/user/icl/local-vc/commit-history-view.png differ diff --git a/docs/user/icl/local-vc/course-management-repositories.png b/docs/user/icl/local-vc/course-management-repositories.png new file mode 100644 index 000000000000..ab0af10e9103 Binary files /dev/null and b/docs/user/icl/local-vc/course-management-repositories.png differ diff --git a/docs/user/icl/local-vc/current-repository-content-view.png b/docs/user/icl/local-vc/current-repository-content-view.png new file mode 100644 index 000000000000..961e9f17889b Binary files /dev/null and b/docs/user/icl/local-vc/current-repository-content-view.png differ diff --git a/docs/user/icl/local-vc/open-repository-button.png b/docs/user/icl/local-vc/open-repository-button.png new file mode 100644 index 000000000000..b1b4d60873cd Binary files /dev/null and b/docs/user/icl/local-vc/open-repository-button.png differ diff --git a/docs/user/icl/local-vc/open-repository-instructor-participations.png b/docs/user/icl/local-vc/open-repository-instructor-participations.png new file mode 100644 index 000000000000..bf051e6d644d Binary files /dev/null and b/docs/user/icl/local-vc/open-repository-instructor-participations.png differ diff --git a/docs/user/icl/local-vc/open-repository-student.png b/docs/user/icl/local-vc/open-repository-student.png new file mode 100644 index 000000000000..93312d1cab27 Binary files /dev/null and b/docs/user/icl/local-vc/open-repository-student.png differ diff --git a/docs/user/icl/local-vc/open-settings.png b/docs/user/icl/local-vc/open-settings.png new file mode 100644 index 000000000000..84c13407ab74 Binary files /dev/null and b/docs/user/icl/local-vc/open-settings.png differ diff --git a/docs/user/icl/local-vc/ssh-add-public-key.png b/docs/user/icl/local-vc/ssh-add-public-key.png new file mode 100644 index 000000000000..3e4683c09b7e Binary files /dev/null and b/docs/user/icl/local-vc/ssh-add-public-key.png differ diff --git a/docs/user/icl/local-version-control.rst b/docs/user/icl/local-version-control.rst new file mode 100644 index 000000000000..1ca1c1c2c31c --- /dev/null +++ b/docs/user/icl/local-version-control.rst @@ -0,0 +1,16 @@ +.. _local-vc: + +Local Version Control +===================== + +.. contents:: Content of this document + :local: + :depth: 2 + + +Repository View +^^^^^^^^^^^^^^^ + +.. include:: local-vc-repository-view.inc + +.. include:: local-vc-authentication.inc diff --git a/docs/user/icl/ssh-add-key-to-artemis.rst b/docs/user/icl/ssh-add-key-to-artemis.rst new file mode 100644 index 000000000000..0e3425faa501 --- /dev/null +++ b/docs/user/icl/ssh-add-key-to-artemis.rst @@ -0,0 +1,68 @@ +.. _use ssh key: + +Using SSH with Artemis +^^^^^^^^^^^^^^^^^^^^^^ + +.. contents:: Content of this document + :local: + :depth: 1 + +You can use SSH keys to establish a secure connection between your computer and Artemis when you are performing Git operations (pull, clone, push) from your local machine. +Personal keys are linked to your Artemis account, inheriting its permissions and operating under its unique identity. +To use your generated SSH keys with Artemis, you need to add it in the account settings. + + +Add an SSH key to your Artemis account +"""""""""""""""""""""""""""""""""""""" + +**1. Copy your public key** + +On Windows in your command prompt, change directory to your `.ssh` directory, and copy the public key file to your clipboard by running: + +.. code-block:: bash + + cd %userprofile%/.ssh + clip < id_ed25519.pub + +On macOS or Linux simply run the following in a terminal: + +.. _xclip: https://wiki.ubuntuusers.de/xclip/ + +.. code-block:: bash + + pbcopy < ~/.ssh/id_ed25519.pub + +If `pbcopy` isn't working, locate the hidden `.ssh` folder, open the file in a text editor, and copy it to your clipboard. +Note that on Linux, you may need to download and install `xclip`_, then use that, as shown in this code snippet: + +.. code-block:: bash + + sudo apt-get install xclip + xclip -sel clip < ~/.ssh/id_ed25519.pub + +Note that the key's name is not necessarily **id_ed25519.pub**, but can be arbitrary, and depends on how you saved it. + + +**2. Add the key to your Artemis account** + +Open the settings, go to the SSH tab, and select 'New Key'. +Then paste the copied SSH key into the text box. + ++---------------------------------------------------+--------------------------------------------------------------+ +|.. figure:: local-vc/open-settings.png | .. figure:: local-vc/ssh-add-public-key.png | +| :alt: Open account settings | :alt: Add public SSH key to account | +| :align: center | :align: center | +| | | +| Open you Artemis account settings | Add public SSH key to account in account settings | ++---------------------------------------------------+--------------------------------------------------------------+ + +**3. Save the key. You're done!** + +Use SSH to connect to Artemis repositories +"""""""""""""""""""""""""""""""""""""""""" + +After everything is set up, you can go to a programming exercise, and use the SSH clone URL with git to access the repository locally, like this, for example: + +.. code-block:: bash + + git clone ssh://git@artemis.cit.tum.de:7921/git/COURSE/exercise-user_1.git diff --git a/docs/user/icl/ssh-intro.rst b/docs/user/icl/ssh-intro.rst new file mode 100644 index 000000000000..fd41034ebe5d --- /dev/null +++ b/docs/user/icl/ssh-intro.rst @@ -0,0 +1,67 @@ +.. _basic SSH introduction: + +SSH +^^^ + +.. contents:: Content of this document + :local: + :depth: 2 + +Artemis uses SSH as a simple way for users to connect securely to repositories to perform Git operations. + +What is SSH? +"""""""""""" + +.. _SSH (Secure Shell): https://en.wikipedia.org/wiki/Secure_Shell + +`SSH (Secure Shell)`_ is a protocol that allows you to securely connect to another computer over a network. +It’s mostly used by system administrators, developers, and IT professionals to remotely manage servers or computers. +SSH provides a secure and encrypted communication channel between your computer and a remote machine, so any data passed (like passwords or commands) is protected from eavesdropping. +In Artemis you an use SSH to access your repositories with Git. + +Why use SSH? +"""""""""""" + +The main advantage of SSH is security. +When you connect to a remote machine using SSH, all the data exchanged between your computer and the server is encrypted. +This means if someone tries to intercept the communication, they can't read it. It's like sending messages through a locked box that only you and the server can open. + +How does SSH work? +"""""""""""""""""" + +SSH works by using two components: + +- Client: The computer you are using to connect. +- Server: The machine you want to connect to. + +When you want to connect, your SSH client sends a request to the server. +If the connection is successful, you can log in to the server and start working as if you were sitting in front of it. +The connection uses SSH keys for authentication. Although it is also possible to use username and password to connect over SSH, this is discouraged. + +What are SSH Keys? +"""""""""""""""""" + +.. _public-key cryptography: https://en.wikipedia.org/wiki/Public-key_cryptography + + +SSH keys are a more secure alternative to passwords for logging into a server. +They are based on `public-key cryptography`_ and come in pairs: a public key and a private key. + +- Public Key: This key is stored on the server. Think of it like a lock that only you can open. +- Private Key: This key stays on your local machine (never shared!). It’s like the key to that lock. + +When you try to connect to the server, your computer proves it has the private key that matches the server's public key, granting you access. +You can add a personal SSH key to your user account to easily authenticate when performing read operations from your local machine. +An Artemis user can currently add one key to their account. +For instructions on how to add your SSH key to your Artemis account, please refer to :ref:`the relevant documentation`. + +Before you can use SSH keys to secure a connection with Artemis the following must have already been done: + +- SSH is enabled on your university's Artemis instance +- You need an SSH key! See :ref:`Creating SSH keys`. + +.. note:: + + - You can use the same SSH key for multiple repositories or projects. + - An Artemis user can currently only add one key to their account. + - Artemis supports ECDSA, RSA2, and Ed25519 key types. diff --git a/docs/user/icl/ssh-key-creation.rst b/docs/user/icl/ssh-key-creation.rst new file mode 100644 index 000000000000..58e7b01f5a4d --- /dev/null +++ b/docs/user/icl/ssh-key-creation.rst @@ -0,0 +1,161 @@ +.. _create ssh key: + +Creating SSH keys +^^^^^^^^^^^^^^^^^ + +.. contents:: Content of this document + :local: + :depth: 2 + +SSH keys can be used to establish a secure connection with the Artemis Local Version Control, where you are performing Git operations from your local machine. +The SSH key needs to be added to Artemis before you can make use of the key. + +Creating an SSH key on Windows +"""""""""""""""""""""""""""""" + +**1. Check for existing keys** + +You should check for existing SSH keys on your local computer. Open a command prompt, and run: + +.. code-block:: bash + + cd %userprofile%/.ssh + +- If you see "No such file or directory", then there aren't any existing keys: go to step 3. + +- Check to see if you have a key already: + +.. code-block:: bash + + dir id_* + +If there are existing keys, you may want to use those: :ref:`Add your key to Artemis`. + +**2. Back up old SSH keys** + +If you have existing SSH keys, but you don't want to use them when connecting to Bitbucket, you should back those up. +In a command prompt on your local computer, run: + +.. code-block:: bash + + mkdir key_backup + copy * key_backup + +**3. Generate a new SSH key** + +If you don't have an existing SSH key that you wish to use, generate one as follows: +1. Log in to your local computer as an administrator. +2. In a command prompt, run: + +.. _Git (with Git Bash): https://gitforwindows.org/ + + +.. code-block:: bash + + ssh-keygen -t ed25519 -C "your_email@example.com" + +Associating the key with your email address helps you to identify the key later on. +Note that the `ssh-keygen` command is only available if you have already installed `Git (with Git Bash)`_. +You'll see a response similar to this: + +.. code-block:: bash + + C:\Users\artemis>ssh-keygen -t ed25519 -C "your_email@example.com" + Generating public/private ed25519 key pair. + Enter file in which to save the key (/c/Users/artemis/.ssh/id_ed25519): + +3. Just press to accept the default location and file name. If the .ssh directory doesn't exist, the system creates one for you. +4. Enter, and re-enter, a passphrase when prompted. The whole interaction will look similar to this: + +.. code-block:: bash + + C:\Users\artemis>ssh-keygen -t ed25519 -C "your_email@example.com" + Generating public/private ed25519 key pair. + Enter file in which to save the key (/c/Users/artemis/.ssh/id_ed25519): + Created directory '/c/Users/artemis/.ssh'. + Enter passphrase (empty for no passphrase): + Enter same passphrase again: + Your identification has been saved in c/Users/artemis/.ssh/id_ed25519. + Your public key has been saved in c/Users/artemis/.ssh/id_ed25519.pub. + The key fingerprint is: + SHA256:wvaHYeLtY6+DlvV5sFZgDi3abcdefghijklmnopqrstuvw your_email@example.com + +5. You're done and you can now :ref:`add your key to Artemis`. + +Creating an SSH key on Linux & macOS +"""""""""""""""""""""""""""""""""""" + +**1. Check for existing SSH keys** + +You should check for existing SSH keys on your local computer. Open a terminal and run: + +.. code-block:: bash + + cd ~/.ssh + +If you see "No such file or directory, then there aren't any existing keys: go to step 3. +Check to see if you have a key already: + +.. code-block:: bash + + ls id_* + +If there are existing keys, you may want to use those: :ref:`Add your key to Artemis`. + +**2. Back up old SSH keys** + +If you have existing SSH keys, but you don't want to use them when connecting to Bitbucket, you should back those up. +In a command prompt on your local computer, run: + +.. code-block:: bash + + mkdir key_backup + cp * key_backup + +**3. Generate a new SSH key** + +If you don't have an existing SSH key that you wish to use, generate one as follows: + +1. Open a terminal on your local computer and enter the following: + +.. code-block:: bash + + ssh-keygen -t ed25519 -C "your_email@example.com" + +Associating the key with your email address helps you to identify the key later on. You'll see a response similar to this: + +.. code-block:: bash + + artemis@homemac ~ % ssh-keygen -t ed25519 -C artemis@email.com + Generating public/private ed25519 key pair. + Enter file in which to save the key (/Users/artemis/.ssh/id_ed25519): + +2. Just press to accept the default location and file name. If the .ssh directory doesn't exist, the system creates one for you. +3. Enter, and re-enter, a passphrase when prompted. The whole interaction will look similar to this: + +.. code-block:: bash + + artemis@homemac ~ % ssh-keygen -t ed25519 -C artemis@email.com + Generating public/private ed25519 key pair. + Enter file in which to save the key (/Users/artemis/.ssh/id_ed25519): + Enter passphrase (empty for no passphrase): + Enter same passphrase again: + Your identification has been saved in /Users/artemis/.ssh/id_ed25519. + Your public key has been saved in /Users/artemis/.ssh/id_ed25519.pub. + The key fingerprint is: + SHA256:gTVWKbn41z6JgBNu3wYjLC4abcdefghijklmnopqrstuvwxy artemis@email.com + The keys randomart image is: + +--[ED25519 256]--+ + |==+. +o.. | + |.oE. +o.. | + | . ...o | + | .o... | + | oo+S . | + | + ..B = . . | + |.+.+.oo+ * o . | + |o++.o+ . + + | + |B+ o. . . | + +----[SHA256]-----+ + artemis@homemac ~ % + +4. You're done and you can now :ref:`add your key to Artemis`. diff --git a/docs/user/integrated-code-lifecycle.rst b/docs/user/integrated-code-lifecycle.rst new file mode 100644 index 000000000000..9e5ab5231ef0 --- /dev/null +++ b/docs/user/integrated-code-lifecycle.rst @@ -0,0 +1,15 @@ +.. _integrated code lifecycle: + +Integrated Code Lifecycle +========================= + +Artemis' Integrated Code Lifecycle consists of two main components: Local Version Control and Local Continuous Integration. + +.. toctree:: + + icl/general + icl/local-version-control + icl/ssh-intro + icl/ssh-key-creation + icl/ssh-add-key-to-artemis + icl/local-continuous-integration 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 46526dddaf56..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,18 +25,18 @@ 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.14.0 +sentry_version=7.16.0 liquibase_version=4.29.2 docker_java_version=3.4.0 -logback_version=1.5.8 +logback_version=1.5.11 java_parser_version=3.26.2 -byte_buddy_version=1.15.3 +byte_buddy_version=1.15.7 # testing # make sure both versions are compatible junit_version=5.11.0 -junit_platform_version=1.11.1 -mockito_version=5.14.1 +junit_platform_version=1.11.3 +mockito_version=5.14.2 # gradle plugin version diff --git a/jest.config.js b/jest.config.js index 3dab49d4b7e0..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.37, - branches: 73.68, - functions: 81.93, - lines: 87.42, + 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 c72840ee901d..d2e155397135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,43 +1,44 @@ { "name": "artemis", - "version": "7.6.0", + "version": "7.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "7.6.0", + "version": "7.6.5", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "18.2.7", - "@angular/cdk": "18.2.7", - "@angular/common": "18.2.7", - "@angular/compiler": "18.2.7", - "@angular/core": "18.2.7", - "@angular/forms": "18.2.7", - "@angular/localize": "18.2.7", - "@angular/material": "18.2.7", - "@angular/platform-browser": "18.2.7", - "@angular/platform-browser-dynamic": "18.2.7", - "@angular/router": "18.2.7", - "@angular/service-worker": "18.2.7", + "@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.0", + "@fingerprintjs/fingerprintjs": "4.5.1", "@fortawesome/angular-fontawesome": "0.15.0", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", - "@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.33.1", + "@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.51.0", + "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "pdfjs-dist": "4.6.82", - "posthog-js": "1.166.1", + "pdfjs-dist": "4.7.76", + "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.5", + "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.7.0", + "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.7", - "@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.7", - "@angular/compiler-cli": "18.2.7", - "@angular/language-service": "18.2.7", - "@sentry/types": "8.33.1", + "@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.4", - "@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.8.0", - "@typescript-eslint/parser": "8.8.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.79.4", + "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.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.7.tgz", - "integrity": "sha512-kpcgXnepEXcoxDTbqbGj7Hg1WJLWj1HLR3/FKmC5TbpBf1xiLxiqfkQNwz3BbE/W9JWMLdrXr3GI9O3O2gWPLg==", + "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.7", + "@angular-devkit/core": "18.2.10", "rxjs": "7.8.1" }, "engines": { @@ -228,17 +233,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.7.tgz", - "integrity": "sha512-u8PriYdgddK7k+OS/pOFPD1v4Iu5bztUJZXZVcGeXBZFFdnGFFzKmQw9mfcyGvTMJp2ABgBuuJT0YqYgNfAhzw==", + "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.7", - "@angular-devkit/build-webpack": "0.1802.7", - "@angular-devkit/core": "18.2.7", - "@angular/build": "18.2.7", + "@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.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.7.tgz", - "integrity": "sha512-VrtbrhZ+dht3f0GjtfRLRGRN4XHN/W+/bA9DqckdxVS6SydsrCWNHonvEPmOs4jJmGIGXIu6tUBMcWleTao2sg==", + "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.7", + "@angular-devkit/architect": "0.1802.10", "rxjs": "7.8.1" }, "engines": { @@ -402,9 +407,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.7.tgz", - "integrity": "sha512-1ZTi4A6tEC2bkJ/puCIdIPYhesnlCVOMSDJL/lZAd0hC6X22T4pwu0AEvue7mcP5NbXpQDiBaXOZ3MmCA8PwOA==", + "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.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.7.tgz", - "integrity": "sha512-j7198lpkOXMG+Gyfln/5aDgBZV7m4pWMzHFhkO3+w3cbCNUN1TVZW0SyJcF+CYaxANzTbuumfvpsYc/fTeAGLw==", + "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.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.7.tgz", - "integrity": "sha512-5B7qD1K+kKOf9lgJT4VNMft3IK2BnRHjN1S6l38ywzQ/nxpmCG7f+qKAAU6CpCywhNUBeXW0hVXTMuMNPVOcQQ==", + "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.7" + "@angular/core": "18.2.9" } }, "node_modules/@angular/build": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.7.tgz", - "integrity": "sha512-oq6JsVxLP9/w9F2IjKroJwPB9CdlMblu2Xhfq/qQZRSUuM8Ppt1svr2FBTo1HrLIbosqukkVcSSdmKYDneo+cg==", + "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.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.7.tgz", - "integrity": "sha512-Dfl37WBLeEUURQrDeuMcOgX2bkQJ+BGMOlr1qsFXzUWHH+qgYW2YwO1rbna/rjxyeFzc2Sy569dYRzNPqMewzg==", + "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.7", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.7.tgz", - "integrity": "sha512-KoWgSvhRsU05A2m6B7jw1kdpyoS+Ce5GGLW6xcnX7VF2AckW54vYd/8ZkgpzQrKfvIpVblYd4KJGizKoaLZ5jA==", + "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.7", - "@angular-devkit/core": "18.2.7", - "@angular-devkit/schematics": "18.2.7", + "@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.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.7.tgz", - "integrity": "sha512-5vDBmBR2JcIxHVEDunKXNU+T+OvTGiHZTSo35GFOHJxKFgX5g6+0tJBZunK04oBZGbJQUmp3pg2kMvuKKjZnkQ==", + "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.7", + "@angular/core": "18.2.9", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.7.tgz", - "integrity": "sha512-XemlYyRGnu/HrICtXwTPmGtyOrI8BhbGg/HMiJ7sVx40AeEIX0uyDgnu9Gc5OjmtDqZZ8Qftg1sQAxaCVjLb1w==", + "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.7" + "@angular/core": "18.2.9" }, "peerDependenciesMeta": { "@angular/core": { @@ -738,14 +744,14 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.7.tgz", - "integrity": "sha512-U7cveObj+rrXH5EC8egAhATCeAAcOceEQDTVIOWmBa0qMR4hOMjtI2XUS2QRuI1Q+fQZ2hVEOW95WVLvEMsANA==", + "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", "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^3.0.0", + "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", @@ -761,14 +767,42 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.7", + "@angular/compiler": "18.2.9", "typescript": ">=5.4 <5.6" } }, + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/core": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.7.tgz", - "integrity": "sha512-hLOxgxLiyWm9iVHBsUsJfx1hDsXWZnfJBlr+N7cev53f0CDoPfbshqq6KV+JFqXFDguzR9dKHm1ewT1jK3e6Tw==", + "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" @@ -782,9 +816,9 @@ } }, "node_modules/@angular/forms": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.7.tgz", - "integrity": "sha512-WO3c9/OA7ekBnDBgmvi5TlHshOt5S4NREIP+/VVyuRgg28BwUWyO/Nqh19nguE1UNNRt6OMLkT6NSV2ewhcXUg==", + "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" @@ -793,16 +827,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.7", - "@angular/core": "18.2.7", - "@angular/platform-browser": "18.2.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.7.tgz", - "integrity": "sha512-gFsme3y5uC/dQGBBX05VnmT2KAEAZ6gsNk8m1b226LYvh8Oc+JQ4sXv7THGq1x5VnrTzRcCIELbkNHCiFdvL1Q==", + "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": { @@ -810,9 +844,9 @@ } }, "node_modules/@angular/localize": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.7.tgz", - "integrity": "sha512-qYozomhO+1BlvtoMEEgKhaKz8thoztqNZEYPq9RmfkTB5uW7Q8h6rr1Sc2YAzJ6+ZA0McwabdJSX1TDxWyZx0Q==", + "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", @@ -829,21 +863,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.7", - "@angular/compiler-cli": "18.2.7" + "@angular/compiler": "18.2.9", + "@angular/compiler-cli": "18.2.9" } }, "node_modules/@angular/material": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.7.tgz", - "integrity": "sha512-mgPj2TCIrsngmu3iNnoaPc6su7uPv+NPCv9HaiKhTx4QGae8EW+RvUxEZJvh4Qaym1fJTi3hjnVeWvQDLQt4CA==", + "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.7", + "@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", @@ -852,9 +886,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.7.tgz", - "integrity": "sha512-xgj2DH/isFrMZ73dJJm89NRnWBI3AHtugQrZbIapkKBdEt/C1o4SR2W2cV4mPb9o+ELnWurfrxFt9o/q2vnVLw==", + "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" @@ -863,9 +897,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.7", - "@angular/common": "18.2.7", - "@angular/core": "18.2.7" + "@angular/animations": "18.2.9", + "@angular/common": "18.2.9", + "@angular/core": "18.2.9" }, "peerDependenciesMeta": { "@angular/animations": { @@ -874,9 +908,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.7.tgz", - "integrity": "sha512-BDldzUKjnUjo0NW5gHjBY6CeJP1bWVfF1h/T3idyYG+F4Lxlb3aykRgLWXg4srNLY1KqE7XOYUmgc5cV613bgw==", + "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" @@ -885,16 +919,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.7", - "@angular/compiler": "18.2.7", - "@angular/core": "18.2.7", - "@angular/platform-browser": "18.2.7" + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.7.tgz", - "integrity": "sha512-TXE8Aw63hDp3PEaNu4B1DMNvlS0uCzs36o/OSCCmewmLnzyJygkgi4jeEj20FsWPAQOUj5g5tnCYgxz1IRrCUg==", + "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" @@ -903,16 +937,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.7", - "@angular/core": "18.2.7", - "@angular/platform-browser": "18.2.7", + "@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.7", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.7.tgz", - "integrity": "sha512-1t8PUWmZi32i/SG/r12vz+cfn0l3xVEa0FY7GXaZK7hlfDL34js1HZXHkvGUuRZRw/4L1jl7AwPoxwGeWr2ldg==", + "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" @@ -924,17 +958,17 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.7", - "@angular/core": "18.2.7" + "@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": { @@ -942,9 +976,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.7.tgz", - "integrity": "sha512-9ickoLz+hcXCeh7jrcin+/SLWm+GkxE2kTvoYyp38p4WkdFXfQJxDFGWp/YHjiKLPx06z2A7W8XKuqbReXDzsw==", + "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" @@ -1014,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" @@ -1044,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": { @@ -1066,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" }, @@ -1097,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" @@ -1127,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" @@ -1172,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" @@ -1212,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" @@ -1243,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" @@ -1283,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" @@ -1353,12 +1387,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.7.tgz", - "integrity": "sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==", + "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.7" + "@babel/types": "^7.25.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -1368,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" @@ -1385,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" @@ -1401,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" @@ -1417,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" @@ -1435,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" @@ -1546,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" @@ -1604,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" @@ -1729,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" @@ -1762,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" @@ -1815,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" @@ -1831,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" @@ -1847,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" @@ -1864,15 +1898,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.7.tgz", - "integrity": "sha512-rvUUtoVlkDWtDWxGAiiQj0aNktTPn3eFynBcMC2IhsXweehwgdI9ODe+XjWw515kEmv22sSOTp/rxIRuTiB7zg==", + "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/plugin-syntax-class-static-block": "^7.14.5" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1882,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": { @@ -1903,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" @@ -1933,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" @@ -1949,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" @@ -1966,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" @@ -1982,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" @@ -1999,14 +2032,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.7.tgz", - "integrity": "sha512-UvcLuual4h7/GfylKm2IAA3aph9rwvAM2XBA0uPKU3lca+Maai4jBjjEVUS568ld6kJcgbouuumCBhMd/Yz17w==", + "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/plugin-syntax-dynamic-import": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2016,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" @@ -2033,14 +2065,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.7.tgz", - "integrity": "sha512-h3MDAP5l34NQkkNulsTNyjdaR+OiB0Im67VU//sFupouP8Q6m9Spy7l66DcaAQxtmCqGdanPByLsnwFttxKISQ==", + "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/plugin-syntax-export-namespace-from": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2050,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" @@ -2067,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" @@ -2085,14 +2116,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.7.tgz", - "integrity": "sha512-Ot43PrL9TEAiCe8C/2erAjXMeVSnE/BLEx6eyrKLNFCCw5jvhTHKyHxdI1pA0kz5njZRYAnMO2KObGqOCRDYSA==", + "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/plugin-syntax-json-strings": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2102,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" @@ -2118,14 +2148,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.7.tgz", - "integrity": "sha512-iImzbA55BjiovLyG2bggWS+V+OLkaBorNvc/yJoeeDQGztknRnDdYfp2d/UPmunZYEnZi6Lg8QcTmNMHOB0lGA==", + "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/plugin-syntax-logical-assignment-operators": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2135,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" @@ -2151,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" @@ -2168,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" @@ -2186,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" @@ -2205,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" @@ -2222,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" @@ -2239,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" @@ -2255,14 +2284,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.7.tgz", - "integrity": "sha512-FbuJ63/4LEL32mIxrxwYaqjJxpbzxPVQj5a+Ebrc8JICV6YX8nE53jY+K0RZT3um56GoNWgkS2BQ/uLGTjtwfw==", + "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/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2272,14 +2300,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.7.tgz", - "integrity": "sha512-8CbutzSSh4hmD+jJHIA8vdTNk15kAzOnFLVVgBSMGr28rt85ouT01/rezMecks9pkU939wDInImwCKv4ahU4IA==", + "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/plugin-syntax-numeric-separator": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2289,16 +2316,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.7.tgz", - "integrity": "sha512-1JdVKPhD7Y5PvgfFy0Mv2brdrolzpzSoUq2pr6xsR+m+3viGGeHEokFKsCgOkbeFOQxfB1Vt2F0cPJLRpFI4Zg==", + "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-syntax-object-rest-spread": "^7.8.3", - "@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" @@ -2308,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" @@ -2325,14 +2351,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.7.tgz", - "integrity": "sha512-m9obYBA39mDPN7lJzD5WkGGb0GO54PPLXsbcnj1Hyeu8mSRz7Gb4b1A6zxNX32ZuUySDK4G6it8SDFWD1nCnqg==", + "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/plugin-syntax-optional-catch-binding": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2342,15 +2367,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.7.tgz", - "integrity": "sha512-h39agClImgPWg4H8mYVAbD1qP9vClFbEjqoJmt87Zen8pjqK8FTPUwrOXAvqu5soytwxrLMd2fx2KSCp2CHcNg==", + "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/plugin-syntax-optional-chaining": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2360,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" @@ -2376,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" @@ -2393,16 +2417,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.7.tgz", - "integrity": "sha512-LzA5ESzBy7tqj00Yjey9yWfs3FKy4EmJyKOSWld144OxkTji81WWnUT8nkLUn+imN/zHL8ZQlOu/MTUAhHaX3g==", + "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/plugin-syntax-private-property-in-object": "^7.14.5" + "@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" @@ -2412,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" @@ -2441,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": { @@ -2458,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" @@ -2495,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" @@ -2511,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" @@ -2528,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" @@ -2544,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" @@ -2560,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" @@ -2576,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" @@ -2592,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" @@ -2609,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" @@ -2626,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" @@ -2768,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" }, @@ -2800,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" @@ -2827,14 +2850,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.7.tgz", - "integrity": "sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==", + "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" @@ -2908,9 +2930,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.2.0.tgz", - "integrity": "sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", + "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", "dev": true, "license": "MIT", "dependencies": { @@ -2919,9 +2941,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", - "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", "dev": true, "license": "MIT", "dependencies": { @@ -3439,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": { @@ -3534,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": { @@ -3554,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": { @@ -3567,9 +3589,9 @@ } }, "node_modules/@fingerprintjs/fingerprintjs": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-4.5.0.tgz", - "integrity": "sha512-mFSQoxyt8SGGRp1QUlhcnVtquW2HzCKfHKxAoIurR6soIJpuK3VvZuH0sg8eNaHH2dJhI3mZOEUx4k+P4GqXzw==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-4.5.1.tgz", + "integrity": "sha512-hKJaRoLHNeUUPhb+Md3pTlY/Js2YR4aXjroaDHpxrjoM8kGnEFyZVZxXo6l3gRyKnQN52Uoqsycd3M73eCdMzw==", "license": "BUSL-1.1", "dependencies": { "tslib": "^2.4.1" @@ -3793,9 +3815,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.6.tgz", - "integrity": "sha512-yfZzps3Cso2UbM7WlxKwZQh2Hs6plrbjs1QnzQDZhK2DgyCo6D8AaHps9olkNcUFlcYERMqU3uJSp1gmy3s/qQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.7.tgz", + "integrity": "sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==", "dev": true, "license": "MIT", "engines": { @@ -4990,9 +5012,9 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.3.0.tgz", - "integrity": "sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", + "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5114,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", @@ -5245,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", @@ -5359,9 +5387,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.7.tgz", - "integrity": "sha512-BmnFxss6zGobGyq9Mi7736golbK8RLgF+zYCQZ+4/OfMMA1jKVoELnyJqNyAx+DQn3m1qKVBjtGEL7pTNpPzOw==", + "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": { @@ -5691,23 +5719,23 @@ } }, "node_modules/@nrwl/devkit": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-19.8.3.tgz", - "integrity": "sha512-67vZJRMCEA543A0uz8dPTZ5lX4wsAlgsr24KJafsUxBC2WCf9z4BqcLj0jVWfmRdKJmu2UwaxtD2UB1bekt3sg==", + "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.3" + "@nx/devkit": "19.8.6" } }, "node_modules/@nrwl/tao": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-19.8.3.tgz", - "integrity": "sha512-byjBtOXx+xGjMu1wKopJSJbrR3gKqTsCEgp1+YSZ45+iFKxFdXLJrGsyhVqBovCKVBM+5/KtGuEkZoUPlP8JWg==", + "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.3", + "nx": "19.8.6", "tslib": "^2.3.0" }, "bin": { @@ -5715,13 +5743,13 @@ } }, "node_modules/@nx/devkit": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-19.8.3.tgz", - "integrity": "sha512-uX50CAM11tzhwswf0ftN0QfzW2FM3M4Mf/pD/nRRnmsTkcPTdMXVu4LHuLVTp4CMsaO+cOQlqgHXujHYfOIctg==", + "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.3", + "@nrwl/devkit": "19.8.6", "ejs": "^3.1.7", "enquirer": "~2.3.6", "ignore": "^5.0.4", @@ -5732,7 +5760,7 @@ "yargs-parser": "21.1.1" }, "peerDependencies": { - "nx": ">= 17 <= 20" + "nx": ">= 19 <= 21" } }, "node_modules/@nx/devkit/node_modules/minimatch": { @@ -5762,9 +5790,9 @@ } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-19.8.3.tgz", - "integrity": "sha512-ORHFFWMZcvFi0xcpCaXccXVEhFwAevSHOIKfW359+12H9w7VW2O42B+2NcVMK1mrDTOjlXTd+0AmAu7P4NzWFA==", + "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" ], @@ -5779,9 +5807,9 @@ } }, "node_modules/@nx/nx-darwin-x64": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-19.8.3.tgz", - "integrity": "sha512-Ji9DPA0tuzygMcypD/FHRDQSPipcRqMNmSaNKxVpcCbozVTWHvqXFk0rloDIUnxnE0+zvE9LN71H2sS4ZHdTQA==", + "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" ], @@ -5796,9 +5824,9 @@ } }, "node_modules/@nx/nx-freebsd-x64": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-19.8.3.tgz", - "integrity": "sha512-Ys+PqtBZCS+QBNs7he3fnxVhMWz/lSSaBVUlVHoQcV1Y4clEpP2TWNQSsbaVnnpcB7pdmKN5ymWdaCaAQuqCMw==", + "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" ], @@ -5813,9 +5841,9 @@ } }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-19.8.3.tgz", - "integrity": "sha512-hGOlML60ELXkgkqLHB/w/sXbTbXFhOQGSXC72CjaP5G0u1gj8eTQKJ7WEsqPAFMk5SLFFxqM7eid0LmAYYuZWQ==", + "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" ], @@ -5830,9 +5858,9 @@ } }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-19.8.3.tgz", - "integrity": "sha512-K/5iVbLbhsx28YtZHvveJgF41rbr2kMdabooZeFqy6VReN7U/zGJMjpV1FzDlf3TNr9jyjPDZgVQRS+qXau2qA==", + "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" ], @@ -5847,9 +5875,9 @@ } }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-19.8.3.tgz", - "integrity": "sha512-zqzWjFniZDXiI/3MYxbJ0yIenUKr56apLy70oABTBHx++dsUA3/DxLMNypMA82a8KQtsbePWUi3Pgtr+JIMNXw==", + "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" ], @@ -5864,9 +5892,9 @@ } }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-19.8.3.tgz", - "integrity": "sha512-W1RRCqsQvpur4BxP5g5cQwjZB6jhxYLSSXi3QQDaU5ITkaV5Pdj/L7D/G6YgRB8lzKZrXc57aLJ5UKY/Z+di7w==", + "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" ], @@ -5881,9 +5909,9 @@ } }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-19.8.3.tgz", - "integrity": "sha512-waTo0zBBGnmU7fS87IpOnVGx7EHa0umzSMlGG0LUoU6swOeNODezsBn1Vbvaw1o7sStWBzdEBlxLxHOQXRAidg==", + "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" ], @@ -5898,9 +5926,9 @@ } }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-19.8.3.tgz", - "integrity": "sha512-lio7ulblEMs1otMtVIrdfdMTBqKRZEHim57AcMHSVnwmtl2ENP6TR3YIgyigjfLlkPanNU7i0QQ4h6Nk2I/FRw==", + "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" ], @@ -5915,9 +5943,9 @@ } }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-19.8.3.tgz", - "integrity": "sha512-RU11iXJzdrw5CmogT2AwsjxK7g8vWf6Oy23NlrvsQFODtavjqAWoD5qpUY/H16s9lVDwrpzCbGbAXph0lbgLKA==", + "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" ], @@ -5931,6 +5959,312 @@ "node": ">= 10" } }, + "node_modules/@parcel/watcher": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz", + "integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.4.1", + "@parcel/watcher-darwin-arm64": "2.4.1", + "@parcel/watcher-darwin-x64": "2.4.1", + "@parcel/watcher-freebsd-x64": "2.4.1", + "@parcel/watcher-linux-arm-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-musl": "2.4.1", + "@parcel/watcher-linux-x64-glibc": "2.4.1", + "@parcel/watcher-linux-x64-musl": "2.4.1", + "@parcel/watcher-win32-arm64": "2.4.1", + "@parcel/watcher-win32-ia32": "2.4.1", + "@parcel/watcher-win32-x64": "2.4.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz", + "integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz", + "integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz", + "integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz", + "integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz", + "integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz", + "integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz", + "integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz", + "integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz", + "integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz", + "integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", + "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz", + "integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6247,14 +6581,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.7", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.7.tgz", - "integrity": "sha512-WOBzO11qstznHbC9tZXQf6/8+PqmaRI6QYcdTspqXNh9q9nNglvi43Xn4tSIpEhW8aSHea9hgWZV8sG+i/4W9Q==", + "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.7", - "@angular-devkit/schematics": "18.2.7", + "@angular-devkit/core": "18.2.10", + "@angular-devkit/schematics": "18.2.10", "jsonc-parser": "3.3.1" }, "engines": { @@ -6264,73 +6598,73 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.33.1.tgz", - "integrity": "sha512-TW6/r+Gl5jiXv54iK1xZ3mlVgTS/jaBp4vcQ0xGMdgiQ3WchEPcFSeYovL+YHT3tSud0GZqVtDQCz+5i76puqA==", + "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.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1" + "@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.33.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.33.1.tgz", - "integrity": "sha512-qauMRTm3qDaLqZ3ibI03cj4gLF40y0ij65nj+cns6iWxGCtPrO8tjvXFWuQsE7Aye9dGMnBgmv7uN+NTUtC3RA==", + "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.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1" + "@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.33.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.33.1.tgz", - "integrity": "sha512-fm4coIOjmanU29NOVN9MyaP4fUCOYytbtFqVSKRFNZQ/xAgNeySiBIbUd6IjujMmnOk9bY0WEUMcdm3Uotjdog==", + "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.33.1", - "@sentry/core": "8.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1" + "@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.33.1", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.33.1.tgz", - "integrity": "sha512-nsxTFTPCT10Ty/v6+AiST3+yotGP1sUb8xqfKB9fPnS1hZHFryp0NnEls7xFjBsBbZPU1GpFkzrk/E6JFzixDQ==", + "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.33.1", - "@sentry/core": "8.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1" + "@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.33.1", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.33.1.tgz", - "integrity": "sha512-jt4oViLMl/eqOALQmD0dPzXsy75Xp8amfRExgXoPdyDg6sLDNdEzpzrX2p7nGl7vsW/0Vm8NZ2TkbEBCll5wfQ==", + "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.33.1", - "@sentry/core": "8.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1", + "@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": { @@ -6344,52 +6678,52 @@ } }, "node_modules/@sentry/browser": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.33.1.tgz", - "integrity": "sha512-c6zI/igexkLwZuGk+u8Rj26ChjxGgkhe6ZbKFsXCYaKAp5ep5X7HQRkkqgbxApiqlC0LduHdd/ymzh139JLg8w==", + "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.33.1", - "@sentry-internal/feedback": "8.33.1", - "@sentry-internal/replay": "8.33.1", - "@sentry-internal/replay-canvas": "8.33.1", - "@sentry/core": "8.33.1", - "@sentry/types": "8.33.1", - "@sentry/utils": "8.33.1" + "@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.33.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.33.1.tgz", - "integrity": "sha512-3SS41suXLFzxL3OQvTMZ6q92ZapELVq2l2SoWlZopcamWhog2Ru0dp2vkunq97kFHb2TzKRTlFH4+4gbT8SJug==", + "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.33.1", - "@sentry/utils": "8.33.1" + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.33.1", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.33.1.tgz", - "integrity": "sha512-GjoAMvwtpIemoF/IiwZ7A60g4nQv3qwzR21GvJqDVUoKD0e8pv9OLX+HyXoUat4wEDGSuDUcUyUKD2G+od73QA==", + "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.33.1", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.33.1.tgz", - "integrity": "sha512-uzuYpiiJuFY3N4WNHMBWUQX5oNv2t/TbG0OHRp3Rr7yeu+HSfD542TIp9/gMZ+G0Cxd8AmVO3wkKIFbk0TL4Qg==", + "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.33.1" + "@sentry/types": "8.35.0" }, "engines": { "node": ">=14.18" @@ -6862,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", @@ -6883,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": { @@ -6973,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": { @@ -7002,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" }, @@ -7019,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", @@ -7037,9 +7402,9 @@ } }, "node_modules/@types/node": { - "version": "22.7.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", - "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", + "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": { @@ -7057,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": { @@ -7087,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": "*", @@ -7143,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", @@ -7195,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", @@ -7243,17 +7608,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", - "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", + "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.8.0", - "@typescript-eslint/type-utils": "8.8.0", - "@typescript-eslint/utils": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.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", @@ -7277,16 +7642,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", - "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", + "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.8.0", - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/typescript-estree": "8.8.0", - "@typescript-eslint/visitor-keys": "8.8.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": { @@ -7306,14 +7671,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", - "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", + "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.8.0", - "@typescript-eslint/visitor-keys": "8.8.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" @@ -7324,14 +7689,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", - "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", + "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.8.0", - "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7349,9 +7714,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", - "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", + "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": { @@ -7363,14 +7728,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", - "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", + "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.8.0", - "@typescript-eslint/visitor-keys": "8.8.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", @@ -7392,16 +7757,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", - "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", + "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.8.0", - "@typescript-eslint/types": "8.8.0", - "@typescript-eslint/typescript-estree": "8.8.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" @@ -7415,13 +7780,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", - "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", + "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.8.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -7451,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", @@ -7715,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": { @@ -7929,6 +8313,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -7942,6 +8327,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -7998,23 +8384,22 @@ "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": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", "dev": true, "license": "MIT" }, @@ -8441,6 +8826,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8477,28 +8863,26 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.0.1.tgz", + "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", + "debug": "3.1.0", "destroy": "1.2.0", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.5.2", "on-finished": "2.4.1", "qs": "6.13.0", - "raw-body": "2.5.2", + "raw-body": "^3.0.0", "type-is": "~1.6.18", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 0.10" } }, "node_modules/body-parser/node_modules/bytes": { @@ -8511,6 +8895,43 @@ "node": ">= 0.8" } }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/bonjour-service": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", @@ -8571,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", @@ -8590,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" @@ -8785,9 +9206,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001666", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001666.tgz", - "integrity": "sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==", + "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", @@ -8855,6 +9276,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -8875,6 +9297,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -9102,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", @@ -9114,9 +9562,9 @@ } }, "node_modules/code-block-writer": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.2.tgz", - "integrity": "sha512-XfXzAGiStXSmCIwrkdfvc7FS5Dtj8yelCtyOf2p2skCAfvLd6zu0rGzuS9NSCO3bq1JKpFZ7tbKdKlcd5occQA==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "dev": true, "license": "MIT" }, @@ -9262,9 +9710,9 @@ "optional": true }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "dev": true, "license": "MIT", "dependencies": { @@ -9312,21 +9760,24 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.1.tgz", + "integrity": "sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/copy-anything": { "version": "2.0.6", @@ -9366,19 +9817,6 @@ "webpack": "^5.1.0" } }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/core-js": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", @@ -9543,9 +9981,9 @@ "license": "MIT" }, "node_modules/critters": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.24.tgz", - "integrity": "sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.25.tgz", + "integrity": "sha512-ROF/tjJyyRdM8/6W0VqoN5Ql05xAGnkf5b7f3sTEl1bI5jTQQf8O918RD/V9tEb9pRY/TKcvJekDbJtniHyPtQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -10255,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", @@ -10497,9 +10925,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.32", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.32.tgz", - "integrity": "sha512-M+7ph0VGBQqqpTT2YrabjNKSQ2fEl9PVx6AK3N558gDH9NO8O6XN9SXXFWRo9u9PbEg/bWq+tjXQr+eXmxubCw==", + "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": { @@ -10515,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", @@ -10608,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" @@ -10780,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", @@ -11372,19 +11812,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/eslint/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11629,46 +12056,94 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "dev": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", + "merge-descriptors": "^2.0.0", "methods": "~1.1.2", + "mime-types": "^3.0.0", "on-finished": "2.4.1", + "once": "1.4.0", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", + "router": "^2.0.0", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "^1.1.0", + "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", - "type-is": "~1.6.18", + "type-is": "^2.0.0", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, "node_modules/express/node_modules/safe-buffer": { @@ -11734,7 +12209,19 @@ "micromatch": "^4.0.4" }, "engines": { - "node": ">=8.6.0" + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/fast-json-patch": { @@ -11758,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", @@ -11866,14 +12353,14 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~2.0.0", + "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -11884,6 +12371,16 @@ "node": ">= 0.8" } }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -12018,9 +12515,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dev": true, "license": "MIT", "dependencies": { @@ -12070,13 +12567,13 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/front-matter": { @@ -12151,6 +12648,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12251,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": { @@ -12346,15 +12844,16 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" } }, "node_modules/glob-to-regexp": { @@ -12529,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", @@ -12601,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", @@ -12736,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" @@ -13107,6 +13586,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -13269,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" } @@ -13288,6 +13765,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -13470,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", @@ -15879,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", @@ -16541,26 +17028,64 @@ "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": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.12.0.tgz", - "integrity": "sha512-74wDsex5tQDSClVkeK1vtxqYCAgCoXxx+K4NSHzgU/muYVYByFqa+0RnrPO9NM6naWm1+G9JmZ0p6QHhXmeYfA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.14.0.tgz", + "integrity": "sha512-JUeY0F/fQZgIod31Ja1eJgiSxLn7BfQlCnqhwXFBzFHEw63OdLK7VJUJ7bnzNsWgCyoUP5tEp1VRY8rDaYzqOA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -16578,11 +17103,14 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -16644,6 +17172,7 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mime": "cli.js" }, @@ -16958,9 +17487,9 @@ "license": "MIT" }, "node_modules/monaco-editor": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.51.0.tgz", - "integrity": "sha512-xaGwVV1fq343cM7aOYB6lVE4Ugf0UyimdD/x5PWcWBMKENwectaEu77FAN7c5sFiyumqeJdX1RPTh1ocioyDjw==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", + "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==", "license": "MIT" }, "node_modules/moo-color": { @@ -17064,9 +17593,9 @@ } }, "node_modules/nan": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", - "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", "license": "MIT", "optional": true }, @@ -17129,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": { @@ -17456,6 +17985,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17620,15 +18150,15 @@ "license": "MIT" }, "node_modules/nx": { - "version": "19.8.3", - "resolved": "https://registry.npmjs.org/nx/-/nx-19.8.3.tgz", - "integrity": "sha512-/3FF4tgwPGRu4bV6O+aHqhTnOGHKF0/HNVkApUwjimSC+YzOX9VH1uBx2eReb4XC1scxDWkIzVi9gkFSXSQDjQ==", + "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.3", + "@nrwl/tao": "19.8.6", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "3.0.0-rc.46", "@zkochan/js-yaml": "0.0.7", @@ -17667,16 +18197,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "19.8.3", - "@nx/nx-darwin-x64": "19.8.3", - "@nx/nx-freebsd-x64": "19.8.3", - "@nx/nx-linux-arm-gnueabihf": "19.8.3", - "@nx/nx-linux-arm64-gnu": "19.8.3", - "@nx/nx-linux-arm64-musl": "19.8.3", - "@nx/nx-linux-x64-gnu": "19.8.3", - "@nx/nx-linux-x64-musl": "19.8.3", - "@nx/nx-win32-arm64-msvc": "19.8.3", - "@nx/nx-win32-x64-msvc": "19.8.3" + "@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", @@ -18406,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" @@ -18525,11 +19055,14 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "5.0.0", @@ -18555,9 +19088,9 @@ } }, "node_modules/pdfjs-dist": { - "version": "4.6.82", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.6.82.tgz", - "integrity": "sha512-BUOryeRFwvbLe0lOU6NhkJNuVQUp06WxlJVVCsxdmJ4y5cU3O3s3/0DunVdK1PMm7v2MUw52qKYaidhDH1Z9+w==", + "version": "4.7.76", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.7.76.tgz", + "integrity": "sha512-8y6wUgC/Em35IumlGjaJOCm3wV4aY/6sqnIT3fVW/67mXsOZ9HWBn8GDKmJUK0GSzpbmX3gQqwfoFayp78Mtqw==", "license": "Apache-2.0", "engines": { "node": ">=18" @@ -18574,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": { @@ -18892,20 +19425,21 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.166.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.166.1.tgz", - "integrity": "sha512-K8IpV8FJTCdwhsXFSbKj5vZ6IXNV079lukpG3cRtst2q5vMmUXRQiks7W3lOZLrjWyuJLKZDUiCeeDIUFORRuQ==", + "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", "fflate": "^0.4.8", "preact": "^10.19.3", - "web-vitals": "^4.0.1" + "web-vitals": "^4.2.0" } }, "node_modules/preact": { - "version": "10.24.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.1.tgz", - "integrity": "sha512-PnBAwFI3Yjxxcxw75n6VId/5TFxNW/81zexzWD9jn1+eSrOP84NdsS38H5IkF/UH3frqRPT+MvuCoVHjTDTnDw==", + "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", @@ -19096,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", @@ -19176,15 +19719,15 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { @@ -19201,6 +19744,19 @@ "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -19343,6 +19899,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -19355,6 +19912,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -19432,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", @@ -19471,9 +20023,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.0.tgz", - "integrity": "sha512-vTbzVAjQDzwQdKuvj7qEq6OlAprCjE656khuGQ4QaBLg7abQ9I9ISpmLuc6inWe7zP75AECjqUa4g4sdQvOXhg==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.1.tgz", + "integrity": "sha512-1DHODs4B8p/mQHU9kr+jv8+wIC9mtG4eBHxWxIq5mhjE3D5oORhCc6deRKzTjs9DcfRFmj9BHSDguZklqCGFWQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -19829,6 +20381,25 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.0.0.tgz", + "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-flatten": "3.0.0", + "is-promise": "4.0.0", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "^8.0.0", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -19895,12 +20466,13 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.79.4", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz", - "integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==", + "version": "1.80.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.4.tgz", + "integrity": "sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w==", "dev": true, "license": "MIT", "dependencies": { + "@parcel/watcher": "^2.4.1", "chokidar": "^4.0.0", "immutable": "^4.0.0", "source-map-js": ">=0.6.2 <2.0.0" @@ -20085,38 +20657,37 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/serialize-javascript": { @@ -20199,19 +20770,19 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -20315,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", @@ -20450,9 +20970,9 @@ } }, "node_modules/simple-statistics": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.5.tgz", - "integrity": "sha512-yw4aOnkvPLbL80zamrEKznAnk5cIIkjEcx/z0aQl+m/YKMmVufrnWgWJWRspqZtwh+ElZXRhJ0MtnUjFUQV5Ow==", + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.7.tgz", + "integrity": "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ==", "license": "ISC", "engines": { "node": "*" @@ -21076,9 +21596,9 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", - "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", + "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", "dev": true, "license": "MIT", "dependencies": { @@ -21430,22 +21950,22 @@ "license": "MIT" }, "node_modules/tldts": { - "version": "6.1.50", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.50.tgz", - "integrity": "sha512-q9GOap6q3KCsLMdOjXhWU5jVZ8/1dIib898JBRLsN+tBhENpBDcAVQbE0epADOjw11FhQQy9AcbqKGBQPUfTQA==", + "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.50" + "tldts-core": "^6.1.54" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.50", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.50.tgz", - "integrity": "sha512-na2EcZqmdA2iV9zHV7OHQDxxdciEpxrjbkp+aHmZgnZKHzoElLajP59np5/4+sare9fQBfixgvXKx8ev1d7ytw==", + "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" }, @@ -21469,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", @@ -21725,9 +22236,9 @@ } }, "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "license": "0BSD" }, "node_modules/tsutils": { @@ -21768,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", @@ -21805,14 +22325,38 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "dev": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.53.0" }, "engines": { "node": ">= 0.6" @@ -21862,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", @@ -22111,9 +22661,9 @@ } }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, "license": "MIT", "dependencies": { @@ -22685,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": { @@ -22873,9 +23423,9 @@ } }, "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index dfe192b8d21b..5927d8b13550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "7.6.0", + "version": "7.6.5", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", @@ -13,34 +13,35 @@ "node_modules" ], "dependencies": { - "@angular/animations": "18.2.7", - "@angular/cdk": "18.2.7", - "@angular/common": "18.2.7", - "@angular/compiler": "18.2.7", - "@angular/core": "18.2.7", - "@angular/forms": "18.2.7", - "@angular/localize": "18.2.7", - "@angular/material": "18.2.7", - "@angular/platform-browser": "18.2.7", - "@angular/platform-browser-dynamic": "18.2.7", - "@angular/router": "18.2.7", - "@angular/service-worker": "18.2.7", + "@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.0", + "@fingerprintjs/fingerprintjs": "4.5.1", "@fortawesome/angular-fontawesome": "0.15.0", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", - "@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.33.1", + "@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.51.0", + "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "pdfjs-dist": "4.6.82", - "posthog-js": "1.166.1", + "pdfjs-dist": "4.7.76", + "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.5", + "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.7.0", + "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,29 +91,25 @@ "d3-transition": "^3.0.1" }, "@typescript-eslint/utils": { - "eslint": "^9.12.0" + "eslint": "^9.13.0" }, "braces": "3.0.3", - "cookie": "0.7.1", - "critters": "0.0.24", + "cookie": "1.0.1", + "critters": "0.0.25", "debug": "4.3.7", "eslint-plugin-deprecation": { "eslint": "^9.12.0" }, "eslint-plugin-jest": { - "@typescript-eslint/eslint-plugin": "^8.8.0" + "@typescript-eslint/eslint-plugin": "^8.11.0" }, - "express": "4.21.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.8", + "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.7", - "@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.7", - "@angular/compiler-cli": "18.2.7", - "@angular/language-service": "18.2.7", - "@sentry/types": "8.33.1", + "@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.4", - "@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.8.0", - "@typescript-eslint/parser": "8.8.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.79.4", + "sass": "1.80.4", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" diff --git a/prebuild.mjs b/prebuild.mjs index 7ef783be432c..5f362babe3fc 100644 --- a/prebuild.mjs +++ b/prebuild.mjs @@ -5,10 +5,11 @@ * - webpack.DefinePlugin and * - MergeJsonWebpackPlugin */ -import fs from "fs"; -import path from "path"; -import { hashElement } from "folder-hash"; -import { fileURLToPath } from "url"; +import fs from 'fs'; +import path from 'path'; +import { hashElement } from 'folder-hash'; +import { fileURLToPath } from 'url'; +import * as esbuild from 'esbuild'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -111,4 +112,25 @@ for (const group of groups) { } } +/* + * The workers of the monaco editor must be bundled separately. + * Specialized workers are available in the vs/esm/language/ directory. + * Be sure to modify the MonacoConfig if you choose to add a worker here. + * For more details, refer to https://github.com/microsoft/monaco-editor/blob/main/samples/browser-esm-esbuild/build.js + */ +const workerEntryPoints = [ + 'vs/language/json/json.worker.js', + 'vs/language/css/css.worker.js', + 'vs/language/html/html.worker.js', + 'vs/language/typescript/ts.worker.js', + 'vs/editor/editor.worker.js' +]; +await esbuild.build({ + entryPoints: workerEntryPoints.map((entry) => `node_modules/monaco-editor/esm/${entry}`), + bundle: true, + format: 'esm', + outbase: 'node_modules/monaco-editor/esm', + outdir: 'node_modules/monaco-editor/bundles' +}); + console.log("Pre-Build complete!"); diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java index cc14d7a35e34..77c01c6fae19 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java @@ -629,7 +629,7 @@ public boolean isAutomatic() { * @return true if the result is an automatic AI Athena result */ @JsonIgnore - public boolean isAthenaAutomatic() { + public boolean isAthenaBased() { return AssessmentType.AUTOMATIC_ATHENA == assessmentType; } 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/repository/ResultRepository.java b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java index 87c381b777d9..5a7b75e82de5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/repository/ResultRepository.java @@ -427,6 +427,14 @@ SELECT COUNT(DISTINCT p) */ boolean existsByParticipationId(long participationId); + /** + * Checks if a result exists for the given submission ID. + * + * @param submissionId the ID of the submission to check. + * @return true if a result exists for the given submission ID, false otherwise. + */ + boolean existsBySubmissionId(long submissionId); + /** * Returns true if there is at least one result for the given exercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index 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/dto/CompetencyGraphNodeDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java index 59feee0edd6b..c56876064668 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java @@ -12,7 +12,7 @@ public record CompetencyGraphNodeDTO(String id, String label, ZonedDateTime softDueDate, Double value, CompetencyNodeValueType valueType) { public enum CompetencyNodeValueType { - MASTERY_PROGRESS + MASTERY_PROGRESS, AVERAGE_MASTERY_PROGRESS, } public static CompetencyGraphNodeDTO of(@NotNull CourseCompetency competency, Double value, CompetencyNodeValueType valueType) { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java index 8592378c6a50..05d621746267 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java @@ -14,6 +14,6 @@ public LearningPathHealthDTO(Set status) { } public enum HealthStatus { - OK, DISABLED, MISSING, NO_COMPETENCIES, NO_RELATIONS + MISSING, NO_COMPETENCIES, NO_RELATIONS } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java new file mode 100644 index 000000000000..d1bac2f5b3ca --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.atlas.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.atlas.domain.competency.RelationType; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UpdateCourseCompetencyRelationDTO(RelationType newRelationType) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyInformationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyInformationDTO.java index 02e0fc7edeb2..f361bc402718 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyInformationDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/CompetencyInformationDTO.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyTaxonomy; /** @@ -19,4 +20,15 @@ */ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record CompetencyInformationDTO(long id, String title, String description, CompetencyTaxonomy taxonomy, ZonedDateTime softDueDate, boolean optional, int masteryThreshold) { + + /** + * Creates a CompetencyInformationDTO from a Competency. + * + * @param competency the Competency to create the DTO from + * @return the created DTO + */ + public static CompetencyInformationDTO of(C competency) { + return new CompetencyInformationDTO(competency.getId(), competency.getTitle(), competency.getDescription(), competency.getTaxonomy(), competency.getSoftDueDate(), + competency.isOptional(), competency.getMasteryThreshold()); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/LectureUnitInformationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/LectureUnitInformationDTO.java index 3a5d9cc8bcb4..8f4016a5f346 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/LectureUnitInformationDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/metrics/LectureUnitInformationDTO.java @@ -18,4 +18,15 @@ */ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record LectureUnitInformationDTO(long id, long lectureId, String lectureTitle, String name, ZonedDateTime releaseDate, Class type) { + + /** + * Creates a LectureUnitInformationDTO from a LectureUnit. + * + * @param lectureUnit the LectureUnit to create the DTO from + * @return the created DTO + */ + public static LectureUnitInformationDTO of(L lectureUnit) { + return new LectureUnitInformationDTO(lectureUnit.getId(), lectureUnit.getLecture().getId(), lectureUnit.getLecture().getTitle(), lectureUnit.getName(), + lectureUnit.getReleaseDate(), lectureUnit.getClass()); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java index 85c627b06408..2e397c06db36 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java @@ -94,4 +94,16 @@ SELECT COUNT(cp) AND c = :competency """) Set findAllPriorByCompetencyId(@Param("competency") CourseCompetency competency, @Param("user") User userId); + + @Query(""" + SELECT COALESCE(GREATEST(0.0, LEAST(1.0, AVG(cp.progress * cp.confidence / com.masteryThreshold))), 0.0) + FROM CompetencyProgress cp + LEFT JOIN cp.competency com + LEFT JOIN com.course c + LEFT JOIN cp.user u + WHERE com.id = :competencyId + AND cp.progress > 0 + AND c.studentGroupName MEMBER OF u.groups + """) + double findAverageOfAllNonZeroStudentProgressByCompetencyId(@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..0d672f1bcbc2 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,14 +1,18 @@ 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; @@ -21,6 +25,8 @@ /** * Spring Data JPA repository for the {@link CourseCompetency} entity. */ +@Profile(PROFILE_CORE) +@Repository public interface CourseCompetencyRepository extends ArtemisJpaRepository { @Query(""" 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..5d7fc9c56e49 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,10 +18,10 @@ /** * 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 diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java index 8deffa786626..1011cacaf450 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyJolService.java @@ -86,9 +86,7 @@ public void setJudgementOfLearning(long competencyId, long userId, short jolValu irisCourseChatSessionService.ifPresent(service -> { // Inform Iris so it can send a message to the user try { - if (userId % 3 > 0) { // HD3-GROUPS: Iris groups are 1 & 2 - service.onJudgementOfLearningSet(jol); - } + service.onJudgementOfLearningSet(jol); } catch (Exception e) { log.warn("Something went wrong while sending the judgement of learning to Iris", e); 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 9ec942846bf8..fbe46aa979b0 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 @@ -23,6 +23,7 @@ import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; 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.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; @@ -41,9 +42,9 @@ public CompetencyService(CompetencyRepository competencyRepository, Authorizatio LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.competencyRepository = competencyRepository; } 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 01eb37cf8271..ea31bff10fad 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 @@ -27,6 +27,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; 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.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -39,6 +40,7 @@ import de.tum.cit.aet.artemis.core.dto.pageablesearch.CompetencyPageableSearchDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -77,11 +79,13 @@ public class CourseCompetencyService { private final LearningObjectImportService learningObjectImportService; + 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) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { this.competencyProgressRepository = competencyProgressRepository; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyRelationRepository = competencyRelationRepository; @@ -93,6 +97,7 @@ public CourseCompetencyService(CompetencyProgressRepository competencyProgressRe this.standardizedCompetencyRepository = standardizedCompetencyRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.learningObjectImportService = learningObjectImportService; + this.courseRepository = courseRepository; } /** @@ -123,6 +128,28 @@ public List findCourseCompetenciesWithProgressForUserByCourseI return findProgressForCompetenciesAndUser(competencies, userId); } + /** + * Updates the type of a course competency relation. + * + * @param courseId The id of the course for which to fetch the competencies + * @param courseCompetencyRelationId The id of the course competency relation to update + * @param updateCourseCompetencyRelationDTO The DTO containing the new relation type + * + */ + public void updateCourseCompetencyRelation(long courseId, long courseCompetencyRelationId, UpdateCourseCompetencyRelationDTO updateCourseCompetencyRelationDTO) { + var relation = competencyRelationRepository.findByIdElseThrow(courseCompetencyRelationId); + var course = courseRepository.findByIdElseThrow(courseId); + var headCompetency = relation.getHeadCompetency(); + var tailCompetency = relation.getTailCompetency(); + + if (!course.getId().equals(headCompetency.getCourse().getId()) || !course.getId().equals(tailCompetency.getCourse().getId())) { + throw new BadRequestAlertException("The relation does not belong to the course", ENTITY_NAME, "relationWrongCourse"); + } + + relation.setType(updateCourseCompetencyRelationDTO.newRelationType()); + competencyRelationRepository.save(relation); + } + /** * Search for all course competencies fitting a {@link CompetencyPageableSearchDTO search query}. The result is paged. * 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 3fc520a21378..4bf07e6e42ca 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 @@ -23,6 +23,7 @@ import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; 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.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; @@ -41,9 +42,9 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.prerequisiteRepository = prerequisiteRepository; } 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 190565c5c35c..ea2a4bd9ec37 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 @@ -33,6 +33,7 @@ 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; +import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.LearningPathRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -89,10 +90,13 @@ public class LearningPathService { private final StudentParticipationRepository studentParticipationRepository; + private final CourseCompetencyRepository courseCompetencyRepository; + public LearningPathService(UserRepository userRepository, LearningPathRepository learningPathRepository, CompetencyProgressRepository competencyProgressRepository, LearningPathNavigationService learningPathNavigationService, CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyRelationRepository competencyRelationRepository, LearningPathNgxService learningPathNgxService, - LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository) { + LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository, + CourseCompetencyRepository courseCompetencyRepository) { this.userRepository = userRepository; this.learningPathRepository = learningPathRepository; this.competencyProgressRepository = competencyProgressRepository; @@ -103,6 +107,7 @@ public LearningPathService(UserRepository userRepository, LearningPathRepository this.learningPathNgxService = learningPathNgxService; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.studentParticipationRepository = studentParticipationRepository; + this.courseCompetencyRepository = courseCompetencyRepository; } /** @@ -298,20 +303,11 @@ else if (learningPath.isStartedByStudent()) { * @return dto containing the health status and additional information (missing learning paths) if needed */ public LearningPathHealthDTO getHealthStatusForCourse(@NotNull Course course) { - if (!course.getLearningPathsEnabled()) { - return new LearningPathHealthDTO(Set.of(LearningPathHealthDTO.HealthStatus.DISABLED)); - } - Set status = new HashSet<>(); Long numberOfMissingLearningPaths = checkMissingLearningPaths(course, status); checkNoCompetencies(course, status); checkNoRelations(course, status); - // if no issues where found, add OK status - if (status.isEmpty()) { - status.add(LearningPathHealthDTO.HealthStatus.OK); - } - return new LearningPathHealthDTO(status, numberOfMissingLearningPaths); } @@ -366,6 +362,25 @@ public LearningPathCompetencyGraphDTO generateLearningPathCompetencyGraph(@NotNu return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); } + /** + * Generates the graph of competencies with the student's progress for the given learning path. + * + * @param courseId the id of the course for which the graph should be generated + * @return dto containing the competencies and relations of the learning path + */ + public LearningPathCompetencyGraphDTO generateLearningPathCompetencyInstructorGraph(long courseId) { + List competencies = courseCompetencyRepository.findByCourseIdOrderById(courseId); + Set progressDTOs = competencies.stream().map(competency -> { + double averageMasteryProgress = competencyProgressRepository.findAverageOfAllNonZeroStudentProgressByCompetencyId(competency.getId()); + return CompetencyGraphNodeDTO.of(competency, averageMasteryProgress, CompetencyGraphNodeDTO.CompetencyNodeValueType.AVERAGE_MASTERY_PROGRESS); + }).collect(Collectors.toSet()); + + Set relations = competencyRelationRepository.findAllWithHeadAndTailByCourseId(courseId); + Set relationDTOs = relations.stream().map(CompetencyGraphEdgeDTO::of).collect(Collectors.toSet()); + + return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); + } + /** * Generates Ngx graph representation of the learning path graph. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java index 449a92a1d171..8e93c6c73090 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java @@ -10,6 +10,7 @@ import java.util.Set; import java.util.stream.Collectors; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.slf4j.Logger; @@ -18,6 +19,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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -32,6 +34,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolPairDTO; 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.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -350,6 +353,23 @@ public ResponseEntity generateCompetenciesFromCourseDescription(@PathVaria return ResponseEntity.accepted().build(); } + /** + * PATCH courses/:courseId/course-competencies/relations/:competencyRelationId update a relation type of an existing relation + * + * @param courseId the id of the course to which the competencies belong + * @param competencyRelationId the id of the competency relation to update + * @param updateCourseCompetencyRelationDTO the new relation type + * @return the ResponseEntity with status 200 (OK) + */ + @PatchMapping("courses/{courseId}/course-competencies/relations/{competencyRelationId}") + @EnforceAtLeastInstructorInCourse + public ResponseEntity updateCompetencyRelation(@PathVariable long courseId, @PathVariable long competencyRelationId, + @RequestBody @Valid UpdateCourseCompetencyRelationDTO updateCourseCompetencyRelationDTO) { + log.info("REST request to update a competency relation: {}", competencyRelationId); + courseCompetencyService.updateCourseCompetencyRelation(courseId, competencyRelationId, updateCourseCompetencyRelationDTO); + return ResponseEntity.noContent().build(); + } + /** * PUT courses/:courseId/course-competencies/:competencyId/jol/:jolValue : Sets the judgement of learning for a competency * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java index d940e50acc51..43a8135f27cb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java @@ -59,6 +59,7 @@ import de.tum.cit.aet.artemis.lecture.service.LearningObjectService; @Profile(PROFILE_CORE) +@FeatureToggle(Feature.LearningPaths) @RestController @RequestMapping("api/") public class LearningPathResource { @@ -108,7 +109,6 @@ public LearningPathResource(CourseService courseService, CourseRepository course * @return the ResponseEntity with status 200 (OK) */ @PutMapping("courses/{courseId}/learning-paths/enable") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastInstructorInCourse public ResponseEntity enableLearningPathsForCourse(@PathVariable long courseId) { log.debug("REST request to enable learning paths for course with id: {}", courseId); @@ -129,7 +129,6 @@ public ResponseEntity enableLearningPathsForCourse(@PathVariable long cour * @return the ResponseEntity with status 200 (OK) */ @PutMapping("courses/{courseId}/learning-paths/generate-missing") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastInstructorInCourse public ResponseEntity generateMissingLearningPathsForCourse(@PathVariable long courseId) { log.debug("REST request to generate missing learning paths for course with id: {}", courseId); @@ -147,7 +146,6 @@ public ResponseEntity generateMissingLearningPathsForCourse(@PathVariable * @return the ResponseEntity with status 200 (OK) and with body the desired page, sorted and matching the given query */ @GetMapping("courses/{courseId}/learning-paths") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastInstructorInCourse public ResponseEntity> getLearningPathsOnPage(@PathVariable long courseId, SearchTermPageableSearchDTO search) { log.debug("REST request to get learning paths for course with id: {}", courseId); @@ -162,7 +160,6 @@ public ResponseEntity> getLearni * @return the ResponseEntity with status 200 (OK) and with body the health status */ @GetMapping("courses/{courseId}/learning-path-health") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastInstructorInCourse public ResponseEntity getHealthStatusForCourse(@PathVariable long courseId) { log.debug("REST request to get health status of learning paths in course with id: {}", courseId); @@ -177,7 +174,6 @@ public ResponseEntity getHealthStatusForCourse(@PathVaria * @return the ResponseEntity with status 200 (OK) and with body the learning path */ @GetMapping("learning-path/{learningPathId}") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPath(@PathVariable long learningPathId) { log.debug("REST request to get learning path with id: {}", learningPathId); @@ -196,7 +192,6 @@ public ResponseEntity getLearningPath(@PathVariable * @return the ResponseEntity with status 200 (OK) and with body the graph */ @GetMapping("learning-path/{learningPathId}/competency-graph") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathCompetencyGraph(@PathVariable long learningPathId) { log.debug("REST request to get competency graph for learning path with id: {}", learningPathId); @@ -208,6 +203,21 @@ public ResponseEntity getLearningPathCompetencyG return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyGraph(learningPath, user)); } + /** + * GET courses/{courseId}/learning-path/competency-instructor-graph : Gets the competency instructor graph + * + * @param courseId the id of the course for which the graph should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the graph + */ + @GetMapping("courses/{courseId}/learning-path/competency-instructor-graph") + @FeatureToggle(Feature.LearningPaths) + @EnforceAtLeastInstructorInCourse + public ResponseEntity getLearningPathCompetencyInstructorGraph(@PathVariable long courseId) { + log.debug("REST request to get competency instructor graph for learning path with id: {}", courseId); + + return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyInstructorGraph(courseId)); + } + /** * GET learning-path/:learningPathId/graph : Gets the ngx representation of the learning path as a graph. * @@ -215,7 +225,6 @@ public ResponseEntity getLearningPathCompetencyG * @return the ResponseEntity with status 200 (OK) and with body the ngx representation of the learning path */ @GetMapping("learning-path/{learningPathId}/graph") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathNgxGraph(@PathVariable long learningPathId) { log.debug("REST request to get ngx graph representation of learning path with id: {}", learningPathId); @@ -229,7 +238,6 @@ public ResponseEntity getLearningPathNgxGraph(@PathVariable * @return the ResponseEntity with status 200 (OK) and with body the ngx representation of the learning path */ @GetMapping("learning-path/{learningPathId}/path") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathNgxPath(@PathVariable long learningPathId) { log.debug("REST request to get ngx path representation of learning path with id: {}", learningPathId); @@ -246,7 +254,6 @@ public ResponseEntity getLearningPathNgxPath(@PathVariable l * @return the ResponseEntity with status 200 (OK) and with body the navigation information */ @GetMapping("learning-path/{learningPathId}/relative-navigation") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getRelativeLearningPathNavigation(@PathVariable @Valid long learningPathId, @RequestParam long learningObjectId, @RequestParam LearningObjectType learningObjectType, @RequestParam long competencyId) { @@ -265,7 +272,6 @@ public ResponseEntity getRelativeLearningPathNavigati * @return the ResponseEntity with status 200 (OK) and with body the navigation information */ @GetMapping("learning-path/{learningPathId}/navigation") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathNavigation(@PathVariable long learningPathId) { log.debug("REST request to get navigation for learning path with id: {}", learningPathId); @@ -281,7 +287,6 @@ public ResponseEntity getLearningPathNavigation(@Path * @return the ResponseEntity with status 200 (OK) and with body the navigation overview */ @GetMapping("learning-path/{learningPathId}/navigation-overview") - @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastStudent public ResponseEntity getLearningPathNavigationOverview(@PathVariable @Valid long learningPathId) { log.debug("REST request to get navigation overview for learning path with id: {}", learningPathId); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/ScienceResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/ScienceResource.java index 7312cc824997..d4e1563df41c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/ScienceResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/ScienceResource.java @@ -21,6 +21,7 @@ * REST controller providing the science related endpoints. */ @Profile(PROFILE_CORE) +@FeatureToggle(Feature.Science) @RestController @RequestMapping("api/") public class ScienceResource { @@ -40,7 +41,6 @@ public ScienceResource(ScienceEventService scienceEventService) { * @return the ResponseEntity with status 200 (OK) */ @PutMapping(value = "science") - @FeatureToggle(Feature.Science) @EnforceAtLeastStudent public ResponseEntity science(@RequestBody ScienceEventDTO event) { log.debug("REST request to log science event of type {}", event); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/StandardizedCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/StandardizedCompetencyResource.java index 8b6316fcc51c..b205db140bc3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/StandardizedCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/StandardizedCompetencyResource.java @@ -29,6 +29,7 @@ * REST controller for managing {@link StandardizedCompetency} entities. */ @Profile(PROFILE_CORE) +@FeatureToggle(Feature.StandardizedCompetencies) @RestController @RequestMapping("api/standardized-competencies/") public class StandardizedCompetencyResource { @@ -58,7 +59,6 @@ public StandardizedCompetencyResource(StandardizedCompetencyService standardized * @return the ResponseEntity with status 200 (OK) and with body containing the standardized competency, or with status 404 (Not Found) */ @GetMapping("{competencyId}") - @FeatureToggle(Feature.StandardizedCompetencies) @EnforceAtLeastInstructor public ResponseEntity getStandardizedCompetency(@PathVariable long competencyId) { log.debug("REST request to get standardized competency with id : {}", competencyId); @@ -74,7 +74,6 @@ public ResponseEntity getStandardizedCompetency(@PathVar * @return the ResponseEntity with status 200 (OK) and with body containing the knowledge areas */ @GetMapping("for-tree-view") - @FeatureToggle(Feature.StandardizedCompetencies) @EnforceAtLeastInstructor public ResponseEntity> getAllForTreeView() { log.debug("REST request to all knowledge areas for tree view"); @@ -91,7 +90,6 @@ public ResponseEntity> getAllForTreeView() { * @return the ResponseEntity with status 200 (OK) and with body containing the knowledge area, or with status 404 (Not Found) */ @GetMapping("knowledge-areas/{knowledgeAreaId}") - @FeatureToggle(Feature.StandardizedCompetencies) @EnforceAtLeastInstructor public ResponseEntity getKnowledgeArea(@PathVariable long knowledgeAreaId) { log.debug("REST request to get knowledge area with id : {}", knowledgeAreaId); @@ -107,7 +105,6 @@ public ResponseEntity getKnowledgeArea(@PathVariable long knowled * @return the ResponseEntity with status 200 (OK) and with body containing the list of sources */ @GetMapping("sources") - @FeatureToggle(Feature.StandardizedCompetencies) @EnforceAtLeastInstructor public ResponseEntity> getSources() { log.debug("REST request to get all sources"); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/admin/AdminStandardizedCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/admin/AdminStandardizedCompetencyResource.java index 376cedb132dc..5c974101f8bb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/admin/AdminStandardizedCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/admin/AdminStandardizedCompetencyResource.java @@ -38,6 +38,8 @@ * Admin REST controller for managing {@link StandardizedCompetency} entities. */ @Profile(PROFILE_CORE) +@FeatureToggle(Feature.StandardizedCompetencies) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminStandardizedCompetencyResource { @@ -61,8 +63,6 @@ public AdminStandardizedCompetencyResource(StandardizedCompetencyService standar * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("standardized-competencies") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity createStandardizedCompetency(@RequestBody @Valid StandardizedCompetencyRequestDTO competency) throws URISyntaxException { log.debug("REST request to create standardized competency : {}", competency); @@ -79,8 +79,6 @@ public ResponseEntity createStandardizedCompete * @return the ResponseEntity with status 200 (OK) and with body the updated standardized competency */ @PutMapping("standardized-competencies/{competencyId}") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity updateStandardizedCompetency(@PathVariable long competencyId, @RequestBody @Valid StandardizedCompetencyRequestDTO competency) { log.debug("REST request to update standardized competency : {}", competency); @@ -97,8 +95,6 @@ public ResponseEntity updateStandardizedCompete * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("standardized-competencies/{competencyId}") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity deleteStandardizedCompetency(@PathVariable long competencyId) { log.debug("REST request to delete standardized competency : {}", competencyId); @@ -115,8 +111,6 @@ public ResponseEntity deleteStandardizedCompetency(@PathVariable long comp * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("standardized-competencies/knowledge-areas") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity createKnowledgeArea(@RequestBody @Valid KnowledgeAreaRequestDTO knowledgeArea) throws URISyntaxException { log.debug("REST request to create knowledge area : {}", knowledgeArea); @@ -134,8 +128,6 @@ public ResponseEntity createKnowledgeArea(@RequestBody @ * @return the ResponseEntity with status 200 (OK) and with body the updated knowledge area */ @PutMapping("standardized-competencies/knowledge-areas/{knowledgeAreaId}") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity updateKnowledgeArea(@PathVariable long knowledgeAreaId, @RequestBody @Valid KnowledgeAreaRequestDTO knowledgeArea) { log.debug("REST request to update knowledge area : {}", knowledgeArea); @@ -151,8 +143,6 @@ public ResponseEntity updateKnowledgeArea(@PathVariable * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("standardized-competencies/knowledge-areas/{knowledgeAreaId}") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity deleteKnowledgeArea(@PathVariable long knowledgeAreaId) { log.debug("REST request to delete knowledge area : {}", knowledgeAreaId); @@ -168,8 +158,6 @@ public ResponseEntity deleteKnowledgeArea(@PathVariable long knowledgeArea * @return the ResponseEntity with status 200 (OK) */ @PutMapping("standardized-competencies/import") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity importStandardizedCompetencyCatalog(@RequestBody @Valid StandardizedCompetencyCatalogDTO standardizedCompetencyCatalogDTO) { log.debug("REST request to import standardized competency catalog"); @@ -184,8 +172,6 @@ public ResponseEntity importStandardizedCompetencyCatalog(@RequestBody @Va * @return the ResponseEntity with status 200 (OK) and the body containing the JSON string of the standardized competency catalog */ @GetMapping("standardized-competencies/export") - @FeatureToggle(Feature.StandardizedCompetencies) - @EnforceAdmin public ResponseEntity exportStandardizedCompetencyCatalog() { log.debug("REST request to export standardized competency catalog"); 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 35625765d858..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,8 +11,8 @@ // 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, boolean status, - List recentBuildJobs, String publicSshKey) implements Serializable { +public record BuildAgentInformation(BuildAgentDTO buildAgent, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, + BuildAgentStatus status, List recentBuildJobs, String publicSshKey) implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -24,7 +24,11 @@ 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()); } + + public enum BuildAgentStatus { + ACTIVE, IDLE, PAUSED + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java index bce9a9b20e65..9a41fc6fdc20 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java @@ -15,7 +15,7 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record BuildConfig(String buildScript, String dockerImage, String commitHashToBuild, String assignmentCommitHash, String testCommitHash, String branch, ProgrammingLanguage programmingLanguage, ProjectType projectType, boolean scaEnabled, boolean sequentialTestRunsEnabled, boolean testwiseCoverageEnabled, - List resultPaths) implements Serializable { + List resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath) implements Serializable { @Override public String dockerImage() { 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 8df60f45026f..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 @@ -24,6 +24,7 @@ import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -275,11 +276,22 @@ public String getIDOfRunningContainer(String containerName) { * @param auxiliaryRepositoriesPaths An array of paths for auxiliary repositories to be included in the build process. * @param auxiliaryRepositoryCheckoutDirectories An array of directory names within the container where each auxiliary repository should be checked out. * @param programmingLanguage The programming language of the repositories, which influences directory naming conventions. + * @param assignmentCheckoutPath The directory within the container where the assignment repository should be checked out; can be null if not applicable, + * default would be used. + * @param testCheckoutPath The directory within the container where the test repository should be checked out; can be null if not applicable, default + * would be used. + * @param solutionCheckoutPath The directory within the container where the solution repository should be checked out; can be null if not applicable, default + * would be used. */ public void populateBuildJobContainer(String buildJobContainerId, Path assignmentRepositoryPath, Path testRepositoryPath, Path solutionRepositoryPath, - Path[] auxiliaryRepositoriesPaths, String[] auxiliaryRepositoryCheckoutDirectories, ProgrammingLanguage programmingLanguage) { - String testCheckoutPath = RepositoryCheckoutPath.TEST.forProgrammingLanguage(programmingLanguage); - String assignmentCheckoutPath = RepositoryCheckoutPath.ASSIGNMENT.forProgrammingLanguage(programmingLanguage); + Path[] auxiliaryRepositoriesPaths, String[] auxiliaryRepositoryCheckoutDirectories, ProgrammingLanguage programmingLanguage, String assignmentCheckoutPath, + String testCheckoutPath, String solutionCheckoutPath) { + + assignmentCheckoutPath = (!StringUtils.isBlank(assignmentCheckoutPath)) ? assignmentCheckoutPath + : RepositoryCheckoutPath.ASSIGNMENT.forProgrammingLanguage(programmingLanguage); + + String defaultTestCheckoutPath = RepositoryCheckoutPath.TEST.forProgrammingLanguage(programmingLanguage); + testCheckoutPath = (!StringUtils.isBlank(defaultTestCheckoutPath) && !StringUtils.isBlank(testCheckoutPath)) ? testCheckoutPath : defaultTestCheckoutPath; // Make sure to create the working directory in case it does not exist. // In case the test checkout path is the working directory, we only create up to the parent, as the working directory is created below. @@ -288,15 +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) { - String solutionCheckoutPath = RepositoryCheckoutPath.SOLUTION.forProgrammingLanguage(programmingLanguage); - addAndPrepareDirectory(buildJobContainerId, solutionRepositoryPath, LOCALCI_WORKING_DIRECTORY + "/testing-dir/" + solutionCheckoutPath); + solutionCheckoutPath = (!StringUtils.isBlank(solutionCheckoutPath)) ? solutionCheckoutPath + : RepositoryCheckoutPath.SOLUTION.forProgrammingLanguage(programmingLanguage); + 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); @@ -307,11 +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, 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); } @@ -428,4 +448,9 @@ private Container getContainerForName(String containerName) { List containers = dockerClient.listContainersCmd().withShowAll(true).exec(); return containers.stream().filter(container -> container.getNames()[0].equals("/" + containerName)).findFirst().orElse(null); } + + private String getParentFolderPath(String path) { + Path parentPath = Paths.get(path).normalize().getParent(); + return parentPath != null ? parentPath.toString() : ""; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java index f25d5b6f749d..75bbaf826b00 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java @@ -9,8 +9,11 @@ import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -27,7 +30,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.scheduling.annotation.Async; import org.springframework.stereotype.Service; import com.github.dockerjava.api.command.CreateContainerResponse; @@ -71,6 +77,8 @@ public class BuildJobExecutionService { @Value("${artemis.version-control.default-branch:main}") private String defaultBranch; + private static final Duration TEMP_DIR_RETENTION_PERIOD = Duration.ofMinutes(5); + public BuildJobExecutionService(BuildJobContainerService buildJobContainerService, BuildJobGitService buildJobGitService, BuildAgentDockerService buildAgentDockerService, BuildLogsMap buildLogsMap) { this.buildJobContainerService = buildJobContainerService; @@ -79,6 +87,38 @@ public BuildJobExecutionService(BuildJobContainerService buildJobContainerServic this.buildLogsMap = buildLogsMap; } + /** + * This method is responsible for cleaning up temporary directories that were used for checking out repositories. + * It is triggered when the application is ready and runs asynchronously. + */ + @EventListener(ApplicationReadyEvent.class) + @Async + public void initAsync() { + final ZonedDateTime currentTime = ZonedDateTime.now(); + cleanUpTempDirectoriesAsync(currentTime); + } + + private void cleanUpTempDirectoriesAsync(ZonedDateTime currentTime) { + log.info("Cleaning up temporary directories in {}", CHECKED_OUT_REPOS_TEMP_DIR); + try (DirectoryStream directoryStream = Files.newDirectoryStream(Path.of(CHECKED_OUT_REPOS_TEMP_DIR))) { + for (Path path : directoryStream) { + try { + ZonedDateTime lastModifiedTime = ZonedDateTime.ofInstant(Files.getLastModifiedTime(path).toInstant(), currentTime.getZone()); + if (Files.isDirectory(path) && lastModifiedTime.isBefore(currentTime.minus(TEMP_DIR_RETENTION_PERIOD))) { + FileUtils.deleteDirectory(path.toFile()); + } + } + catch (IOException e) { + log.error("Could not delete temporary directory {}", path, e); + } + } + } + catch (IOException e) { + log.error("Could not delete temporary directories", e); + } + log.info("Clean up of temporary directories in {} completed.", CHECKED_OUT_REPOS_TEMP_DIR); + } + /** * Orchestrates the execution of a build job in a Docker container. This method handles the preparation and configuration of the container, * including cloning the necessary repositories, checking out the appropriate branches, and preparing the environment for the build. @@ -245,7 +285,8 @@ private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); log.debug(msg); buildJobContainerService.populateBuildJobContainer(containerId, assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, - buildJob.repositoryInfo().auxiliaryRepositoryCheckoutDirectories(), buildJob.buildConfig().programmingLanguage()); + buildJob.repositoryInfo().auxiliaryRepositoryCheckoutDirectories(), buildJob.buildConfig().programmingLanguage(), buildJob.buildConfig().assignmentCheckoutPath(), + buildJob.buildConfig().testCheckoutPath(), buildJob.buildConfig().solutionCheckoutPath()); msg = "~~~~~~~~~~~~~~~~~~~~ Executing Build Script for Build job " + buildJob.id() + " ~~~~~~~~~~~~~~~~~~~~"; buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); @@ -511,15 +552,16 @@ private void deleteCloneRepo(VcsRepositoryUri repositoryUri, @Nullable String co } buildJobGitService.deleteLocalRepository(repository); } + // Do not throw an exception if deletion fails. If an exception occurs, clean up will happen in the next server start. catch (EntityNotFoundException e) { msg = "Error while checking out repository"; buildLogsMap.appendBuildLogEntry(buildJobId, msg); - throw new LocalCIException(msg, e); + log.error("Error while deleting repository with URI {} and Path {}", repositoryUri, repositoryPath, e); } catch (IOException e) { msg = "Error while deleting repository"; buildLogsMap.appendBuildLogEntry(buildJobId, msg); - throw new LocalCIException(msg, e); + log.error("Error while deleting repository with URI {} and Path {}", repositoryUri, repositoryPath, e); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobManagementService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobManagementService.java index f514d59496cd..b57d0a45323d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobManagementService.java @@ -58,7 +58,7 @@ public class BuildJobManagementService { private final ReentrantLock lock = new ReentrantLock(); - @Value("${artemis.continuous-integration.timeout-seconds:240}") + @Value("${artemis.continuous-integration.timeout-seconds:120}") private int timeoutSeconds; @Value("${artemis.continuous-integration.asynchronous:true}") @@ -74,6 +74,8 @@ public class BuildJobManagementService { */ private final Map> runningFutures = new ConcurrentHashMap<>(); + private final Map> runningFuturesWrapper = new ConcurrentHashMap<>(); + /** * A set that contains all build jobs that were cancelled by the user. * This set is unique for each node and contains only the build jobs that were cancelled on this node. @@ -149,9 +151,17 @@ public CompletableFuture executeBuildJob(BuildJobQueueItem buildJob lock.unlock(); } + int buildJobTimeoutSeconds; + if (buildJobItem.buildConfig().timeoutSeconds() != 0 && buildJobItem.buildConfig().timeoutSeconds() < this.timeoutSeconds) { + buildJobTimeoutSeconds = buildJobItem.buildConfig().timeoutSeconds(); + } + else { + buildJobTimeoutSeconds = this.timeoutSeconds; + } + CompletableFuture futureResult = createCompletableFuture(() -> { try { - return future.get(timeoutSeconds, TimeUnit.SECONDS); + return future.get(buildJobTimeoutSeconds, TimeUnit.SECONDS); } catch (Exception e) { // RejectedExecutionException is thrown if the queue size limit (defined in "artemis.continuous-integration.queue-size-limit") is reached. @@ -170,9 +180,20 @@ public CompletableFuture executeBuildJob(BuildJobQueueItem buildJob } } }); - futureResult.whenComplete(((result, throwable) -> runningFutures.remove(buildJobItem.id()))); - return futureResult; + runningFuturesWrapper.put(buildJobItem.id(), futureResult); + return futureResult.whenComplete(((result, throwable) -> { + runningFutures.remove(buildJobItem.id()); + runningFuturesWrapper.remove(buildJobItem.id()); + })); + } + + Set getRunningBuildJobIds() { + return Set.copyOf(runningFutures.keySet()); + } + + CompletableFuture getRunningBuildJobFutureWrapper(String buildJobId) { + return runningFuturesWrapper.get(buildJobId); } /** @@ -227,7 +248,7 @@ private void finishBuildJobExceptionally(String buildJobId, String containerName * * @param buildJobId The id of the build job that should be cancelled. */ - private void cancelBuildJob(String buildJobId) { + void cancelBuildJob(String buildJobId) { Future future = runningFutures.get(buildJobId); if (future != null) { try { 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 7534de04e3bf..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 @@ -2,6 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_BUILDAGENT; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -10,20 +11,29 @@ import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import jakarta.annotation.PostConstruct; 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; +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.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -32,8 +42,11 @@ import com.hazelcast.collection.ItemEvent; import com.hazelcast.collection.ItemListener; import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; 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; @@ -64,6 +77,8 @@ public class SharedQueueProcessingService { private final BuildAgentSshKeyService buildAgentSSHKeyService; + private final TaskScheduler taskScheduler; + private IQueue queue; private IQueue resultQueue; @@ -80,32 +95,119 @@ public class SharedQueueProcessingService { */ private final ReentrantLock instanceLock = new ReentrantLock(); + /** + * Lock for pausing and resuming the build agent. + */ + private final ReentrantLock pauseResumeLock = new ReentrantLock(); + private UUID listenerId; + /** + * Scheduled future for checking availability and processing next build job. + */ + private ScheduledFuture scheduledFuture; + + /** + * Flag to indicate whether the build agent is paused. + */ + private final AtomicBoolean isPaused = new AtomicBoolean(false); + + /** + * Flag to indicate whether the build agent should process build results. This is necessary to differentiate between when the build agent is paused and grace period is not over + * yet. + */ + private final AtomicBoolean processResults = new AtomicBoolean(true); + + @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) { + BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap, BuildAgentSshKeyService buildAgentSSHKeyService, TaskScheduler taskScheduler) { this.hazelcastInstance = hazelcastInstance; this.localCIBuildExecutorService = (ThreadPoolExecutor) localCIBuildExecutorService; this.buildJobManagementService = buildJobManagementService; this.buildLogsMap = buildLogsMap; this.buildAgentSSHKeyService = buildAgentSSHKeyService; + this.taskScheduler = taskScheduler; } /** * Initialize relevant data from hazelcast */ - @PostConstruct + @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"); this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); + // Remove listener if already present + if (this.listenerId != null) { + this.queue.removeItemListener(this.listenerId); + } this.listenerId = this.queue.addItemListener(new QueuedBuildJobItemListener(), true); + + /* + * Check every 10 seconds whether the node has at least one thread available for a new build job. + * If so, process the next build job. + * This is a backup mechanism in case the build queue is not empty, no new build jobs are entering the queue and the + * node otherwise stopped checking for build jobs in the queue. + */ + scheduledFuture = taskScheduler.scheduleAtFixedRate(this::checkAvailabilityAndProcessNextBuild, Duration.ofSeconds(10)); + + ITopic pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); + pauseBuildAgentTopic.addMessageListener(message -> { + if (buildAgentShortName.equals(message.getMessageObject())) { + pauseBuildAgent(); + } + }); + + ITopic resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); + resumeBuildAgentTopic.addMessageListener(message -> { + if (buildAgentShortName.equals(message.getMessageObject())) { + resumeBuildAgent(); + } + }); } @PreDestroy - public void removeListener() { - this.queue.removeItemListener(this.listenerId); + public void removeListenerAndCancelScheduledFuture() { + removeListener(); + cancelCheckAvailabilityAndProcessNextBuildScheduledFuture(); + } + + private void removeListener() { + // check if Hazelcast is still active, before invoking this + try { + if (hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning()) { + this.queue.removeItemListener(this.listenerId); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to remove listener from SharedQueueProcessingService as Hazelcast instance is not active any more."); + } + } + + private void cancelCheckAvailabilityAndProcessNextBuildScheduledFuture() { + if (scheduledFuture != null && !scheduledFuture.isCancelled()) { + scheduledFuture.cancel(false); + } } /** @@ -118,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(); @@ -127,23 +230,12 @@ public void updateBuildAgentInformation() { } } - /** - * Check every 10 seconds whether the node has at least one thread available for a new build job. - * If so, process the next build job. - * This is a backup mechanism in case the build queue is not empty, no new build jobs are entering the queue and the - * node otherwise stopped checking for build jobs in the queue. - */ - @Scheduled(fixedRate = 10000) - public void checkForBuildJobs() { - checkAvailabilityAndProcessNextBuild(); - } - /** * Checks whether the node has at least one thread available for a new build job. * If so, process the next build job. */ private void checkAvailabilityAndProcessNextBuild() { - if (noDataMemberInClusterAvailable(hazelcastInstance)) { + if (noDataMemberInClusterAvailable(hazelcastInstance) || queue == null) { log.debug("There are only lite member in the cluster. Not processing build jobs."); return; } @@ -158,14 +250,14 @@ private void checkAvailabilityAndProcessNextBuild() { return; } - if (queue.isEmpty()) { + if (queue.isEmpty() || isPaused.get()) { return; } BuildJobQueueItem buildJob = null; instanceLock.lock(); try { // Recheck conditions after acquiring the lock to ensure they are still valid - if (!nodeIsAvailable() || queue.isEmpty()) { + if (!nodeIsAvailable() || queue.isEmpty() || isPaused.get()) { return; } @@ -181,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(); @@ -203,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(); @@ -225,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 { @@ -241,7 +333,9 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue List processingJobsOfMember = getProcessingJobsOfNode(memberAddress); int numberOfCurrentBuildJobs = processingJobsOfMember.size(); int maxNumberOfConcurrentBuilds = localCIBuildExecutorService.getMaximumPoolSize(); - boolean active = numberOfCurrentBuildJobs > 0; + boolean hasJobs = numberOfCurrentBuildJobs > 0; + BuildAgentInformation.BuildAgentStatus status = isPaused.get() ? BuildAgentInformation.BuildAgentStatus.PAUSED + : hasJobs ? BuildAgentInformation.BuildAgentStatus.ACTIVE : BuildAgentInformation.BuildAgentStatus.IDLE; BuildAgentInformation agent = buildAgentInformation.get(memberAddress); List recentBuildJobs; if (agent != null) { @@ -260,11 +354,13 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue String publicSshKey = buildAgentSSHKeyService.getPublicKeyAsString(); - return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, active, 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() { @@ -297,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); @@ -305,7 +401,12 @@ private void processBuild(BuildJobQueueItem buildJob) { buildLogsMap.removeBuildLogs(buildJob.id()); ResultQueueItem resultQueueItem = new ResultQueueItem(buildResult, finishedJob, buildLogs, null); - resultQueue.add(resultQueueItem); + if (processResults.get()) { + resultQueue.add(resultQueueItem); + } + else { + log.info("Build agent is paused. Not adding build result to result queue for build job: {}", buildJob); + } // after processing a build job, remove it from the processing jobs processingJobs.remove(buildJob.id()); @@ -342,7 +443,12 @@ private void processBuild(BuildJobQueueItem buildJob) { failedResult.setBuildLogEntries(buildLogs); ResultQueueItem resultQueueItem = new ResultQueueItem(failedResult, job, buildLogs, ex); - resultQueue.add(resultQueueItem); + if (processResults.get()) { + resultQueue.add(resultQueueItem); + } + else { + log.info("Build agent is paused. Not adding build result to result queue for build job: {}", buildJob); + } processingJobs.remove(buildJob.id()); localProcessingJobs.decrementAndGet(); @@ -353,6 +459,90 @@ private void processBuild(BuildJobQueueItem buildJob) { }); } + private void pauseBuildAgent() { + if (isPaused.get()) { + log.info("Build agent is already paused"); + return; + } + + pauseResumeLock.lock(); + try { + log.info("Pausing build agent with address {}", hazelcastInstance.getCluster().getLocalMember().getAddress().toString()); + + isPaused.set(true); + removeListenerAndCancelScheduledFuture(); + updateLocalBuildAgentInformation(); + + log.info("Gracefully cancelling running build jobs"); + + Set runningBuildJobIds = buildJobManagementService.getRunningBuildJobIds(); + if (runningBuildJobIds.isEmpty()) { + log.info("No running build jobs to cancel"); + } + else { + List> runningFuturesWrapper = runningBuildJobIds.stream().map(buildJobManagementService::getRunningBuildJobFutureWrapper) + .filter(Objects::nonNull).toList(); + + if (!runningFuturesWrapper.isEmpty()) { + CompletableFuture allFuturesWrapper = CompletableFuture.allOf(runningFuturesWrapper.toArray(new CompletableFuture[0])); + + try { + allFuturesWrapper.get(pauseGracePeriodSeconds, TimeUnit.SECONDS); + log.info("All running build jobs finished during grace period"); + } + catch (TimeoutException e) { + handleTimeoutAndCancelRunningJobs(); + } + catch (InterruptedException | ExecutionException e) { + log.error("Error while waiting for running build jobs to finish", e); + } + } + } + } + finally { + pauseResumeLock.unlock(); + } + } + + private void handleTimeoutAndCancelRunningJobs() { + if (!isPaused.get()) { + log.info("Build agent was resumed before the build jobs could be cancelled"); + return; + } + log.info("Grace period exceeded. Cancelling running build jobs."); + + processResults.set(false); + Set runningBuildJobIdsAfterGracePeriod = buildJobManagementService.getRunningBuildJobIds(); + List runningBuildJobsAfterGracePeriod = processingJobs.getAll(runningBuildJobIdsAfterGracePeriod).values().stream().toList(); + runningBuildJobIdsAfterGracePeriod.forEach(buildJobManagementService::cancelBuildJob); + queue.addAll(runningBuildJobsAfterGracePeriod); + log.info("Cancelled running build jobs and added them back to the queue with Ids {}", runningBuildJobIdsAfterGracePeriod); + log.debug("Cancelled running build jobs: {}", runningBuildJobsAfterGracePeriod); + } + + private void resumeBuildAgent() { + if (!isPaused.get()) { + log.info("Build agent is already running"); + return; + } + + pauseResumeLock.lock(); + try { + log.info("Resuming build agent with address {}", hazelcastInstance.getCluster().getLocalMember().getAddress().toString()); + isPaused.set(false); + processResults.set(true); + // We remove the listener and scheduledTask first to avoid having multiple listeners and scheduled tasks running + removeListenerAndCancelScheduledFuture(); + listenerId = queue.addItemListener(new QueuedBuildJobItemListener(), true); + scheduledFuture = taskScheduler.scheduleAtFixedRate(this::checkAvailabilityAndProcessNextBuild, Duration.ofSeconds(10)); + checkAvailabilityAndProcessNextBuild(); + updateLocalBuildAgentInformation(); + } + finally { + pauseResumeLock.unlock(); + } + } + /** * Checks whether the node has at least one thread available for a new build job. */ diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/TestResultXmlParser.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/TestResultXmlParser.java index 5e2672e4b426..50c3c5925f97 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/TestResultXmlParser.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/TestResultXmlParser.java @@ -33,22 +33,8 @@ public static void processTestResultFile(String testResultFileString, List element - // And parse the inner test suites - TestSuites suites = mapper.readValue(testResultFileString, TestSuites.class); - if (suites.testsuites() == null) { - return; - } - - for (TestSuite suite : suites.testsuites()) { - processTestSuite(suite, failedTests, successfulTests); - } - } + // A toplevel element is parsed like a + processTestSuite(testSuite, failedTests, successfulTests); } private static void processTestSuite(TestSuite testSuite, List failedTests, List successfulTests) { @@ -64,17 +50,19 @@ private static void processTestSuite(TestSuite testSuite, List testsuites) { + for (TestSuite suite : testSuite.testSuites()) { + processTestSuite(suite, failedTests, successfulTests); + } } @JsonIgnoreProperties(ignoreUnknown = true) - record TestSuite(@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "testcase") List testCases) { + record TestSuite(@JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "testcase") List testCases, + @JacksonXmlElementWrapper(useWrapping = false) @JacksonXmlProperty(localName = "testsuite") List testSuites) { TestSuite { testCases = Objects.requireNonNullElse(testCases, Collections.emptyList()); + testSuites = Objects.requireNonNullElse(testSuites, Collections.emptyList()); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java index d3bf4e217ea3..4ff2d48fedf5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java @@ -167,6 +167,10 @@ public void addTag(String tag) { this.tags.add(tag); } + public void setCourse(Course course) { + this.course = course; + } + public Conversation getConversation() { return conversation; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java index c6bfd3384110..b9a911ce6194 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java @@ -106,7 +106,9 @@ public boolean equals(Object object) { return false; } PushNotificationDeviceConfiguration that = (PushNotificationDeviceConfiguration) object; - return token.equals(that.token) && deviceType == that.deviceType && expirationDate.equals(that.expirationDate) && Arrays.equals(secretKey, that.secretKey) + // Use compareTo rather than equals for dates to ensure timestamps and dates with the same time are considered equal + // This is caused by Java internal design having different classes for Date (java.util) and Timestamp (java.sql) + return token.equals(that.token) && deviceType == that.deviceType && expirationDate.compareTo(that.expirationDate) == 0 && Arrays.equals(secretKey, that.secretKey) && owner.equals(that.owner); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java index c6a915b994e2..db61138b3a73 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java @@ -30,4 +30,6 @@ default AnswerPost findAnswerPostByIdElseThrow(Long answerPostId) { default AnswerPost findAnswerMessageByIdElseThrow(Long answerPostId) { return getValueElseThrow(findById(answerPostId).filter(answerPost -> answerPost.getPost().getConversation() != null), answerPostId); } + + long countAnswerPostsByPostIdIn(List postIds); } 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/repository/PostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java index 449a629fb4af..aacfbc33d179 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java @@ -45,4 +45,8 @@ default Post findPostByIdElseThrow(Long postId) throws EntityNotFoundException { default Post findPostOrMessagePostByIdElseThrow(Long postId) throws EntityNotFoundException { return getValueElseThrow(findById(postId), postId); } + + List findAllByConversationId(Long conversationId); + + List findAllByCourseId(Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java index fddce3b9e6cd..fc6f7e9e1256 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/MailService.java @@ -8,6 +8,8 @@ import java.util.Locale; import java.util.Set; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -261,6 +263,18 @@ public void sendNotification(Notification notification, User user, Object notifi // posts use a different mechanism for the url context.setVariable(NOTIFICATION_URL, extractNotificationUrl(post, artemisServerUrl.toString())); subject = createAnnouncementText(notificationSubject, locale); + + // Render markdown content of post to html + try { + Parser parser = Parser.builder().build(); + HtmlRenderer renderer = HtmlRenderer.builder().build(); + String postContent = post.getContent(); + String renderedPostContent = renderer.render(parser.parse(postContent)); + post.setContent(renderedPostContent); + } + catch (Exception e) { + // In case something goes wrong, leave content of post as-is + } } else { context.setVariable(NOTIFICATION_URL, extractNotificationUrl(notification, artemisServerUrl.toString())); 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/communication/web/admin/AdminSystemNotificationResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/admin/AdminSystemNotificationResource.java index 6850598633e9..4e94766284d6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/admin/AdminSystemNotificationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/admin/AdminSystemNotificationResource.java @@ -30,6 +30,7 @@ * REST controller for administrating system notifications. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminSystemNotificationResource { @@ -58,7 +59,6 @@ public AdminSystemNotificationResource(SystemNotificationRepository systemNotifi * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("system-notifications") - @EnforceAdmin public ResponseEntity createSystemNotification(@RequestBody SystemNotification systemNotification) throws URISyntaxException { log.debug("REST request to save SystemNotification : {}", systemNotification); if (systemNotification.getId() != null) { @@ -79,7 +79,6 @@ public ResponseEntity createSystemNotification(@RequestBody System * status 500 (Internal Server Error) if the system notification couldn't be updated */ @PutMapping("system-notifications") - @EnforceAdmin public ResponseEntity updateSystemNotification(@RequestBody SystemNotification systemNotification) { log.debug("REST request to update SystemNotification : {}", systemNotification); if (systemNotification.getId() == null) { @@ -101,7 +100,6 @@ public ResponseEntity updateSystemNotification(@RequestBody * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("system-notifications/{notificationId}") - @EnforceAdmin public ResponseEntity deleteSystemNotification(@PathVariable Long notificationId) { log.debug("REST request to delete SystemNotification : {}", notificationId); systemNotificationRepository.deleteById(notificationId); 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 71e4dc0a5775..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 @@ -93,6 +93,8 @@ public final class Constants { // Used to cut off CI specific path segments when receiving static code analysis reports public static final String ASSIGNMENT_DIRECTORY = "/" + ASSIGNMENT_REPO_NAME + "/"; + public static final String TEST_WORKING_DIRECTORY = "test"; + // Used as a value for for the Java template pom.xml public static final String STUDENT_WORKING_DIRECTORY = ASSIGNMENT_DIRECTORY + "src"; @@ -106,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 @@ -390,6 +395,18 @@ public final class Constants { */ public static final int MIN_SCORE_ORANGE = 40; + public static final String ASSIGNMENT_REPO_PLACEHOLDER = "${studentWorkingDirectory}"; + + public static final String TEST_REPO_PLACEHOLDER = "${testWorkingDirectory}"; + + public static final String SOLUTION_REPO_PLACEHOLDER = "${solutionWorkingDirectory}"; + + public static final String ASSIGNMENT_REPO_PARENT_PLACEHOLDER = "${studentParentWorkingDirectoryName}"; + + public static final String ASSIGNMENT_REPO_PLACEHOLDER_NO_SLASH = "${studentWorkingDirectoryNoSlash}"; + + public static final Pattern ALLOWED_CHECKOUT_DIRECTORY = Pattern.compile("[\\w-]+(/[\\w-]+)*$"); + private Constants() { } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/PublicResourcesConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/PublicResourcesConfiguration.java index 2d7adf676f74..e979c66b6183 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/PublicResourcesConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/PublicResourcesConfiguration.java @@ -10,6 +10,7 @@ import jakarta.validation.constraints.NotNull; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.http.CacheControl; @@ -32,6 +33,9 @@ public PublicResourcesConfiguration(JHipsterProperties jHipsterProperties) { this.jHipsterProperties = jHipsterProperties; } + @Value("${artemis.file-upload-path}") + private String fileUploadPath; + @Override public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) { // Enable static resource serving in general from "/public" from both classpath and hosts filesystem @@ -46,6 +50,19 @@ public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) { addResourceHandlerForPath(registry, "images", "about").setCacheControl(defaultCacheControl); addResourceHandlerForPath(registry, "emoji").setCacheControl(defaultCacheControl); + + // Add caching for course icons, user profile pictures, and drag and drop quiz pictures + // Add resource handlers for dynamic image paths based on fileUploadPath + // TODO: those paths have to be the same as in FilePathService, ideally we reuse the constants and define them only once + registry.addResourceHandler("/course/icons/**").addResourceLocations("file:" + fileUploadPath + "/images/course/icons/").setCacheControl(defaultCacheControl); + + registry.addResourceHandler("/user/profile-pictures/**").addResourceLocations("file:" + fileUploadPath + "/images/user/profile-pictures/") + .setCacheControl(defaultCacheControl); + + registry.addResourceHandler("/drag-and-drop/**").addResourceLocations("file:" + fileUploadPath + "/images/drag-and-drop/").setCacheControl(defaultCacheControl); + + // e.g. public/videos/course-competencies/create-competencies.gif + addResourceHandlerForPath(registry, "videos", "course-competencies").setCacheControl(defaultCacheControl); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java index 1e38341e6ae9..3153723dc5a8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java @@ -62,7 +62,6 @@ import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; 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.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; @@ -306,8 +305,7 @@ private boolean allowSubscription(@Nullable Principal principal, String destinat // TODO: Is it right that TAs are not allowed to subscribe to exam exercises? if (exerciseRepository.isExamExercise(exerciseId)) { - Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); - return authorizationCheckService.isAtLeastInstructorInCourse(login, exercise.getCourseViaExerciseGroupOrCourseMember().getId()); + return authorizationCheckService.isAtLeastInstructorInExercise(login, exerciseId); } else { return authorizationCheckService.isAtLeastTeachingAssistantInExercise(login, exerciseId); 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/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 7fa995658af1..6498340f3bc2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -561,4 +561,9 @@ public void hasAcceptedIrisElseThrow() { public String getSshPublicKey() { return sshPublicKey; } + + @Nullable + public @Size(max = 100) String getSshPublicKeyHash() { + return sshPublicKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java new file mode 100644 index 000000000000..2f3b8d51596c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.core.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseDeletionSummaryDTO(long numberOfBuilds, long numberOfCommunicationPosts, long numberOfAnswerPosts) { +} 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/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 1ac72940f919..3d627425a8f7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -78,6 +78,8 @@ public class UserDTO extends AuditingEntityDTO { private String sshPublicKey; + private String sshKeyHash; + private ZonedDateTime irisAccepted; public UserDTO() { @@ -291,4 +293,12 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } + + public String getSshKeyHash() { + return sshKeyHash; + } + + public void setSshKeyHash(String sshKeyHash) { + this.sshKeyHash = sshKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java index a6da8966dfc5..f84bf9e0819a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java @@ -25,6 +25,8 @@ public class UserPublicInfoDTO { private String lastName; + private String imageUrl; + private Boolean isInstructor; private Boolean isEditor; @@ -43,6 +45,7 @@ public UserPublicInfoDTO(User user) { this.name = user.getName(); this.firstName = user.getFirstName(); this.lastName = user.getLastName(); + this.imageUrl = user.getImageUrl(); } /** @@ -101,6 +104,14 @@ public void setLastName(String lastName) { this.lastName = lastName; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public Boolean getIsInstructor() { return isInstructor; } @@ -152,6 +163,7 @@ public int hashCode() { @Override public String toString() { return "UserPublicInfoDTO{" + "id=" + id + ", login='" + login + '\'' + ", name='" + name + '\'' + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' - + ", isInstructor=" + isInstructor + ", isEditor=" + isEditor + ", isTeachingAssistant=" + isTeachingAssistant + ", isStudent=" + isStudent + '}'; + + ", imageUrl='" + imageUrl + '\'' + ", isInstructor=" + isInstructor + ", isEditor=" + isEditor + ", isTeachingAssistant=" + isTeachingAssistant + ", isStudent=" + + isStudent + '}'; } } 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 fa3bba8a4b73..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; @@ -322,6 +323,14 @@ GROUP BY SUBSTRING(CAST(s.submissionDate AS string), 1, 10), p.student.login """) List findAllNotEndedCoursesByManagementGroupNames(@Param("now") ZonedDateTime now, @Param("userGroups") List userGroups); + @Query(""" + SELECT COUNT(DISTINCT ug.userId) + FROM Course c + JOIN UserGroup ug ON c.studentGroupName = ug.group + WHERE c.id = :courseId + """) + int countCourseStudents(@Param("courseId") long courseId); + /** * Counts the number of members of a course, i.e. users that are a member of the course's student, tutor, editor or instructor group. * Users that are part of multiple groups are NOT counted multiple times. @@ -534,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/security/annotations/AnnotationUtils.java b/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/AnnotationUtils.java index c5aef335defb..028342bfeee9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/AnnotationUtils.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/AnnotationUtils.java @@ -3,7 +3,9 @@ import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Optional; import jakarta.validation.constraints.NotBlank; @@ -22,7 +24,8 @@ private AnnotationUtils() { } /** - * Extracts the annotation from the method or type + * Extracts the annotation from the method or type and all super classes. + * In case multiple versions of the annotation are present, the one closest to the method is returned. * * @param clazz the annotation class * @param joinPoint the join point @@ -33,26 +36,71 @@ private AnnotationUtils() { public static Optional getAnnotation(@NotNull Class clazz, @NotNull ProceedingJoinPoint joinPoint) { final var method = ((MethodSignature) joinPoint.getSignature()).getMethod(); T annotation = method.getAnnotation(clazz); + + Optional foundAnnotation = getAnnotation(clazz, method.getDeclaredAnnotations(), annotation); + if (foundAnnotation.isPresent()) { + return foundAnnotation; + } + + for (Class declaringClass = method.getDeclaringClass(); declaringClass != null; declaringClass = declaringClass.getSuperclass()) { + annotation = declaringClass.getAnnotation(clazz); + foundAnnotation = getAnnotation(clazz, declaringClass.getDeclaredAnnotations(), annotation); + if (foundAnnotation.isPresent()) { + return foundAnnotation; + } + } + + return Optional.empty(); + } + + private static Optional getAnnotation(Class clazz, Annotation[] declaredAnnotations, T annotation) { if (annotation != null) { return Optional.of(annotation); } - for (Annotation a : method.getDeclaredAnnotations()) { + for (Annotation a : declaredAnnotations) { annotation = a.annotationType().getAnnotation(clazz); if (annotation != null) { return Optional.of(annotation); } } - annotation = method.getDeclaringClass().getAnnotation(clazz); + return Optional.empty(); + } + + /** + * Extracts the annotations from the method or type and all super classes. + * In case multiple versions of the annotation are present, all are returned. + * + * @param clazz the annotation class + * @param joinPoint the join point + * @param the type of the annotation + * @return the annotations if they are present, empty otherwise + */ + public static List getAnnotations(@NotNull Class clazz, @NotNull ProceedingJoinPoint joinPoint) { + List annotations = new ArrayList<>(); + + final var method = ((MethodSignature) joinPoint.getSignature()).getMethod(); + T annotation = method.getAnnotation(clazz); + + addAnnotations(clazz, method.getDeclaredAnnotations(), annotation, annotations); + + for (Class declaringClass = method.getDeclaringClass(); declaringClass != null; declaringClass = declaringClass.getSuperclass()) { + annotation = declaringClass.getAnnotation(clazz); + addAnnotations(clazz, declaringClass.getDeclaredAnnotations(), annotation, annotations); + } + + return annotations; + } + + private static void addAnnotations(Class clazz, Annotation[] declaredAnnotations, T annotation, List annotations) { if (annotation != null) { - return Optional.of(annotation); + annotations.add(annotation); } - for (Annotation a : method.getDeclaringClass().getDeclaredAnnotations()) { + for (Annotation a : declaredAnnotations) { annotation = a.annotationType().getAnnotation(clazz); if (annotation != null) { - return Optional.of(annotation); + annotations.add(annotation); } } - return Optional.empty(); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/EnforceAdmin.java b/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/EnforceAdmin.java index 5adbcd73c16c..9fdbf88d9d82 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/EnforceAdmin.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/annotations/EnforceAdmin.java @@ -10,11 +10,8 @@ /** * This annotation is used to enforce that the user is an admin. * It should only be used with endpoints starting with {@code /api/admin/} - *

- * It's only addable to methods. The intention is that a developer can see the required role without the need to scroll up. - * This also prevents overrides of the annotation. */ -@Target(ElementType.METHOD) +@Target({ ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @PreAuthorize("hasRole('ADMIN')") public @interface EnforceAdmin { 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 01bb68edc441..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 @@ -58,9 +58,12 @@ import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.communication.domain.NotificationType; +import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.notification.GroupNotification; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.communication.repository.GroupNotificationRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; import de.tum.cit.aet.artemis.core.config.Constants; @@ -68,6 +71,8 @@ import de.tum.cit.aet.artemis.core.domain.DomainObject; 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; @@ -104,6 +109,7 @@ import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismCase; import de.tum.cit.aet.artemis.plagiarism.repository.PlagiarismCaseRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.tutorialgroup.repository.TutorialGroupNotificationRepository; import de.tum.cit.aet.artemis.tutorialgroup.repository.TutorialGroupRepository; @@ -201,6 +207,12 @@ public class CourseService { private final TutorialGroupNotificationRepository tutorialGroupNotificationRepository; + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + + private final BuildJobRepository buildJobRepository; + public CourseService(CourseRepository courseRepository, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, AuthorizationCheckService authCheckService, UserRepository userRepository, LectureService lectureService, GroupNotificationRepository groupNotificationRepository, ExerciseGroupRepository exerciseGroupRepository, AuditEventRepository auditEventRepository, UserService userService, ExamDeletionService examDeletionService, @@ -213,7 +225,8 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise TutorialGroupRepository tutorialGroupRepository, PlagiarismCaseRepository plagiarismCaseRepository, ConversationRepository conversationRepository, LearningPathService learningPathService, Optional irisSettingsService, LectureRepository lectureRepository, TutorialGroupNotificationRepository tutorialGroupNotificationRepository, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, FaqRepository faqRepository) { + PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, PostRepository postRepository, + AnswerPostRepository answerPostRepository, BuildJobRepository buildJobRepository, FaqRepository faqRepository) { this.courseRepository = courseRepository; this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; @@ -253,6 +266,9 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.prerequisiteRepository = prerequisiteRepository; this.competencyRelationRepository = competencyRelationRepository; + this.buildJobRepository = buildJobRepository; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; this.faqRepository = faqRepository; } @@ -444,6 +460,22 @@ public Set findAllOnlineCoursesForPlatformForUser(String registrationId, .collect(Collectors.toSet()); } + /** + * Get the course deletion summary for the given course. + * + * @param course the course for which to get the deletion summary + * @return the course deletion summary + */ + public CourseDeletionSummaryDTO getDeletionSummary(Course course) { + List programmingExerciseIds = course.getExercises().stream().map(Exercise::getId).toList(); + long numberOfBuilds = buildJobRepository.countBuildJobsByExerciseIds(programmingExerciseIds); + + List posts = postRepository.findAllByCourseId(course.getId()); + long numberOfCommunicationPosts = posts.size(); + long numberOfAnswerPosts = answerPostRepository.countAnswerPostsByPostIdIn(posts.stream().map(Post::getId).toList()); + return new CourseDeletionSummaryDTO(numberOfBuilds, numberOfCommunicationPosts, numberOfAnswerPosts); + } + /** * Deletes all elements associated with the course including: *

    @@ -661,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/TelemetrySendingService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/TelemetrySendingService.java deleted file mode 100644 index f3d50f64722c..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/TelemetrySendingService.java +++ /dev/null @@ -1,89 +0,0 @@ -package de.tum.cit.aet.artemis.core.service; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; - -import java.util.Arrays; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.core.env.Environment; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; - -@Service -@Profile(PROFILE_SCHEDULING) -public class TelemetrySendingService { - - private static final Logger log = LoggerFactory.getLogger(TelemetrySendingService.class); - - @JsonInclude(JsonInclude.Include.NON_EMPTY) - public record TelemetryData(String version, String serverUrl, String operator, String contact, List profiles, String adminName) { - } - - private final Environment env; - - private final RestTemplate restTemplate; - - public TelemetrySendingService(Environment env, RestTemplate restTemplate) { - this.env = env; - this.restTemplate = restTemplate; - } - - @Value("${artemis.version}") - private String version; - - @Value("${server.url}") - private String serverUrl; - - @Value("${info.operatorName}") - private String operator; - - @Value("${info.operatorAdminName}") - private String operatorAdminName; - - @Value("${info.contact}") - private String contact; - - @Value("${artemis.telemetry.sendAdminDetails}") - private boolean sendAdminDetails; - - @Value("${artemis.telemetry.destination}") - private String destination; - - /** - * Assembles the telemetry data, and sends it to the external telemetry server. - * - * @throws Exception if the writing the telemetry data to a json format fails, or the connection to the telemetry server fails - */ - @Async - public void sendTelemetryByPostRequest() throws Exception { - List activeProfiles = Arrays.asList(env.getActiveProfiles()); - TelemetryData telemetryData; - if (sendAdminDetails) { - telemetryData = new TelemetryData(version, serverUrl, operator, contact, activeProfiles, operatorAdminName); - } - else { - telemetryData = new TelemetryData(version, serverUrl, operator, null, activeProfiles, null); - } - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - ObjectWriter objectWriter = new ObjectMapper().writer().withDefaultPrettyPrinter(); - - var telemetryJson = objectWriter.writeValueAsString(telemetryData); - HttpEntity requestEntity = new HttpEntity<>(telemetryJson, headers); - var response = restTemplate.postForEntity(destination + "/api/telemetry", requestEntity, String.class); - log.info("Successfully sent telemetry data. {}", response.getBody()); - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java index 4d40473c4eb9..5871cd7ed7d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Stream; import java.util.zip.ZipEntry; @@ -33,6 +34,12 @@ public class ZipFileService { private final FileService fileService; + /** + * Set of file names that should be ignored when zipping. + * This currently only includes the gc.log.lock (garbage collector) file created by JGit in programming repositories. + */ + private static final Set IGNORED_ZIP_FILE_NAMES = Set.of(Path.of("gc.log.lock")); + public ZipFileService(FileService fileService) { this.fileService = fileService; } @@ -113,7 +120,7 @@ private void createZipFileFromPathStream(Path zipFilePath, Stream paths, P if (extraFilter != null) { filteredPaths = filteredPaths.filter(extraFilter); } - filteredPaths.forEach(path -> { + filteredPaths.filter(path -> !IGNORED_ZIP_FILE_NAMES.contains(path)).forEach(path -> { ZipEntry zipEntry = new ZipEntry(pathsRoot.relativize(path).toString()); copyToZipFile(zipOutputStream, path, zipEntry); }); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleAspect.java b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleAspect.java index 87785e123cec..ffeed9cfa513 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleAspect.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/feature/FeatureToggleAspect.java @@ -1,8 +1,11 @@ package de.tum.cit.aet.artemis.core.service.feature; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static de.tum.cit.aet.artemis.core.security.annotations.AnnotationUtils.getAnnotations; import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -26,25 +29,24 @@ public FeatureToggleAspect(FeatureToggleService featureToggleService) { /** * Pointcut around all methods or classes annotated with {@link FeatureToggle}. - * - * @param featureToggle The feature toggle annotation containing the relevant features */ - @Pointcut("@within(featureToggle) || @annotation(featureToggle)") - public void callAt(FeatureToggle featureToggle) { + @Pointcut("@within(de.tum.cit.aet.artemis.core.service.feature.FeatureToggle) || @annotation(de.tum.cit.aet.artemis.core.service.feature.FeatureToggle) || execution(@(@de.tum.cit.aet.artemis.core.service.feature.FeatureToggle *) * *(..))") + protected void callAt() { } /** * Aspect around all methods for which a feature toggle has been activated. Will check all specified features and only * execute the underlying method if all features are enabled. Will otherwise return forbidden (as response entity) * - * @param joinPoint Proceeding join point of the aspect - * @param featureToggle The feature toggle annotation containing all features that should get checked + * @param joinPoint Proceeding join point of the aspect * @return The original return value of the called method, if all features are enabled, a forbidden response entity otherwise * @throws Throwable If there was any error during method execution (both the aspect or the actual called method) */ - @Around(value = "callAt(featureToggle)", argNames = "joinPoint,featureToggle") - public Object around(ProceedingJoinPoint joinPoint, FeatureToggle featureToggle) throws Throwable { - if (Arrays.stream(featureToggle.value()).allMatch(featureToggleService::isFeatureEnabled)) { + @Around(value = "callAt()", argNames = "joinPoint") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + List featureToggleAnnotations = getAnnotations(FeatureToggle.class, joinPoint); + Stream features = featureToggleAnnotations.stream().flatMap(featureToggle -> Arrays.stream(featureToggle.value())); + if (features.allMatch(featureToggleService::isFeatureEnabled)) { return joinPoint.proceed(); } else { 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/service/telemetry/TelemetrySendingService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/telemetry/TelemetrySendingService.java new file mode 100644 index 000000000000..265b96192ad0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/telemetry/TelemetrySendingService.java @@ -0,0 +1,128 @@ +package de.tum.cit.aet.artemis.core.service.telemetry; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; + +import java.util.Arrays; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.core.service.ProfileService; + +@Service +@Profile(PROFILE_SCHEDULING) +public class TelemetrySendingService { + + private static final Logger log = LoggerFactory.getLogger(TelemetrySendingService.class); + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record TelemetryData(String version, String serverUrl, String operator, List profiles, boolean isProductionInstance, boolean isTestServer, String dataSource, + String contact, String adminName) { + } + + private final Environment env; + + private final RestTemplate restTemplate; + + private final ProfileService profileService; + + public TelemetrySendingService(Environment env, RestTemplate restTemplate, ProfileService profileService) { + this.env = env; + this.restTemplate = restTemplate; + this.profileService = profileService; + } + + @Value("${artemis.version}") + private String version; + + @Value("${server.url}") + private String serverUrl; + + @Value("${info.operatorName}") + private String operator; + + @Value("${info.operatorAdminName}") + private String operatorAdminName; + + @Value("${info.contact}") + private String operatorContact; + + @Value("${artemis.telemetry.destination}") + private String destination; + + @Value("${spring.datasource.url}") + private String datasourceUrl; + + @Value("${info.test-server:false}") + private boolean isTestServer; + + /** + * Sends telemetry data to a specified destination via an HTTP POST request asynchronously. + * The telemetry includes information about the application version, environment, data source, + * and optionally, administrator details. If Eureka is enabled, the number of registered + * instances is also included. + * + *

    + * The method constructs the telemetry data object, converts it to JSON, and sends it to a + * telemetry collection server. The request is sent asynchronously due to the {@code @Async} annotation. + * + * @param sendAdminDetails a flag indicating whether to include administrator details in the + * telemetry data (such as contact information and admin name). + */ + @Async + public void sendTelemetryByPostRequest(boolean sendAdminDetails) { + + try { + String telemetryJson = new ObjectMapper().writer().withDefaultPrettyPrinter().writeValueAsString(buildTelemetryData(sendAdminDetails)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(telemetryJson, headers); + + log.info("Sending telemetry to {}", destination); + var response = restTemplate.postForEntity(destination + "/api/telemetry", requestEntity, String.class); + log.info("Successfully sent telemetry data. {}", response.getBody()); + } + catch (JsonProcessingException e) { + log.warn("JsonProcessingException in sendTelemetry.", e); + } + catch (Exception e) { + log.warn("Exception in sendTelemetry, with dst URI: {}", destination, e); + } + } + + /** + * Retrieves telemetry data for the current system configuration, including details + * about the active profiles, data source type, and optionally admin contact details. + * + * @param sendAdminDetails whether to include admin contact information in the telemetry data + * @return an instance of {@link TelemetryData} containing the gathered telemetry information + */ + private TelemetryData buildTelemetryData(boolean sendAdminDetails) { + TelemetryData telemetryData; + var dataSource = datasourceUrl.startsWith("jdbc:mysql") ? "mysql" : "postgresql"; + List activeProfiles = Arrays.asList(env.getActiveProfiles()); + + String contact = null; + String adminName = null; + if (sendAdminDetails) { + contact = operatorContact; + adminName = operatorAdminName; + } + telemetryData = new TelemetryData(version, serverUrl, operator, activeProfiles, profileService.isProductionActive(), isTestServer, dataSource, contact, adminName); + return telemetryData; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/TelemetryService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/telemetry/TelemetryService.java similarity index 54% rename from src/main/java/de/tum/cit/aet/artemis/core/service/TelemetryService.java rename to src/main/java/de/tum/cit/aet/artemis/core/service/telemetry/TelemetryService.java index 408e6c3dd514..d43f79aae256 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/TelemetryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/telemetry/TelemetryService.java @@ -1,4 +1,4 @@ -package de.tum.cit.aet.artemis.core.service; +package de.tum.cit.aet.artemis.core.service.telemetry; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; @@ -10,7 +10,7 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.core.JsonProcessingException; +import de.tum.cit.aet.artemis.core.service.ProfileService; @Service @Profile(PROFILE_SCHEDULING) @@ -22,37 +22,30 @@ public class TelemetryService { private final TelemetrySendingService telemetrySendingService; - @Value("${artemis.telemetry.enabled}") - public boolean useTelemetry; + private final boolean useTelemetry; - @Value("${artemis.telemetry.destination}") - private String destination; + private final boolean sendAdminDetails; - public TelemetryService(ProfileService profileService, TelemetrySendingService telemetrySendingService) { + public TelemetryService(ProfileService profileService, TelemetrySendingService telemetrySendingService, @Value("${artemis.telemetry.enabled}") boolean useTelemetry, + @Value("${artemis.telemetry.sendAdminDetails}") boolean sendAdminDetails) { this.profileService = profileService; this.telemetrySendingService = telemetrySendingService; + this.useTelemetry = useTelemetry; + this.sendAdminDetails = sendAdminDetails; } /** - * Sends telemetry to the server specified in artemis.telemetry.destination. - * This function runs once, at the startup of the application. - * If telemetry is disabled in artemis.telemetry.enabled, no data is sent. + * Sends telemetry data to the server after the application is ready. + * This method is triggered automatically when the application context is fully initialized. + *

    + * If telemetry is disabled (as specified by the {@code useTelemetry} flag), the task will not be executed. */ @EventListener(ApplicationReadyEvent.class) public void sendTelemetry() { if (!useTelemetry || profileService.isDevActive()) { return; } - - log.info("Sending telemetry information"); - try { - telemetrySendingService.sendTelemetryByPostRequest(); - } - catch (JsonProcessingException e) { - log.warn("JsonProcessingException in sendTelemetry.", e); - } - catch (Exception e) { - log.warn("Exception in sendTelemetry, with dst URI: {}", destination, e); - } + log.info("Start sending telemetry data asynchronously"); + telemetrySendingService.sendTelemetryByPostRequest(sendAdminDetails); } } 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 0cb3379e4f99..c1eac3ba3a66 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,7 @@ 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.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; @@ -555,6 +556,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. * 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 4413ec2f6a8d..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 @@ -12,6 +12,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -38,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; @@ -51,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; @@ -162,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 * @@ -482,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 @@ -646,7 +692,8 @@ private ResponseEntity responseEntityForFilePath(Path filePath) { if (file == null) { return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(file); + return ResponseEntity.ok().cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS)) // Cache for 30 days; + .contentType(getMediaTypeFromFilename(filePath.getFileName().toString())).body(file); } catch (IOException e) { log.error("Failed to return requested file with path {}", filePath, e); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AuditResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminAuditResource.java similarity index 96% rename from src/main/java/de/tum/cit/aet/artemis/core/web/admin/AuditResource.java rename to src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminAuditResource.java index fe8af9ff7eb0..7c0a339d9717 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AuditResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminAuditResource.java @@ -30,13 +30,14 @@ * REST controller for getting the audit events. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") -public class AuditResource { +public class AdminAuditResource { private final AuditEventService auditEventService; - public AuditResource(AuditEventService auditEventService) { + public AdminAuditResource(AuditEventService auditEventService) { this.auditEventService = auditEventService; } @@ -47,7 +48,6 @@ public AuditResource(AuditEventService auditEventService) { * @return the ResponseEntity with status 200 (OK) and the list of AuditEvents in body */ @GetMapping("audits") - @EnforceAdmin public ResponseEntity> getAll(Pageable pageable) { Page page = auditEventService.findAll(pageable); HttpHeaders headers = generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); @@ -63,7 +63,6 @@ public ResponseEntity> getAll(Pageable pageable) { * @return the ResponseEntity with status 200 (OK) and the list of AuditEvents in body */ @GetMapping(value = "audits", params = { "fromDate", "toDate" }) - @EnforceAdmin public ResponseEntity> getByDates(@RequestParam(value = "fromDate") LocalDate fromDate, @RequestParam(value = "toDate") LocalDate toDate, Pageable pageable) { Instant from = fromDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); @@ -81,7 +80,6 @@ public ResponseEntity> getByDates(@RequestParam(value = "fromDa * @return the ResponseEntity with status 200 (OK) and the AuditEvent in body, or status 404 (Not Found) */ @GetMapping("audits/{id:.+}") - @EnforceAdmin public ResponseEntity get(@PathVariable Long id) { return ResponseUtil.wrapOrNotFound(auditEventService.find(id)); } 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 bd1bcc4dea1b..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; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -33,6 +35,7 @@ import tech.jhipster.web.util.PaginationUtil; @Profile(PROFILE_LOCALCI) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminBuildJobQueueResource { @@ -54,7 +57,6 @@ public AdminBuildJobQueueResource(SharedQueueManagementService localCIBuildJobQu * @return the queued build jobs */ @GetMapping("queued-jobs") - @EnforceAdmin public ResponseEntity> getQueuedBuildJobs() { log.debug("REST request to get the queued build jobs"); List buildJobQueue = localCIBuildJobQueueService.getQueuedJobs(); @@ -67,7 +69,6 @@ public ResponseEntity> getQueuedBuildJobs() { * @return the running build jobs */ @GetMapping("running-jobs") - @EnforceAdmin public ResponseEntity> getRunningBuildJobs() { log.debug("REST request to get the running build jobs"); List runningBuildJobs = localCIBuildJobQueueService.getProcessingJobs(); @@ -80,7 +81,6 @@ public ResponseEntity> getRunningBuildJobs() { * @return list of build agents information */ @GetMapping("build-agents") - @EnforceAdmin public ResponseEntity> getBuildAgentSummary() { log.debug("REST request to get information on available build agents"); List buildAgentSummary = localCIBuildJobQueueService.getBuildAgentInformationWithoutRecentBuildJobs(); @@ -94,12 +94,14 @@ public ResponseEntity> getBuildAgentSummary() { * @return the build agent information */ @GetMapping("build-agent") - @EnforceAdmin 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()); } /** @@ -109,7 +111,6 @@ public ResponseEntity getBuildAgentDetails(@RequestParam * @return the ResponseEntity with the result of the cancellation */ @DeleteMapping("cancel-job/{buildJobId}") - @EnforceAdmin public ResponseEntity cancelBuildJob(@PathVariable String buildJobId) { log.debug("REST request to cancel the build job with id {}", buildJobId); // Call the cancelBuildJob method in LocalCIBuildJobManagementService @@ -124,7 +125,6 @@ public ResponseEntity cancelBuildJob(@PathVariable String buildJobId) { * @return the ResponseEntity with the result of the cancellation */ @DeleteMapping("cancel-all-queued-jobs") - @EnforceAdmin public ResponseEntity cancelAllQueuedBuildJobs() { log.debug("REST request to cancel all queued build jobs"); // Call the cancelAllQueuedBuildJobs method in LocalCIBuildJobManagementService @@ -139,7 +139,6 @@ public ResponseEntity cancelAllQueuedBuildJobs() { * @return the ResponseEntity with the result of the cancellation */ @DeleteMapping("cancel-all-running-jobs") - @EnforceAdmin public ResponseEntity cancelAllRunningBuildJobs() { log.debug("REST request to cancel all running build jobs"); // Call the cancelAllRunningBuildJobs method in LocalCIBuildJobManagementService @@ -155,7 +154,6 @@ public ResponseEntity cancelAllRunningBuildJobs() { * @return the ResponseEntity with the result of the cancellation */ @DeleteMapping("cancel-all-running-jobs-for-agent") - @EnforceAdmin public ResponseEntity cancelAllRunningBuildJobsForAgent(@RequestParam String agentName) { log.debug("REST request to cancel all running build jobs for agent {}", agentName); // Call the cancelAllRunningBuildJobsForAgent method in LocalCIBuildJobManagementService @@ -171,7 +169,6 @@ public ResponseEntity cancelAllRunningBuildJobsForAgent(@RequestParam Stri * @return the page of finished build jobs */ @GetMapping("finished-jobs") - @EnforceAdmin public ResponseEntity> getFinishedBuildJobs(FinishedBuildJobPageableSearchDTO search) { log.debug("REST request to get a page of finished build jobs with build status {}, build agent address {}, start date {} and end date {}", search.buildStatus(), search.buildAgentAddress(), search.startDate(), search.endDate()); @@ -190,11 +187,50 @@ public ResponseEntity> getFinishedBuildJobs(FinishedBu * @return the build job statistics */ @GetMapping("build-job-statistics") - @EnforceAdmin public ResponseEntity getBuildJobStatistics(@RequestParam(required = false, defaultValue = "7") int span) { log.debug("REST request to get the build job statistics"); List buildJobResultCountDtos = buildJobRepository.getBuildJobsResultsStatistics(ZonedDateTime.now().minusDays(span), null); BuildJobsStatisticsDTO buildJobStatistics = BuildJobsStatisticsDTO.of(buildJobResultCountDtos); return ResponseEntity.ok(buildJobStatistics); } + + /** + * {@code PUT /api/admin/agent/{agentName}/pause} : Pause the specified build agent. + * This endpoint allows administrators to pause a specific build agent by its name. + * Pausing a build agent will prevent it from picking up any new build jobs until it is resumed. + * + *

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

    + * + * @param agentName the name of the build agent to be paused (provided as a path variable) + * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully paused + * or an appropriate error response if something went wrong + */ + @PutMapping("agent/{agentName}/pause") + public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { + log.debug("REST request to pause agent {}", agentName); + localCIBuildJobQueueService.pauseBuildAgent(agentName); + return ResponseEntity.noContent().build(); + } + + /** + * {@code PUT /api/admin/agent/{agentName}/resume} : Resume the specified build agent. + * This endpoint allows administrators to resume a specific build agent by its name. + * Resuming a build agent will allow it to pick up new build jobs again. + * + *

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

    + * + * @param agentName the name of the build agent to be resumed (provided as a path variable) + * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully resumed + * or an appropriate error response if something went wrong + */ + @PutMapping("agent/{agentName}/resume") + public ResponseEntity resumeBuildAgent(@PathVariable String agentName) { + log.debug("REST request to resume agent {}", agentName); + localCIBuildJobQueueService.resumeBuildAgent(agentName); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java index a75b2e2d4a0a..6c26cab4798f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java @@ -34,6 +34,7 @@ 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.CourseDeletionSummaryDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; @@ -48,6 +49,7 @@ * REST controller for managing Course. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminCourseResource { @@ -90,7 +92,6 @@ public AdminCourseResource(UserRepository userRepository, CourseService courseSe * @return the list of groups (the user has access to) */ @GetMapping("courses/groups") - @EnforceAdmin public ResponseEntity> getAllGroupsForAllCourses() { log.debug("REST request to get all Groups for all Courses"); List courses = courseRepository.findAll(); @@ -113,7 +114,6 @@ public ResponseEntity> getAllGroupsForAllCourses() { * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping(value = "courses", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @EnforceAdmin public ResponseEntity createCourse(@RequestPart Course course, @RequestPart(required = false) MultipartFile file) throws URISyntaxException { log.debug("REST request to save Course : {}", course); if (course.getId() != null) { @@ -167,7 +167,6 @@ public ResponseEntity createCourse(@RequestPart Course course, @RequestP * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("courses/{courseId}") - @EnforceAdmin public ResponseEntity deleteCourse(@PathVariable long courseId) { log.info("REST request to delete Course : {}", courseId); Course course = courseRepository.findByIdWithExercisesAndLecturesAndLectureUnitsAndCompetenciesElseThrow(courseId); @@ -183,6 +182,20 @@ public ResponseEntity deleteCourse(@PathVariable long courseId) { return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, Course.ENTITY_NAME, course.getTitle())).build(); } + /** + * GET /courses/:courseId/deletion-summary : get the deletion summary for the course with the given id. + * + * @param courseId the id of the course + * @return the ResponseEntity with status 200 (OK) and the deletion summary in the body + */ + @GetMapping("courses/{courseId}/deletion-summary") + public ResponseEntity getDeletionSummary(@PathVariable long courseId) { + log.debug("REST request to get deletion summary course: {}", courseId); + final Course course = courseRepository.findByIdWithEagerExercisesElseThrow(courseId); + + return ResponseEntity.ok().body(courseService.getDeletionSummary(course)); + } + /** * Creates a default channel with the given name and adds all students, tutors and instructors as participants. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminDataExportResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminDataExportResource.java index 0e1f17de42c1..6547fd7ef57a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminDataExportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminDataExportResource.java @@ -17,6 +17,7 @@ * REST controller for requesting data exports for another user as admin. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminDataExportResource { @@ -34,7 +35,6 @@ public AdminDataExportResource(DataExportService dataExportService) { * @return the ResponseEntity with status 200 (OK) and with body a DTO containing the id, the state and the request date of the data export */ @PostMapping("data-exports/{login}") - @EnforceAdmin public ResponseEntity requestDataExportForUser(@PathVariable String login) { return ResponseEntity.ok(dataExportService.requestDataExportForUserAsAdmin(login)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/FeatureToggleResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminFeatureToggleResource.java similarity index 91% rename from src/main/java/de/tum/cit/aet/artemis/core/web/admin/FeatureToggleResource.java rename to src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminFeatureToggleResource.java index 84a58f7918aa..e2d24720efbd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/FeatureToggleResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminFeatureToggleResource.java @@ -18,13 +18,14 @@ import de.tum.cit.aet.artemis.core.service.feature.FeatureToggleService; @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") -public class FeatureToggleResource { +public class AdminFeatureToggleResource { private final FeatureToggleService featureToggleService; - public FeatureToggleResource(FeatureToggleService featureToggleService) { + public AdminFeatureToggleResource(FeatureToggleService featureToggleService) { this.featureToggleService = featureToggleService; } @@ -36,7 +37,6 @@ public FeatureToggleResource(FeatureToggleService featureToggleService) { * @see FeatureToggleService */ @PutMapping("feature-toggle") - @EnforceAdmin public ResponseEntity> toggleFeatures(@RequestBody Map features) { featureToggleService.updateFeatureToggles(features); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminImprintResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminImprintResource.java index 1e705926232c..58c99393679a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminImprintResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminImprintResource.java @@ -22,6 +22,7 @@ * REST controller for editing the imprint as an admin. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminImprintResource { @@ -40,7 +41,6 @@ public AdminImprintResource(LegalDocumentService legalDocumentService) { * @return the ResponseEntity with status 200 (OK) and with body the imprint with the given language */ @GetMapping("imprint-for-update") - @EnforceAdmin public ResponseEntity getImprintForUpdate(@RequestParam("language") String language) { if (!Language.isValidShortName(language)) { throw new BadRequestException("Language not supported"); @@ -55,7 +55,6 @@ public ResponseEntity getImprintForUpdate(@RequestParam("language") * @return the ResponseEntity with status 200 (OK) and with body the updated imprint */ @PutMapping("imprint") - @EnforceAdmin public ResponseEntity updateImprint(@RequestBody ImprintDTO imprint) { return ResponseEntity.ok(legalDocumentService.updateImprint(imprint)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/LogResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminLogResource.java similarity index 96% rename from src/main/java/de/tum/cit/aet/artemis/core/web/admin/LogResource.java rename to src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminLogResource.java index 375deea352f6..a0855aa92929 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/LogResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminLogResource.java @@ -23,9 +23,10 @@ * Controller for view and managing Log Level at runtime. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") -public class LogResource { +public class AdminLogResource { /** * GET logs -- Gets the current log levels. @@ -33,7 +34,6 @@ public class LogResource { * @return A list of all loggers with their log level */ @GetMapping("logs") - @EnforceAdmin public ResponseEntity> getList() { LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); return ResponseEntity.ok(context.getLoggerList().stream().map(LoggerVM::new).toList()); @@ -46,7 +46,6 @@ public ResponseEntity> getList() { * @return The updated logger */ @PutMapping("logs") - @EnforceAdmin public ResponseEntity changeLevel(@RequestBody LoggerVM jsonLogger) { LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); Logger logger = context.getLogger(jsonLogger.getName()); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminOrganizationResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminOrganizationResource.java index 75d3719b9c6e..c62673b6178f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminOrganizationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminOrganizationResource.java @@ -36,6 +36,7 @@ * REST controller for administrating the Organization entities */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminOrganizationResource { @@ -72,7 +73,6 @@ public AdminOrganizationResource(OrganizationService organizationService, Organi * @return empty ResponseEntity with status 200 (OK), or 404 (Not Found) otherwise */ @PostMapping("organizations/{organizationId}/courses/{courseId}") - @EnforceAdmin public ResponseEntity addCourseToOrganization(@PathVariable Long courseId, @PathVariable Long organizationId) { log.debug("REST request to add course to organization : {}", organizationId); Organization organization = organizationRepository.findByIdElseThrow(organizationId); @@ -90,7 +90,6 @@ public ResponseEntity addCourseToOrganization(@PathVariable Long courseId, * @return empty ResponseEntity with status 200 (OK), or 404 (Not Found) otherwise */ @DeleteMapping("organizations/{organizationId}/courses/{courseId}") - @EnforceAdmin public ResponseEntity removeCourseFromOrganization(@PathVariable Long courseId, @PathVariable Long organizationId) { Organization organization = organizationRepository.findByIdElseThrow(organizationId); courseRepository.removeOrganizationFromCourse(courseId, organization); @@ -107,7 +106,6 @@ public ResponseEntity removeCourseFromOrganization(@PathVariable Long cour * @return empty ResponseEntity with status 200 (OK), or 404 (Not Found) otherwise */ @PostMapping("organizations/{organizationId}/users/{userLogin}") - @EnforceAdmin public ResponseEntity addUserToOrganization(@PathVariable String userLogin, @PathVariable Long organizationId) { User user = userRepository.getUserByLoginElseThrow(userLogin); Organization organization = organizationRepository.findByIdElseThrow(organizationId); @@ -128,7 +126,6 @@ public ResponseEntity addUserToOrganization(@PathVariable String userLogin * @return empty ResponseEntity with status 200 (OK), or 404 (Not Found) otherwise */ @DeleteMapping("organizations/{organizationId}/users/{userLogin}") - @EnforceAdmin public ResponseEntity removeUserFromOrganization(@PathVariable String userLogin, @PathVariable Long organizationId) { log.debug("REST request to remove course to organization : {}", organizationId); User user = userRepository.getUserByLoginElseThrow(userLogin); @@ -145,7 +142,6 @@ public ResponseEntity removeUserFromOrganization(@PathVariable String user * @return the ResponseEntity containing the added organization with status 200 (OK), or 404 (Not Found) otherwise */ @PostMapping("organizations") - @EnforceAdmin public ResponseEntity addOrganization(@RequestBody Organization organization) { log.debug("REST request to add new organization : {}", organization); Organization created = organizationService.add(organization); @@ -161,7 +157,6 @@ public ResponseEntity addOrganization(@RequestBody Organization or * @return the ResponseEntity containing the updated organization with status 200 (OK), or 404 (Not Found) otherwise */ @PutMapping("organizations/{organizationId}") - @EnforceAdmin public ResponseEntity updateOrganization(@PathVariable Long organizationId, @RequestBody Organization organization) { log.debug("REST request to update organization : {}", organization); if (organization.getId() == null) { @@ -182,7 +177,6 @@ public ResponseEntity updateOrganization(@PathVariable Long organi * @return empty ResponseEntity with status 200 (OK), or 404 (Not Found) otherwise */ @DeleteMapping("organizations/{organizationId}") - @EnforceAdmin public ResponseEntity deleteOrganization(@PathVariable Long organizationId) { log.debug("REST request to delete organization : {}", organizationId); organizationService.deleteOrganization(organizationId); @@ -195,7 +189,6 @@ public ResponseEntity deleteOrganization(@PathVariable Long organizationId * @return ResponseEntity containing a list of all organizations with status 200 (OK) */ @GetMapping("organizations") - @EnforceAdmin public ResponseEntity> getAllOrganizations() { log.debug("REST request to get all organizations"); // TODO: we should avoid findAll() and instead load batches of organizations @@ -210,7 +203,6 @@ public ResponseEntity> getAllOrganizations() { * @return ResponseEntity containing a map containing the numbers of users and courses */ @GetMapping("organizations/{organizationId}/count") - @EnforceAdmin public ResponseEntity getNumberOfUsersAndCoursesByOrganization(@PathVariable long organizationId) { log.debug("REST request to get number of users and courses of organization : {}", organizationId); @@ -227,7 +219,6 @@ public ResponseEntity getNumberOfUsersAndCoursesByOrganiza * containing their relative numbers of users and courses */ @GetMapping("organizations/count-all") - @EnforceAdmin public ResponseEntity> getNumberOfUsersAndCoursesOfAllOrganizations() { log.debug("REST request to get number of users and courses of all organizations"); @@ -250,7 +241,6 @@ public ResponseEntity> getNumberOfUsersAndCoursesOfAl * if exists, else with status 404 (Not Found) */ @GetMapping("organizations/{organizationId}") - @EnforceAdmin public ResponseEntity getOrganizationById(@PathVariable long organizationId) { log.debug("REST request to get organization : {}", organizationId); Organization organization = organizationRepository.findByIdElseThrow(organizationId); @@ -265,7 +255,6 @@ public ResponseEntity getOrganizationById(@PathVariable long organ * if exists, else with status 404 (Not Found) */ @GetMapping("organizations/{organizationId}/full") - @EnforceAdmin public ResponseEntity getOrganizationByIdWithUsersAndCourses(@PathVariable long organizationId) { log.debug("REST request to get organization with users and courses : {}", organizationId); Organization organization = organizationRepository.findByIdWithEagerUsersAndCoursesElseThrow(organizationId); @@ -279,7 +268,6 @@ public ResponseEntity getOrganizationByIdWithUsersAndCourses(@Path * @return ResponseEntity containing a set of organizations containing the given user */ @GetMapping("organizations/users/{userId}") - @EnforceAdmin public ResponseEntity> getAllOrganizationsByUser(@PathVariable Long userId) { log.debug("REST request to get all organizations of user : {}", userId); Set organizations = organizationRepository.findAllOrganizationsByUserId(userId); @@ -293,7 +281,6 @@ public ResponseEntity> getAllOrganizationsByUser(@PathVariable * @return the title of the organization wrapped in an ResponseEntity or 404 Not Found if no organization with that id exists */ @GetMapping("organizations/{organizationId}/title") - @EnforceAdmin public ResponseEntity getOrganizationTitle(@PathVariable Long organizationId) { final var title = organizationRepository.getOrganizationTitle(organizationId); return title == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(title); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminPrivacyStatementResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminPrivacyStatementResource.java index af48dc8f3565..736d926218ab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminPrivacyStatementResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminPrivacyStatementResource.java @@ -22,6 +22,7 @@ * REST controller for editing the Privacy Statement as an admin. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminPrivacyStatementResource { @@ -39,7 +40,6 @@ public AdminPrivacyStatementResource(LegalDocumentService legalDocumentService) * @param language the language of the privacy statement * @return the ResponseEntity with status 200 (OK) and with body the privacy statement */ - @EnforceAdmin @GetMapping("privacy-statement-for-update") public ResponseEntity getPrivacyStatementForUpdate(@RequestParam("language") String language) { if (!Language.isValidShortName(language)) { @@ -54,7 +54,6 @@ public ResponseEntity getPrivacyStatementForUpdate(@Request * @param privacyStatement the privacy statement to update * @return the ResponseEntity with status 200 (OK) and with body the updated privacy statement */ - @EnforceAdmin @PutMapping("privacy-statement") public ResponseEntity updatePrivacyStatement(@RequestBody PrivacyStatementDTO privacyStatement) { return ResponseEntity.ok(legalDocumentService.updatePrivacyStatement(privacyStatement)); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminStatisticsResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminStatisticsResource.java index 9c6db4cc7616..c91d7df176c3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminStatisticsResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminStatisticsResource.java @@ -23,6 +23,7 @@ * REST controller for administrating statistics. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminStatisticsResource { @@ -44,7 +45,6 @@ public AdminStatisticsResource(StatisticsService statisticsService) { * @return the ResponseEntity with status 200 (OK) and the data in body, or status 404 (Not Found) */ @GetMapping("management/statistics/data") - @EnforceAdmin public ResponseEntity> getChartData(@RequestParam SpanType span, @RequestParam Integer periodIndex, @RequestParam GraphType graphType) { log.debug("REST request to get graph data"); return ResponseEntity.ok(this.statisticsService.getChartData(span, periodIndex, graphType, StatisticsView.ARTEMIS, null)); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminUserResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminUserResource.java index 289d296c5624..3f25c36d8a67 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminUserResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminUserResource.java @@ -25,7 +25,6 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -69,6 +68,7 @@ * Another option would be to have a specific JPA entity graph to handle this case. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminUserResource { @@ -108,7 +108,6 @@ public AdminUserResource(UserRepository userRepository, UserService userService, * @throws BadRequestAlertException 400 (Bad Request) if the login or email is already in use */ @PostMapping("users") - @EnforceAdmin public ResponseEntity createUser(@Valid @RequestBody ManagedUserVM managedUserVM) throws URISyntaxException { this.userService.checkUsernameAndPasswordValidityElseThrow(managedUserVM.getLogin(), managedUserVM.getPassword()); @@ -144,7 +143,6 @@ else if (userRepository.findOneByEmailIgnoreCase(managedUserVM.getEmail()).isPre * @throws LoginAlreadyUsedException 400 (Bad Request) if the login is already in use */ @PutMapping("users") - @EnforceAdmin public ResponseEntity updateUser(@Valid @RequestBody ManagedUserVM managedUserVM) { this.userService.checkUsernameAndPasswordValidityElseThrow(managedUserVM.getLogin(), managedUserVM.getPassword()); log.debug("REST request to update User : {}", managedUserVM); @@ -181,7 +179,6 @@ public ResponseEntity updateUser(@Valid @RequestBody ManagedUserVM mana * @return the ResponseEntity with status 200 (OK) and with body the "login" user, or with status 404 (Not Found) */ @GetMapping("users/{login:" + Constants.LOGIN_REGEX + "}") - @EnforceAdmin public ResponseEntity getUser(@PathVariable String login) { log.debug("REST request to get User : {}", login); return ResponseUtil.wrapOrNotFound(userRepository.findOneWithGroupsAndAuthoritiesByLogin(login).map(user -> { @@ -201,7 +198,6 @@ public ResponseEntity getUser(@PathVariable String login) { * @return the list of users who could not be imported, because they could NOT be found in the Artemis database and could NOT be found in the connected LDAP */ @PostMapping("users/import") - @EnforceAdmin public ResponseEntity> importUsers(@RequestBody List userDtos) { log.debug("REST request to import {} to Artemis", userDtos); List notFoundStudentsDtos = userService.importUsers(userDtos); @@ -215,7 +211,6 @@ public ResponseEntity> importUsers(@RequestBody List syncUserViaLdap(@PathVariable Long userId) { log.debug("REST request to update ldap information User : {}", userId); @@ -235,7 +230,6 @@ public ResponseEntity syncUserViaLdap(@PathVariable Long userId) { * @return the ResponseEntity with status 200 (OK) and with body all users */ @GetMapping("users") - @EnforceAdmin public ResponseEntity> getAllUsers(UserPageableSearchDTO userSearch) { final Page page = userRepository.getAllManagedUsers(userSearch); HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); @@ -248,7 +242,6 @@ public ResponseEntity> getAllUsers(UserPageableSearchDTO userSearc * @return the ResponseEntity with status 200 (OK) and with body all logins of not enrolled users */ @GetMapping("users/not-enrolled") - @EnforceAdmin public ResponseEntity> getNotEnrolledUsers() { List logins = userRepository.findAllNotEnrolledUsers(); return new ResponseEntity<>(logins, HttpStatus.OK); @@ -260,7 +253,6 @@ public ResponseEntity> getNotEnrolledUsers() { * @return the ResponseEntity with status 200 (OK) and with body a string list of the all the roles */ @GetMapping("users/authorities") - @EnforceAdmin public ResponseEntity> getAuthorities() { return ResponseEntity.ok(authorityRepository.getAuthorities()); } @@ -272,7 +264,6 @@ public ResponseEntity> getAuthorities() { * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("users/{login:" + Constants.LOGIN_REGEX + "}") - @EnforceAdmin public ResponseEntity deleteUser(@PathVariable String login) { log.debug("REST request to delete User: {}", login); if (userRepository.isCurrentUser(login)) { @@ -289,8 +280,7 @@ public ResponseEntity deleteUser(@PathVariable String login) { * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("users") - @EnforceAdmin - public ResponseEntity> deleteUsers(@RequestParam(name = "login") List logins) { + public ResponseEntity> deleteUsers(@RequestBody List logins) { log.debug("REST request to delete {} users", logins.size()); List deletedUsers = Collections.synchronizedList(new java.util.ArrayList<>()); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index 992b340359a1..bd673ece51d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -167,6 +167,7 @@ public ResponseEntity getAccount() { userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); userDTO.setSshPublicKey(user.getSshPublicKey()); + userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java b/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java new file mode 100644 index 000000000000..c0cd6608ec2c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java @@ -0,0 +1,8 @@ +package de.tum.cit.aet.artemis.exam.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ExamDeletionSummaryDTO(long numberOfBuilds, long numberOfCommunicationPosts, long numberOfAnswerPosts, long numberRegisteredStudents, long numberNotStartedExams, + long numberStartedExams, long numberSubmittedExams) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java index 2236d4d2035c..4d3bcfe61369 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java @@ -245,6 +245,10 @@ SELECT MAX(se.workingTime) """) Set findAllUnsubmittedWithExercisesByExamId(@Param("examId") Long examId); + List findAllByExamId(Long examId); + + List findAllByExamId_AndTestRunIsTrue(Long examId); + @Query(""" SELECT DISTINCT se FROM StudentExam se diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java index b16e5af39eff..ca97f86bce7c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java @@ -84,17 +84,26 @@ public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) { studentExam = optionalStudentExam.get(); } else { - // Only Test Exams can be self-created by the user. Exam examWithExerciseGroupsAndExercises = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId); + // An exam can be started 5 minutes before the start time, which is when programming exercises are unlocked + boolean canExamBeStarted = ZonedDateTime.now().isAfter(ExamDateService.getExamProgrammingExerciseUnlockDate(examWithExerciseGroupsAndExercises)); + boolean isExamEnded = ZonedDateTime.now().isAfter(examWithExerciseGroupsAndExercises.getEndDate()); + // Generate a student exam if the following conditions are met: + // 1. The exam has not ended. + // 2. The exam is either a test exam, OR it is a normal exam where the user is registered and can click the start button. + // Allowing student exams to be generated only when students can click the start button prevents inconsistencies. + // For example, this avoids a scenario where a student generates an exam and an instructor adds an exercise group afterward. + if (!isExamEnded + && (examWithExerciseGroupsAndExercises.isTestExam() || (examRegistrationService.isUserRegisteredForExam(examId, currentUser.getId()) && canExamBeStarted))) { + studentExam = studentExamService.generateIndividualStudentExam(examWithExerciseGroupsAndExercises, currentUser); + // For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource + studentExam.setExercises(null); - if (!examWithExerciseGroupsAndExercises.isTestExam()) { + } + else { // We skip the alert since this can happen when a tutor sees the exam card or the user did not participate yet is registered for the exam - throw new BadRequestAlertException("The requested Exam is no test exam and thus no student exam can be created", ENTITY_NAME, - "StudentExamGenerationOnlyForTestExams", true); + throw new BadRequestAlertException("Cannot generate student exam for exam ID " + examId + ".", ENTITY_NAME, "cannotGenerateStudentExam", true); } - studentExam = studentExamService.generateTestExam(examWithExerciseGroupsAndExercises, currentUser); - // For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource - studentExam.setExercises(null); } Exam exam = studentExam.getExam(); diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java index a8fd46512528..1c05e1d4be65 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java @@ -20,7 +20,10 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingScale; import de.tum.cit.aet.artemis.assessment.repository.GradingScaleRepository; +import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.core.config.Constants; @@ -29,13 +32,16 @@ import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exam.domain.ExerciseGroup; import de.tum.cit.aet.artemis.exam.domain.StudentExam; +import de.tum.cit.aet.artemis.exam.dto.ExamDeletionSummaryDTO; import de.tum.cit.aet.artemis.exam.repository.ExamLiveEventRepository; import de.tum.cit.aet.artemis.exam.repository.ExamRepository; 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.domain.ExerciseType; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseDeletionService; import de.tum.cit.aet.artemis.exercise.service.ParticipationService; +import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.quiz.domain.QuizPool; import de.tum.cit.aet.artemis.quiz.repository.QuizPoolRepository; @@ -71,10 +77,17 @@ public class ExamDeletionService { private final QuizPoolRepository quizPoolRepository; + private final BuildJobRepository buildJobRepository; + + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + public ExamDeletionService(ExerciseDeletionService exerciseDeletionService, ParticipationService participationService, CacheManager cacheManager, UserRepository userRepository, ExamRepository examRepository, AuditEventRepository auditEventRepository, StudentExamRepository studentExamRepository, GradingScaleRepository gradingScaleRepository, StudentParticipationRepository studentParticipationRepository, ChannelRepository channelRepository, ChannelService channelService, - ExamLiveEventRepository examLiveEventRepository, QuizPoolRepository quizPoolRepository) { + ExamLiveEventRepository examLiveEventRepository, QuizPoolRepository quizPoolRepository, BuildJobRepository buildJobRepository, PostRepository postRepository, + AnswerPostRepository answerPostRepository) { this.exerciseDeletionService = exerciseDeletionService; this.participationService = participationService; this.cacheManager = cacheManager; @@ -88,6 +101,9 @@ public ExamDeletionService(ExerciseDeletionService exerciseDeletionService, Part this.channelService = channelService; this.examLiveEventRepository = examLiveEventRepository; this.quizPoolRepository = quizPoolRepository; + this.buildJobRepository = buildJobRepository; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; } /** @@ -240,4 +256,34 @@ public void deleteTestRun(Long testRunId) { log.info("Request to delete Test Run {}", testRunId); studentExamRepository.deleteById(testRunId); } + + /** + * Get the exam deletion summary for the given exam. + * + * @param examId the ID of the exam for which the deletion summary should be fetched + * @return the exam deletion summary + */ + public ExamDeletionSummaryDTO getExamDeletionSummary(@NotNull long examId) { + Exam exam = examRepository.findOneWithEagerExercisesGroupsAndStudentExams(examId); + long numberOfBuilds = exam.getExerciseGroups().stream().flatMap(group -> group.getExercises().stream()) + .filter(exercise -> ExerciseType.PROGRAMMING.equals(exercise.getExerciseType())) + .mapToLong(exercise -> buildJobRepository.countBuildJobsByExerciseIds(List.of(exercise.getId()))).sum(); + + Channel channel = channelRepository.findChannelByExamId(examId); + Long conversationId = channel.getId(); + + List postIds = postRepository.findAllByConversationId(conversationId).stream().map(Post::getId).toList(); + long numberOfCommunicationPosts = postIds.size(); + long numberOfAnswerPosts = answerPostRepository.countAnswerPostsByPostIdIn(postIds); + + Set studentExams = exam.getStudentExams(); + long numberRegisteredStudents = studentExams.size(); + + // Boolean.TRUE/Boolean.FALSE are used to handle the case where isStarted/isSubmitted is null + long notStartedExams = studentExams.stream().filter(studentExam -> studentExam.isStarted() == null || !studentExam.isStarted()).count(); + long startedExams = studentExams.stream().filter(studentExam -> Boolean.TRUE.equals(studentExam.isStarted())).count(); + long submittedExams = studentExams.stream().filter(studentExam -> Boolean.TRUE.equals(studentExam.isStarted()) && Boolean.TRUE.equals(studentExam.isSubmitted())).count(); + + return new ExamDeletionSummaryDTO(numberOfBuilds, numberOfCommunicationPosts, numberOfAnswerPosts, numberRegisteredStudents, notStartedExams, startedExams, submittedExams); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamRegistrationService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamRegistrationService.java index 2f747de288ff..18b6aafbbe29 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamRegistrationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamRegistrationService.java @@ -72,9 +72,12 @@ public class ExamRegistrationService { private static final boolean IS_TEST_RUN = false; + private final StudentExamService studentExamService; + public ExamRegistrationService(ExamUserRepository examUserRepository, ExamRepository examRepository, UserService userService, ParticipationService participationService, UserRepository userRepository, AuditEventRepository auditEventRepository, CourseRepository courseRepository, StudentExamRepository studentExamRepository, - StudentParticipationRepository studentParticipationRepository, AuthorizationCheckService authorizationCheckService, ExamUserService examUserService) { + StudentParticipationRepository studentParticipationRepository, AuthorizationCheckService authorizationCheckService, ExamUserService examUserService, + StudentExamService studentExamService) { this.examRepository = examRepository; this.userService = userService; this.userRepository = userRepository; @@ -86,6 +89,7 @@ public ExamRegistrationService(ExamUserRepository examUserRepository, ExamReposi this.authorizationCheckService = authorizationCheckService; this.examUserRepository = examUserRepository; this.examUserService = examUserService; + this.studentExamService = studentExamService; } /** @@ -193,6 +197,7 @@ public boolean isUserRegisteredForExam(Long examId, Long userId) { * Registers student to the exam. In order to do this, we add the user to the course group, because the user only has access to the exam of a course if the student also has * access to the course of the exam. * We only need to add the user to the course group, if the student is not yet part of it, otherwise the student cannot access the exam (within the course). + * If the exam has already started, a student exam is additionally generated. * * @param course the course containing the exam * @param exam the exam for which we want to register a student @@ -216,6 +221,11 @@ public void registerStudentToExam(Course course, Exam exam, User student) { registeredExamUser = examUserRepository.save(registeredExamUser); exam.addExamUser(registeredExamUser); examRepository.save(exam); + // Generate a student exam for the registered student if the exam has already started + if (exam.isStarted()) { + Exam examWithExerciseGroupsAndExercises = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(exam.getId()); + studentExamService.generateIndividualStudentExam(examWithExerciseGroupsAndExercises, student); + } } else { log.warn("Student {} is already registered for the exam {}", student.getLogin(), exam.getId()); diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java index 73e0ffaa8d8b..a2898a9df5ff 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java @@ -9,7 +9,6 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -797,16 +796,17 @@ public void setUpExerciseParticipationsAndSubmissions(StudentExam studentExam, L } /** - * Generates a new test exam for the student and stores it in the database + * Generates a new individual StudentExam for the specified student and stores it in the database. * - * @param exam the exam with loaded exercise groups and exercises for which the StudentExam should be created - * @param student the corresponding student - * @return a StudentExam for the student and exam + * @param exam The exam with eagerly loaded users, exercise groups, and exercises. + * @param student The student for whom the StudentExam should be created. + * @return The generated StudentExam. */ - public StudentExam generateTestExam(Exam exam, User student) { + public StudentExam generateIndividualStudentExam(Exam exam, User student) { // To create a new StudentExam, the Exam with loaded ExerciseGroups and Exercises is needed long start = System.nanoTime(); - StudentExam studentExam = generateIndividualStudentExam(exam, student); + Set userSet = Collections.singleton(student); + StudentExam studentExam = studentExamRepository.createRandomStudentExams(exam, userSet, examQuizQuestionsGenerator).getFirst(); // we need to break a cycle for the serialization studentExam.getExam().setExerciseGroups(null); studentExam.getExam().setStudentExams(null); @@ -816,20 +816,6 @@ public StudentExam generateTestExam(Exam exam, User student) { return studentExam; } - /** - * Generates an individual StudentExam - * - * @param exam with eagerly loaded users, exerciseGroups and exercises loaded - * @param student the student for which the StudentExam should be created - * @return the generated StudentExam - */ - private StudentExam generateIndividualStudentExam(Exam exam, User student) { - // StudentExams are saved in the called method - HashSet userHashSet = new HashSet<>(); - userHashSet.add(student); - return studentExamRepository.createRandomStudentExams(exam, userHashSet, examQuizQuestionsGenerator).getFirst(); - } - /** * Generates the student exams randomly based on the exam configuration and the exercise groups * Important: the passed exams needs to include the registered users, exercise groups and exercises (eagerly loaded) diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java index 209f2a4fa040..8223ba8e54a9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java @@ -77,6 +77,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.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.feature.Feature; import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; @@ -88,6 +89,7 @@ import de.tum.cit.aet.artemis.exam.domain.StudentExam; import de.tum.cit.aet.artemis.exam.domain.SuspiciousSessionsAnalysisOptions; import de.tum.cit.aet.artemis.exam.dto.ExamChecklistDTO; +import de.tum.cit.aet.artemis.exam.dto.ExamDeletionSummaryDTO; import de.tum.cit.aet.artemis.exam.dto.ExamInformationDTO; import de.tum.cit.aet.artemis.exam.dto.ExamScoresDTO; import de.tum.cit.aet.artemis.exam.dto.ExamUserDTO; @@ -1320,4 +1322,19 @@ public ResponseEntity> getAllSuspiciousExamSessio analyzeSessionsIpOutsideOfRange); return ResponseEntity.ok(examSessionService.retrieveAllSuspiciousExamSessionsByExamId(examId, options, Optional.ofNullable(ipSubnet))); } + + /** + * GET /courses/{courseId}/exams/{examId}/deletion-summary : Get a summary of the deletion of an exam. + * + * @param courseId the id of the course + * @param examId the id of the exam + * + * @return the ResponseEntity with status 200 (OK) and with body a summary of the deletion of the exam + */ + @GetMapping("courses/{courseId}/exams/{examId}/deletion-summary") + @EnforceAtLeastInstructorInCourse + public ResponseEntity getDeletionSummary(@PathVariable long courseId, @PathVariable long examId) { + log.debug("REST request to get deletion summary for exam : {}", examId); + return ResponseEntity.ok(examDeletionService.getExamDeletionSummary(examId)); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/admin/AdminExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/admin/AdminExamResource.java index add9135f8c54..d1f70035e858 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/admin/AdminExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/admin/AdminExamResource.java @@ -20,6 +20,7 @@ * REST controller for administrating Exam. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminExamResource { @@ -38,7 +39,6 @@ public AdminExamResource(ExamRepository examRepository) { * @return the ResponseEntity with status 200 (OK) and a list of exams. */ @GetMapping("courses/upcoming-exams") - @EnforceAdmin public ResponseEntity> getCurrentAndUpcomingExams() { log.debug("REST request to get all upcoming exams"); 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 7503427a81fc..b25eb7ab154d 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 @@ -562,8 +562,9 @@ public Submission findLatestSubmissionWithRatedResultWithCompletionDate(Particip boolean ratedOrPractice = Boolean.TRUE.equals(result.isRated()) || participation.isPracticeMode(); boolean noProgrammingAndAssessmentOver = !isProgrammingExercise && isAssessmentOver; // For programming exercises we check that the assessment due date has passed (if set) for manual results otherwise we always show the automatic result - boolean programmingAfterAssessmentOrAutomatic = isProgrammingExercise && ((result.isManual() && isAssessmentOver) || result.isAutomatic()); - if (ratedOrPractice && (noProgrammingAndAssessmentOver || programmingAfterAssessmentOrAutomatic)) { + boolean programmingAfterAssessmentOrAutomaticOrAthena = isProgrammingExercise + && ((result.isManual() && isAssessmentOver) || result.isAutomatic() || result.isAthenaBased()); + if (ratedOrPractice && (noProgrammingAndAssessmentOver || programmingAfterAssessmentOrAutomaticOrAthena)) { // take the first found result that fulfills the above requirements // or // take newer results and thus disregard older ones diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java index 326507d47dd4..304c206938b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java @@ -162,7 +162,7 @@ public Result getResultForCorrectionRound(int correctionRound) { */ @NotNull private List filterNonAutomaticResults() { - return results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaAutomatic())).toList(); + return results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).toList(); } /** @@ -188,8 +188,7 @@ public boolean hasResultForCorrectionRound(int correctionRound) { */ @JsonIgnore public void removeAutomaticResults() { - this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaAutomatic())) - .collect(Collectors.toCollection(ArrayList::new)); + this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).collect(Collectors.toCollection(ArrayList::new)); } /** @@ -214,7 +213,7 @@ public List getResults() { @JsonIgnore public List getManualResults() { - return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); + return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new)); } /** @@ -224,7 +223,7 @@ public List getManualResults() { */ @JsonIgnore public List getNonAthenaResults() { - return results.stream().filter(result -> result != null && !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); + return results.stream().filter(result -> result != null && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new)); } /** 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..5201d413fef1 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; @@ -25,6 +26,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 +80,14 @@ public class ExerciseDeletionService { private final CompetencyProgressService competencyProgressService; + 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, + Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -98,6 +103,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.channelRepository = channelRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; + this.irisSettingsService = irisSettingsService; } /** @@ -169,6 +175,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); 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 c6cdc6ee1730..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 @@ -20,7 +20,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import org.apache.velocity.exception.ResourceNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -382,7 +381,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc throw new BadRequestAlertException("Not intended for the use in exams", "participation", "preconditions not met"); } if (exercise.getDueDate() != null && now().isAfter(exercise.getDueDate())) { - throw new BadRequestAlertException("The due date is over", "participation", "preconditions not met"); + throw new BadRequestAlertException("The due date is over", "participation", "feedbackRequestAfterDueDate", true); } if (exercise instanceof ProgrammingExercise) { ((ProgrammingExercise) exercise).validateSettingsForFeedbackRequest(); @@ -393,7 +392,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc StudentParticipation participation = (exercise instanceof ProgrammingExercise) ? programmingExerciseParticipationService.findStudentParticipationByExerciseAndStudentId(exercise, principal.getName()) : studentParticipationRepository.findByExerciseIdAndStudentLogin(exercise.getId(), principal.getName()) - .orElseThrow(() -> new ResourceNotFoundException("Participation not found")); + .orElseThrow(() -> new BadRequestAlertException("Submission not found", "participation", "noSubmissionExists", true)); checkAccessPermissionOwner(participation, user); participation = studentParticipationRepository.findByIdWithResultsElseThrow(participation.getId()); @@ -406,21 +405,20 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc } else if (exercise instanceof ProgrammingExercise) { if (participation.findLatestLegalResult() == null) { - throw new BadRequestAlertException("User has not reached the conditions to submit a feedback request", "participation", "preconditions not met"); + throw new BadRequestAlertException("You need to submit at least once and have the build results", "participation", "noSubmissionExists", true); } } // Check if feedback has already been requested - var currentDate = now(); - var participationIndividualDueDate = participation.getIndividualDueDate(); - if (participationIndividualDueDate != null && currentDate.isAfter(participationIndividualDueDate)) { - throw new BadRequestAlertException("Request has already been sent", "participation", "already sent"); + var latestResult = participation.findLatestResult(); + if (latestResult != null && latestResult.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA && latestResult.getCompletionDate().isAfter(now())) { + throw new BadRequestAlertException("Request has already been sent", "participation", "feedbackRequestAlreadySent", true); } // 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/exercise/web/ParticipationTeamWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java index ccf10fb60273..ad7d9b0fa16a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java @@ -10,11 +10,10 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import jakarta.annotation.PostConstruct; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; import org.springframework.messaging.handler.annotation.DestinationVariable; @@ -31,6 +30,7 @@ import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.User; @@ -96,7 +96,7 @@ public ParticipationTeamWebsocketService(WebsocketMessagingService websocketMess /** * Initialize relevant data from hazelcast */ - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void init() { // participationId-username -> timestamp this.lastTypingTracker = hazelcastInstance.getMap("lastTypingTracker"); @@ -307,11 +307,19 @@ public void handleDisconnect(SessionDisconnectEvent event) { * @param sessionId id of the sessions which is unsubscribing */ public void unsubscribe(String sessionId) { - Optional.ofNullable(destinationTracker.get(sessionId)).ifPresent(destination -> { - Long participationId = getParticipationIdFromDestination(destination); - sendOnlineTeamStudents(participationId, sessionId); - destinationTracker.remove(sessionId); - }); + // check if Hazelcast is still active, before invoking this + try { + if (hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning()) { + Optional.ofNullable(destinationTracker.get(sessionId)).ifPresent(destination -> { + destinationTracker.remove(sessionId); + Long participationId = getParticipationIdFromDestination(destination); + sendOnlineTeamStudents(participationId, sessionId); + }); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Failed to unsubscribe as Hazelcast is no longer active"); + } } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/admin/AdminExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/admin/AdminExerciseResource.java index 306ad29ddd4f..76546cbe6289 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/admin/AdminExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/admin/AdminExerciseResource.java @@ -20,6 +20,7 @@ * REST controller for administrating Exercise. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminExerciseResource { @@ -38,7 +39,6 @@ public AdminExerciseResource(ExerciseRepository exerciseRepository) { * @return the ResponseEntity with status 200 (OK) and a list of exercises. */ @GetMapping("exercises/upcoming") - @EnforceAdmin public ResponseEntity> getUpcomingExercises() { log.debug("REST request to get all upcoming exercises"); Set upcomingExercises = exerciseRepository.findAllExercisesWithCurrentOrUpcomingDueDate(); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/IrisTemplate.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/IrisTemplate.java deleted file mode 100644 index e1b486a34cbf..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/IrisTemplate.java +++ /dev/null @@ -1,65 +0,0 @@ -package de.tum.cit.aet.artemis.iris.domain; - -import java.util.Objects; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -import org.hibernate.annotations.Cache; -import org.hibernate.annotations.CacheConcurrencyStrategy; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.cit.aet.artemis.core.domain.DomainObject; - -/** - * An IrisTemplate represents a handlebars template for Iris. - * It is sent to the Iris Python server to generate a response. - */ -@Entity -@Table(name = "iris_template") -@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public class IrisTemplate extends DomainObject { - - @Column(name = "content", columnDefinition = "LONGTEXT") - private String content; - - /** - * Empty constructor required for Hibernate and Jackson. - */ - public IrisTemplate() { - } - - /** - * Create a new IrisTemplate with content. - * - * @param content the content of the template - */ - public IrisTemplate(String content) { - this.content = content; - } - - public String getContent() { - return content; - } - - public void setContent(String template) { - this.content = template; - } - - @Override - public boolean equals(Object other) { - if (!super.equals(other)) { - return false; - } - IrisTemplate template = (IrisTemplate) other; - return Objects.equals(content, template.content); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), content); - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisHestiaSession.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisHestiaSession.java deleted file mode 100644 index f23603711cf5..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisHestiaSession.java +++ /dev/null @@ -1,37 +0,0 @@ -package de.tum.cit.aet.artemis.iris.domain.session; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.ManyToOne; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.cit.aet.artemis.programming.domain.hestia.CodeHint; - -/** - * An Iris session for a hestia code hint. - * Currently used to generate descriptions for code hints. - */ -@Entity -@DiscriminatorValue("HESTIA") -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public class IrisHestiaSession extends IrisSession { - - @ManyToOne - @JsonIgnore - private CodeHint codeHint; - - public CodeHint getCodeHint() { - return codeHint; - } - - public void setCodeHint(CodeHint codeHint) { - this.codeHint = codeHint; - } - - @Override - public String toString() { - return "IrisHestiaSession{" + "id=" + getId() + ", codeHint=" + (codeHint == null ? "null" : codeHint.getId()) + '}'; - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisSession.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisSession.java index 3e6240a383de..13a4bd6f8b2b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisSession.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisSession.java @@ -28,7 +28,7 @@ /** * An IrisSession represents a list of messages of Artemis, a user, and an LLM. - * See {@link IrisExerciseChatSession} and {@link IrisHestiaSession} for concrete implementations. + * See {@link IrisExerciseChatSession} and {@link IrisCourseChatSession} for concrete implementations. */ @Entity @Table(name = "iris_session") @@ -40,7 +40,6 @@ @JsonSubTypes({ @JsonSubTypes.Type(value = IrisExerciseChatSession.class, name = "chat"), // TODO: Legacy. Should ideally be "exercise_chat" @JsonSubTypes.Type(value = IrisCourseChatSession.class, name = "course_chat"), - @JsonSubTypes.Type(value = IrisHestiaSession.class, name = "hestia"), }) // @formatter:on @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisTextExerciseChatSession.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisTextExerciseChatSession.java new file mode 100644 index 000000000000..071c4c5ed325 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/session/IrisTextExerciseChatSession.java @@ -0,0 +1,48 @@ +package de.tum.cit.aet.artemis.iris.domain.session; + +import java.util.Optional; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.text.domain.TextExercise; + +/** + * An IrisTextExerciseChatSession represents a conversation between a user and an LLM in the context of a text exercise. + * This is used for students receiving tutor assistance from Iris while working on a text exercise. + */ +@Entity +@DiscriminatorValue("TEXT_EXERCISE_CHAT") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class IrisTextExerciseChatSession extends IrisChatSession { + + @ManyToOne + @JsonIgnore + private TextExercise exercise; + + public IrisTextExerciseChatSession() { + } + + public IrisTextExerciseChatSession(TextExercise exercise, User user) { + super(user); + this.exercise = exercise; + } + + public TextExercise getExercise() { + return exercise; + } + + public void setExercise(TextExercise exercise) { + this.exercise = exercise; + } + + @Override + public String toString() { + return "IrisTextExerciseChatSession{" + "user=" + Optional.ofNullable(getUser()).map(User::getLogin).orElse("null") + "," + "exercise=" + exercise + '}'; + } +} 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 4305461b71cf..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,31 +1,25 @@ package de.tum.cit.aet.artemis.iris.domain.settings; +import java.util.SortedSet; +import java.util.TreeSet; + import jakarta.annotation.Nullable; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.OneToOne; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - /** * An {@link IrisSubSettings} implementation for chat settings. * Chat settings notably provide settings for the rate limit. - * Chat settings provide a single {@link IrisTemplate} for the chat messages. */ @Entity @DiscriminatorValue("CHAT") @JsonInclude(JsonInclude.Include.NON_EMPTY) public class IrisChatSubSettings extends IrisSubSettings { - @Nullable - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - private IrisTemplate template; - @Nullable @Column(name = "rate_limit") private Integer rateLimit; @@ -34,14 +28,9 @@ public class IrisChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; - @Nullable - public IrisTemplate getTemplate() { - return template; - } - - public void setTemplate(@Nullable IrisTemplate template) { - this.template = template; - } + @Column(name = "enabled_for_categories") + @Convert(converter = IrisListConverter.class) + private SortedSet enabledForCategories = new TreeSet<>(); @Nullable public Integer getRateLimit() { @@ -60,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/IrisCompetencyGenerationSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCompetencyGenerationSubSettings.java index f68ae30d4b53..b8447b1bb378 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCompetencyGenerationSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCompetencyGenerationSubSettings.java @@ -1,36 +1,16 @@ package de.tum.cit.aet.artemis.iris.domain.settings; -import jakarta.annotation.Nullable; -import jakarta.persistence.CascadeType; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.OneToOne; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - /** * An {@link IrisSubSettings} implementation for the settings for competency generation. - * CompetencyGeneration settings provide a single {@link IrisTemplate} */ @Entity @DiscriminatorValue("COMPETENCY_GENERATION") @JsonInclude(JsonInclude.Include.NON_EMPTY) public class IrisCompetencyGenerationSubSettings extends IrisSubSettings { - @Nullable - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - private IrisTemplate template; - - @Nullable - public IrisTemplate getTemplate() { - return template; - } - - public void setTemplate(@Nullable IrisTemplate template) { - this.template = template; - } - } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java index 2354ffd3c142..fce389a7b95f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisCourseSettings.java @@ -29,22 +29,17 @@ public class IrisCourseSettings extends IrisSettings { private IrisChatSubSettings irisChatSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - @JoinColumn(name = "iris_lecture_ingestion_settings_id") - private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @JoinColumn(name = "iris_text_exercise_chat_settings_id") + private IrisTextExerciseChatSubSettings irisTextExerciseChatSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - @JoinColumn(name = "iris_hestia_settings_id") - private IrisHestiaSubSettings irisHestiaSettings; + @JoinColumn(name = "iris_lecture_ingestion_settings_id") + private IrisLectureIngestionSubSettings irisLectureIngestionSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; - @Override - public boolean isValid() { - return course != null; - } - public Course getCourse() { return course; } @@ -74,13 +69,13 @@ public void setIrisChatSettings(IrisChatSubSettings irisChatSettings) { } @Override - public IrisHestiaSubSettings getIrisHestiaSettings() { - return irisHestiaSettings; + public IrisTextExerciseChatSubSettings getIrisTextExerciseChatSettings() { + return irisTextExerciseChatSettings; } @Override - public void setIrisHestiaSettings(IrisHestiaSubSettings irisHestiaSettings) { - this.irisHestiaSettings = irisHestiaSettings; + public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings irisTextExerciseChatSettings) { + this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java index 410bbde1954c..ba095a018808 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisExerciseSettings.java @@ -28,10 +28,9 @@ public class IrisExerciseSettings extends IrisSettings { @JoinColumn(name = "iris_chat_settings_id") private IrisChatSubSettings irisChatSettings; - @Override - public boolean isValid() { - return exercise != null; - } + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @JoinColumn(name = "iris_text_exercise_chat_settings_id") + private IrisTextExerciseChatSubSettings irisTextExerciseChatSettings; public Exercise getExercise() { return exercise; @@ -61,13 +60,13 @@ public void setIrisChatSettings(IrisChatSubSettings irisChatSettings) { } @Override - public IrisHestiaSubSettings getIrisHestiaSettings() { - return null; + public IrisTextExerciseChatSubSettings getIrisTextExerciseChatSettings() { + return irisTextExerciseChatSettings; } @Override - public void setIrisHestiaSettings(IrisHestiaSubSettings irisHestiaSettings) { - + public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings irisTextExerciseChatSettings) { + this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java index 0ae60c36edd8..ddb156da0038 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisGlobalSettings.java @@ -1,15 +1,12 @@ package de.tum.cit.aet.artemis.iris.domain.settings; import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; -import org.hibernate.Hibernate; - import com.fasterxml.jackson.annotation.JsonInclude; /** @@ -22,89 +19,22 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public class IrisGlobalSettings extends IrisSettings { - @Column(name = "current_version") - private int currentVersion; - - @Column(name = "enable_auto_update_chat") - private boolean enableAutoUpdateChat; - - @Column(name = "enable_auto_update_hestia") - private boolean enableAutoUpdateHestia; - - @Column(name = "enable_auto_update_lecture_ingestion") - private boolean enableAutoUpdateLectureIngestion; - - @Column(name = "enable_auto_update_competency_generation") - private boolean enableAutoUpdateCompetencyGeneration; - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_chat_settings_id") private IrisChatSubSettings irisChatSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) - @JoinColumn(name = "iris_lecture_ingestion_settings_id") - private IrisLectureIngestionSubSettings irisLectureIngestionSettings; + @JoinColumn(name = "iris_text_exercise_chat_settings_id") + private IrisTextExerciseChatSubSettings irisTextExerciseChatSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) - @JoinColumn(name = "iris_hestia_settings_id") - private IrisHestiaSubSettings irisHestiaSettings; + @JoinColumn(name = "iris_lecture_ingestion_settings_id") + private IrisLectureIngestionSubSettings irisLectureIngestionSettings; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "iris_competency_generation_settings_id") private IrisCompetencyGenerationSubSettings irisCompetencyGenerationSettings; - @Override - public boolean isValid() { - var chatSettingsValid = !Hibernate.isInitialized(irisChatSettings) || irisChatSettings == null - || (irisChatSettings.getTemplate() != null && irisChatSettings.getTemplate().getContent() != null && !irisChatSettings.getTemplate().getContent().isEmpty()); - var hestiaSettingsValid = !Hibernate.isInitialized(irisHestiaSettings) || irisHestiaSettings == null - || (irisHestiaSettings.getTemplate() != null && irisHestiaSettings.getTemplate().getContent() != null && !irisHestiaSettings.getTemplate().getContent().isEmpty()); - var competencyGenerationSettingsValid = !Hibernate.isInitialized(irisCompetencyGenerationSettings) || irisCompetencyGenerationSettings == null - || (irisCompetencyGenerationSettings.getTemplate() != null && irisCompetencyGenerationSettings.getTemplate().getContent() != null - && !irisCompetencyGenerationSettings.getTemplate().getContent().isEmpty()); - return chatSettingsValid && hestiaSettingsValid && competencyGenerationSettingsValid; - } - - public int getCurrentVersion() { - return currentVersion; - } - - public void setCurrentVersion(int currentVersion) { - this.currentVersion = currentVersion; - } - - public boolean isEnableAutoUpdateChat() { - return enableAutoUpdateChat; - } - - public void setEnableAutoUpdateChat(boolean enableAutoUpdateChat) { - this.enableAutoUpdateChat = enableAutoUpdateChat; - } - - public boolean isEnableAutoUpdateLectureIngestion() { - return enableAutoUpdateLectureIngestion; - } - - public void setEnableAutoUpdateLectureIngestion(boolean enableAutoUpdateLectureIngestion) { - this.enableAutoUpdateLectureIngestion = enableAutoUpdateLectureIngestion; - } - - public boolean isEnableAutoUpdateHestia() { - return enableAutoUpdateHestia; - } - - public void setEnableAutoUpdateHestia(boolean enableAutoUpdateHestia) { - this.enableAutoUpdateHestia = enableAutoUpdateHestia; - } - - public boolean isEnableAutoUpdateCompetencyGeneration() { - return enableAutoUpdateCompetencyGeneration; - } - - public void setEnableAutoUpdateCompetencyGeneration(boolean enableAutoUpdateCompetencyGeneration) { - this.enableAutoUpdateCompetencyGeneration = enableAutoUpdateCompetencyGeneration; - } - @Override public IrisLectureIngestionSubSettings getIrisLectureIngestionSettings() { return irisLectureIngestionSettings; @@ -126,13 +56,13 @@ public void setIrisChatSettings(IrisChatSubSettings irisChatSettings) { } @Override - public IrisHestiaSubSettings getIrisHestiaSettings() { - return irisHestiaSettings; + public IrisTextExerciseChatSubSettings getIrisTextExerciseChatSettings() { + return irisTextExerciseChatSettings; } @Override - public void setIrisHestiaSettings(IrisHestiaSubSettings irisHestiaSettings) { - this.irisHestiaSettings = irisHestiaSettings; + public void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings irisTextExerciseChatSettings) { + this.irisTextExerciseChatSettings = irisTextExerciseChatSettings; } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisHestiaSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisHestiaSubSettings.java deleted file mode 100644 index 1c478a8ccfbe..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisHestiaSubSettings.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.tum.cit.aet.artemis.iris.domain.settings; - -import jakarta.annotation.Nullable; -import jakarta.persistence.CascadeType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.OneToOne; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - -/** - * An {@link IrisSubSettings} implementation for the Hestia integration settings. - * Hestia settings provide a single {@link IrisTemplate} for the hestia code hint generation requests. - */ -@Entity -@DiscriminatorValue("HESTIA") -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public class IrisHestiaSubSettings extends IrisSubSettings { - - @Nullable - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - private IrisTemplate template; - - @Nullable - public IrisTemplate getTemplate() { - return template; - } - - public void setTemplate(@Nullable IrisTemplate template) { - this.template = template; - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisModelListConverter.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisListConverter.java similarity index 88% rename from src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisModelListConverter.java rename to src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisListConverter.java index 938ce5dae0c7..be4cf5199a0b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisModelListConverter.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisListConverter.java @@ -9,7 +9,7 @@ import jakarta.persistence.Converter; @Converter -public class IrisModelListConverter implements AttributeConverter, String> { +public class IrisListConverter implements AttributeConverter, String> { @Override public String convertToDatabaseColumn(SortedSet type) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java index 5ca715a2f688..61b2912d5cf6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSettings.java @@ -45,17 +45,15 @@ public abstract class IrisSettings extends DomainObject { public abstract void setIrisChatSettings(IrisChatSubSettings irisChatSettings); - public abstract IrisLectureIngestionSubSettings getIrisLectureIngestionSettings(); + public abstract IrisTextExerciseChatSubSettings getIrisTextExerciseChatSettings(); - public abstract void setIrisLectureIngestionSettings(IrisLectureIngestionSubSettings irisLectureIngestionSettings); + public abstract void setIrisTextExerciseChatSettings(IrisTextExerciseChatSubSettings irisTextExerciseChatSettings); - public abstract IrisHestiaSubSettings getIrisHestiaSettings(); + public abstract IrisLectureIngestionSubSettings getIrisLectureIngestionSettings(); - public abstract void setIrisHestiaSettings(IrisHestiaSubSettings irisHestiaSettings); + public abstract void setIrisLectureIngestionSettings(IrisLectureIngestionSubSettings irisLectureIngestionSettings); public abstract IrisCompetencyGenerationSubSettings getIrisCompetencyGenerationSettings(); public abstract void setIrisCompetencyGenerationSettings(IrisCompetencyGenerationSubSettings irisCompetencyGenerationSubSettings); - - public abstract boolean isValid(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java index 16588cf448a5..c9fc576311db 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettings.java @@ -26,7 +26,6 @@ * IrisSubSettings is an abstract super class for the specific sub settings types. * Sub Settings are settings for a specific feature of Iris. * {@link IrisChatSubSettings} are used to specify settings for the chat feature. - * {@link IrisHestiaSubSettings} are used to specify settings for the Hestia integration. * {@link IrisCompetencyGenerationSubSettings} are used to specify settings for the competency generation feature. *

    * Also see {@link de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService} for more information. @@ -40,8 +39,8 @@ // @formatter:off @JsonSubTypes({ @JsonSubTypes.Type(value = IrisChatSubSettings.class, name = "chat"), + @JsonSubTypes.Type(value = IrisTextExerciseChatSubSettings.class, name = "text-exercise-chat"), @JsonSubTypes.Type(value = IrisLectureIngestionSubSettings.class, name = "lecture-ingestion"), - @JsonSubTypes.Type(value = IrisHestiaSubSettings.class, name = "hestia"), @JsonSubTypes.Type(value = IrisCompetencyGenerationSubSettings.class, name = "competency-generation") }) // @formatter:on @@ -51,13 +50,12 @@ public abstract class IrisSubSettings extends DomainObject { @Column(name = "enabled") private boolean enabled = false; - @Column(name = "allowed_models") - @Convert(converter = IrisModelListConverter.class) - private SortedSet allowedModels = new TreeSet<>(); + @Column(name = "allowed_variants", nullable = false) + @Convert(converter = IrisListConverter.class) + private SortedSet allowedVariants = new TreeSet<>(); - @Nullable - @Column(name = "preferred_model") - private String preferredModel; + @Column(name = "selected_variant", nullable = false) + private String selectedVariant; public boolean isEnabled() { return enabled; @@ -67,20 +65,20 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } - public SortedSet getAllowedModels() { - return allowedModels; + public SortedSet getAllowedVariants() { + return allowedVariants; } - public void setAllowedModels(SortedSet allowedModels) { - this.allowedModels = allowedModels; + public void setAllowedVariants(SortedSet allowedVariants) { + this.allowedVariants = allowedVariants; } @Nullable - public String getPreferredModel() { - return preferredModel; + public String getSelectedVariant() { + return selectedVariant; } - public void setPreferredModel(@Nullable String preferredModel) { - this.preferredModel = preferredModel; + public void setSelectedVariant(@Nullable String selectedVariant) { + this.selectedVariant = selectedVariant; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java index d938134f4555..dafdd1edcfb9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisSubSettingsType.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.iris.domain.settings; public enum IrisSubSettingsType { - CHAT, HESTIA, COMPETENCY_GENERATION, LECTURE_INGESTION + CHAT, // TODO: Split into PROGRAMMING_EXERCISE_CHAT and COURSE_CHAT + TEXT_EXERCISE_CHAT, COMPETENCY_GENERATION, LECTURE_INGESTION } 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 new file mode 100644 index 000000000000..2b96a709a9ad --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java @@ -0,0 +1,61 @@ +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; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * An {@link IrisSubSettings} implementation for the settings for the chat in a text exercise. + */ +@Entity +@DiscriminatorValue("TEXT_EXERCISE_CHAT") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class IrisTextExerciseChatSubSettings extends IrisSubSettings { + + @Nullable + @Column(name = "rate_limit") + private Integer rateLimit; + + @Nullable + @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; + } + + public void setRateLimit(@Nullable Integer rateLimit) { + this.rateLimit = rateLimit; + } + + @Nullable + public Integer getRateLimitTimeframeHours() { + return rateLimitTimeframeHours; + } + + 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 72d8e599ed70..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,15 +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; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedModels, - @Nullable String preferredModel, @Nullable IrisTemplate template) { +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/IrisCombinedCompetencyGenerationSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java index 18ffcbc17b50..414b422e0f64 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedCompetencyGenerationSubSettingsDTO.java @@ -6,9 +6,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedCompetencyGenerationSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, - @Nullable IrisTemplate template) { +public record IrisCombinedCompetencyGenerationSubSettingsDTO(boolean enabled, @Nullable Set allowedVariants, @Nullable String selectedVariant) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedHestiaSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedHestiaSubSettingsDTO.java deleted file mode 100644 index c70ce4825a92..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedHestiaSubSettingsDTO.java +++ /dev/null @@ -1,13 +0,0 @@ -package de.tum.cit.aet.artemis.iris.dto; - -import java.util.Set; - -import jakarta.annotation.Nullable; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedHestiaSubSettingsDTO(boolean enabled, @Nullable Set allowedModels, @Nullable String preferredModel, @Nullable IrisTemplate template) { -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java index 9353757c782e..b05645603dbe 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedSettingsDTO.java @@ -2,7 +2,12 @@ import com.fasterxml.jackson.annotation.JsonInclude; +// @formatter:off @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedSettingsDTO(IrisCombinedChatSubSettingsDTO irisChatSettings, IrisCombinedLectureIngestionSubSettingsDTO irisLectureIngestionSettings, - IrisCombinedHestiaSubSettingsDTO irisHestiaSettings, IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings) { -} +public record IrisCombinedSettingsDTO( + IrisCombinedChatSubSettingsDTO irisChatSettings, + IrisCombinedTextExerciseChatSubSettingsDTO irisTextExerciseChatSettings, + IrisCombinedLectureIngestionSubSettingsDTO irisLectureIngestionSettings, + IrisCombinedCompetencyGenerationSubSettingsDTO irisCompetencyGenerationSettings +) {} +// @formatter:on 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 new file mode 100644 index 000000000000..f8a5ccb61748 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.iris.dto; + +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 SortedSet allowedVariants, + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisHestiaSessionRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisHestiaSessionRepository.java deleted file mode 100644 index 22a14bd98bd7..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisHestiaSessionRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package de.tum.cit.aet.artemis.iris.repository; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; - -import java.util.List; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Repository; - -import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; -import de.tum.cit.aet.artemis.iris.domain.session.IrisHestiaSession; - -/** - * Repository interface for managing {@link IrisHestiaSession} entities. - * Provides custom queries for finding hestia sessions based on different criteria. - */ -@Repository -@Profile(PROFILE_IRIS) -public interface IrisHestiaSessionRepository extends ArtemisJpaRepository { - - /** - * Finds a list of {@link IrisHestiaSession} based on the exercise and user IDs. - * - * @param codeHintId The ID of the code hint. - * @return A list of hestia sessions sorted by creation date in descending order. - */ - List findByCodeHintIdOrderByCreationDateDesc(Long codeHintId); -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java index 8c4ffd56068e..c9e8eafba618 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisSettingsRepository.java @@ -28,8 +28,8 @@ public interface IrisSettingsRepository extends ArtemisJpaRepository findAllGlobalSettings(); @@ -42,8 +42,8 @@ default IrisGlobalSettings findGlobalSettingsElseThrow() { SELECT irisSettings FROM IrisCourseSettings irisSettings LEFT JOIN FETCH irisSettings.irisChatSettings + LEFT JOIN FETCH irisSettings.irisTextExerciseChatSettings LEFT JOIN FETCH irisSettings.irisLectureIngestionSettings - LEFT JOIN FETCH irisSettings.irisHestiaSettings LEFT JOIN FETCH irisSettings.irisCompetencyGenerationSettings WHERE irisSettings.course.id = :courseId """) @@ -59,6 +59,7 @@ default IrisGlobalSettings findGlobalSettingsElseThrow() { SELECT irisSettings FROM IrisExerciseSettings irisSettings LEFT JOIN FETCH irisSettings.irisChatSettings + LEFT JOIN FETCH irisSettings.irisTextExerciseChatSettings WHERE irisSettings.exercise.id = :exerciseId """) Optional findExerciseSettings(@Param("exerciseId") long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTemplateRepository.java b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTemplateRepository.java deleted file mode 100644 index 2b1a930d7aef..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTemplateRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package de.tum.cit.aet.artemis.iris.repository; - -import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - -/** - * Spring Data repository for the IrisTemplate entity. - */ -public interface IrisTemplateRepository extends ArtemisJpaRepository { - -} 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 new file mode 100644 index 000000000000..be8d6c3b4331 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/repository/IrisTextExerciseChatSessionRepository.java @@ -0,0 +1,95 @@ +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; +import java.util.List; + +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; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.iris.domain.session.IrisTextExerciseChatSession; + +/** + * 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 { + + /** + * Finds a list of {@link IrisTextExerciseChatSession} based on the exercise and user IDs. + * + * @param exerciseId The ID of the exercise. + * @param userId The ID of the user. + * @return A list of text exercise chat sessions sorted by creation date in descending order. + */ + @Query(""" + + SELECT s + FROM IrisTextExerciseChatSession s + WHERE s.exercise.id = :exerciseId + AND s.user.id = :userId + ORDER BY s.creationDate DESC + """) + List findByExerciseIdAndUserId(@Param("exerciseId") Long exerciseId, @Param("userId") Long userId); + + @Query(""" + SELECT s + FROM IrisTextExerciseChatSession s + WHERE s.exercise.id = :exerciseId + AND s.user.id = :userId + ORDER BY s.creationDate DESC + """) + List findSessionsByExerciseIdAndUserId(@Param("exerciseId") Long exerciseId, @Param("userId") Long userId, Pageable pageable); + + @EntityGraph(type = LOAD, attributePaths = "messages") + List findSessionsWithMessagesByIdIn(List ids); + + /** + * Finds the latest text exercise chat sessions by exercise ID and user ID, including their messages, with pagination support. + * This method avoids in-memory paging by retrieving the session IDs directly from the database. + * + * @param exerciseId the ID of the exercise to find the text exercise chat sessions for + * @param userId the ID of the user to find the text exercise chat sessions for + * @param pageable the pagination information + * @return a list of {@code IrisExerciseChatSession} with messages, or an empty list if no sessions are found + */ + default List findLatestByExerciseIdAndUserIdWithMessages(Long exerciseId, Long userId, Pageable pageable) { + List ids = findSessionsByExerciseIdAndUserId(exerciseId, userId, pageable).stream().map(DomainObject::getId).toList(); + + if (ids.isEmpty()) { + return Collections.emptyList(); + } + + return findSessionsWithMessagesByIdIn(ids); + } + + /** + * Finds a list of text exercise chat sessions or throws an exception if none are found. + * + * @param exerciseId The ID of the exercise. + * @param userId The ID of the user. + * @return A list of text exercise chat sessions. + * @throws EntityNotFoundException if no sessions are found. + */ + @NotNull + default List findByExerciseIdAndUserIdElseThrow(long exerciseId, long userId) throws EntityNotFoundException { + var result = findByExerciseIdAndUserId(exerciseId, userId); + if (result.isEmpty()) { + throw new EntityNotFoundException("Iris Text Exercise Chat Session"); + } + return result; + } +} 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 7c37831a611c..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; @@ -17,7 +21,7 @@ import de.tum.cit.aet.artemis.iris.service.websocket.IrisWebsocketService; /** - * Service to handle the Competency generation subsytem of Iris. + * Service to handle the Competency generation subsystem of Iris. */ @Service @Profile(PROFILE_IRIS) @@ -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/IrisDefaultTemplateService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisDefaultTemplateService.java deleted file mode 100644 index 2a4757596bf1..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisDefaultTemplateService.java +++ /dev/null @@ -1,76 +0,0 @@ -package de.tum.cit.aet.artemis.iris.service; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.Optional; - -import org.apache.commons.io.IOUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Profile; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Service; - -import de.tum.cit.aet.artemis.core.service.ResourceLoaderService; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; - -/** - * Service that loads default Iris templates from the resources/templates/iris folder. - */ -@Profile(PROFILE_IRIS) -@Service -public class IrisDefaultTemplateService { - - private static final Logger log = LoggerFactory.getLogger(IrisDefaultTemplateService.class); - - private final ResourceLoaderService resourceLoaderService; - - public IrisDefaultTemplateService(ResourceLoaderService resourceLoaderService) { - this.resourceLoaderService = resourceLoaderService; - } - - /** - * Loads the default Iris template with the given file name. - * For example, "chat.hbs" will load the template from "resources/templates/iris/chat.hbs". - * - * @param templateFileName The file name of the template to load. - * @return The loaded Iris template, or an empty template if an IO error occurred. - */ - public IrisTemplate load(String templateFileName) { - Path filePath = Path.of("templates", "iris", templateFileName); - Resource resource = resourceLoaderService.getResource(filePath); - try { - String fileContent = IOUtils.toString(resource.getInputStream(), StandardCharsets.UTF_8); - return new IrisTemplate(fileContent); - } - catch (IOException e) { - log.error("Error while loading Iris template from file: {}", filePath, e); - return new IrisTemplate(""); - } - } - - /** - * Loads the global template version from the "resources/templates/iris/template-version.txt" file. - * - * @return an Optional containing the version loaded from the file, or an empty Optional if there was an error. - */ - public Optional loadGlobalTemplateVersion() { - Path filePath = Path.of("templates", "iris", "template-version.txt"); - Resource resource = resourceLoaderService.getResource(filePath); - try { - String fileContent = IOUtils.toString(resource.getInputStream(), StandardCharsets.UTF_8); - int version = Integer.parseInt(fileContent.trim()); - return Optional.of(version); - } - catch (IOException e) { - log.error("Error while loading global template version from file: {}", filePath, e); - } - catch (NumberFormatException e) { - log.error("Content of {} was not a parseable int!", filePath, e); - } - return Optional.empty(); - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisSessionService.java index 41fa1247b739..308637e97bcf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisSessionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/IrisSessionService.java @@ -14,14 +14,14 @@ import de.tum.cit.aet.artemis.iris.domain.message.IrisMessage; import de.tum.cit.aet.artemis.iris.domain.session.IrisCourseChatSession; import de.tum.cit.aet.artemis.iris.domain.session.IrisExerciseChatSession; -import de.tum.cit.aet.artemis.iris.domain.session.IrisHestiaSession; import de.tum.cit.aet.artemis.iris.domain.session.IrisSession; +import de.tum.cit.aet.artemis.iris.domain.session.IrisTextExerciseChatSession; import de.tum.cit.aet.artemis.iris.service.session.IrisChatBasedFeatureInterface; 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.IrisHestiaSessionService; import de.tum.cit.aet.artemis.iris.service.session.IrisRateLimitedFeatureInterface; import de.tum.cit.aet.artemis.iris.service.session.IrisSubFeatureInterface; +import de.tum.cit.aet.artemis.iris.service.session.IrisTextExerciseChatSessionService; /** * Service for managing Iris sessions. @@ -32,18 +32,18 @@ public class IrisSessionService { private final UserRepository userRepository; + private final IrisTextExerciseChatSessionService irisTextExerciseChatSessionService; + private final IrisExerciseChatSessionService irisExerciseChatSessionService; private final IrisCourseChatSessionService irisCourseChatSessionService; - private final IrisHestiaSessionService irisHestiaSessionService; - - public IrisSessionService(UserRepository userRepository, IrisExerciseChatSessionService irisExerciseChatSessionService, - IrisCourseChatSessionService irisCourseChatSessionService, IrisHestiaSessionService irisHestiaSessionService) { + public IrisSessionService(UserRepository userRepository, IrisTextExerciseChatSessionService irisTextExerciseChatSessionService, + IrisExerciseChatSessionService irisExerciseChatSessionService, IrisCourseChatSessionService irisCourseChatSessionService) { this.userRepository = userRepository; + this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; this.irisCourseChatSessionService = irisCourseChatSessionService; - this.irisHestiaSessionService = irisHestiaSessionService; } /** @@ -136,9 +136,9 @@ public void checkRateLimit(IrisSession session, User user) { @SuppressWarnings("unchecked") private IrisSubFeatureWrapper getIrisSessionSubService(S session) { return switch (session) { + case IrisTextExerciseChatSession chatSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisTextExerciseChatSessionService, chatSession); case IrisExerciseChatSession chatSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisExerciseChatSessionService, chatSession); case IrisCourseChatSession courseChatSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisCourseChatSessionService, courseChatSession); - case IrisHestiaSession hestiaSession -> (IrisSubFeatureWrapper) new IrisSubFeatureWrapper<>(irisHestiaSessionService, hestiaSession); case null, default -> throw new BadRequestException("Unknown Iris session type " + session.getClass().getSimpleName()); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java index 785fc59b9ed7..f41de6b6c97d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisConnectorService.java @@ -19,10 +19,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; import de.tum.cit.aet.artemis.iris.exception.IrisException; import de.tum.cit.aet.artemis.iris.exception.IrisForbiddenException; import de.tum.cit.aet.artemis.iris.exception.IrisInternalPyrisErrorException; -import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisModelDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisWebhookLectureIngestionExecutionDTO; import de.tum.cit.aet.artemis.iris.web.open.PublicPyrisStatusUpdateResource; @@ -50,13 +51,14 @@ public PyrisConnectorService(@Qualifier("pyrisRestTemplate") RestTemplate restTe } /** - * Requests all available models from Pyris + * Requests all available variants from Pyris for a feature * - * @return A list of available Models as IrisModelDTO + * @param feature The feature to get the variants for + * @return A list of available Models as IrisVariantDTO */ - public List getOfferedModels() throws PyrisConnectorException { + public List getOfferedVariants(IrisSubSettingsType feature) throws PyrisConnectorException { try { - var response = restTemplate.getForEntity(pyrisUrl + "/api/v1/models", PyrisModelDTO[].class); + var response = restTemplate.getForEntity(pyrisUrl + "/api/v1/pipelines/" + feature.name() + "/variants", PyrisVariantDTO[].class); if (!response.getStatusCode().is2xxSuccessful() || !response.hasBody()) { throw new PyrisConnectorException("Could not fetch offered models"); } 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 aed62b6049c1..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 @@ -11,6 +11,7 @@ import de.tum.cit.aet.artemis.iris.service.IrisCompetencyGenerationService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; @@ -19,8 +20,12 @@ 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; @Service @Profile(PROFILE_IRIS) @@ -30,6 +35,8 @@ public class PyrisStatusUpdateService { private final IrisExerciseChatSessionService irisExerciseChatSessionService; + private final IrisTextExerciseChatSessionService irisTextExerciseChatSessionService; + private final IrisCourseChatSessionService courseChatSessionService; private final IrisCompetencyGenerationService competencyGenerationService; @@ -37,66 +44,85 @@ public class PyrisStatusUpdateService { private static final Logger log = LoggerFactory.getLogger(PyrisStatusUpdateService.class); public PyrisStatusUpdateService(PyrisJobService pyrisJobService, IrisExerciseChatSessionService irisExerciseChatSessionService, - IrisCourseChatSessionService courseChatSessionService, IrisCompetencyGenerationService competencyGenerationService) { + IrisTextExerciseChatSessionService irisTextExerciseChatSessionService, IrisCourseChatSessionService courseChatSessionService, + IrisCompetencyGenerationService competencyGenerationService) { this.pyrisJobService = pyrisJobService; this.irisExerciseChatSessionService = irisExerciseChatSessionService; + this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; this.courseChatSessionService = courseChatSessionService; this.competencyGenerationService = competencyGenerationService; } /** - * 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); + + removeJobIfTerminatedElseUpdate(statusUpdate.stages(), updatedJob); + } + + /** + * Handles the status update of an exercise chat job and forwards it to + * {@link IrisTextExerciseChatSessionService#handleStatusUpdate(TextExerciseChatJob, PyrisTextExerciseChatStatusUpdateDTO)} + * + * @param job the job that is updated + * @param statusUpdate the status update + */ + public void handleStatusUpdate(TextExerciseChatJob job, PyrisTextExerciseChatStatusUpdateDTO 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); + } } /** @@ -108,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/PyrisModelDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/PyrisVariantDTO.java similarity index 67% rename from src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/PyrisModelDTO.java rename to src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/PyrisVariantDTO.java index 705fada64870..ccfbecf7ee9a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/PyrisModelDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/PyrisVariantDTO.java @@ -3,5 +3,5 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PyrisModelDTO(String id, String name, String description) { +public record PyrisVariantDTO(String id, String name, String description) { } 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/chat/textexercise/PyrisTextExerciseChatPipelineExecutionDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/textexercise/PyrisTextExerciseChatPipelineExecutionDTO.java new file mode 100644 index 000000000000..1b2cf59d9fdb --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/textexercise/PyrisTextExerciseChatPipelineExecutionDTO.java @@ -0,0 +1,14 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisMessageDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisTextExerciseDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisTextExerciseChatPipelineExecutionDTO(PyrisPipelineExecutionDTO execution, PyrisTextExerciseDTO exercise, List conversation, + String currentSubmission) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/textexercise/PyrisTextExerciseChatStatusUpdateDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/textexercise/PyrisTextExerciseChatStatusUpdateDTO.java new file mode 100644 index 000000000000..e8b5de7a950b --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/chat/textexercise/PyrisTextExerciseChatStatusUpdateDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.iris.service.pyris.dto.status.PyrisStageDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisTextExerciseChatStatusUpdateDTO(String result, List stages) { +} 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/dto/data/PyrisTextExerciseDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisTextExerciseDTO.java new file mode 100644 index 000000000000..8ba3b1f9aaa8 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/data/PyrisTextExerciseDTO.java @@ -0,0 +1,33 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.dto.data; + +import java.time.Instant; +import java.time.chrono.ChronoZonedDateTime; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.text.domain.TextExercise; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PyrisTextExerciseDTO(long id, String title, PyrisCourseDTO course, String problemStatement, Instant startDate, Instant endDate) { + + /** + * Create a new PyrisTextExerciseDTO from the given TextExercise + * + * @param exercise the exercise + * @return the dto + */ + public static PyrisTextExerciseDTO of(TextExercise exercise) { + // @formatter:off + return new PyrisTextExerciseDTO( + exercise.getId(), + exercise.getTitle(), + new PyrisCourseDTO(exercise.getCourseViaExerciseGroupOrCourseMember()), + exercise.getProblemStatement(), + Optional.ofNullable(exercise.getStartDate()).map(ChronoZonedDateTime::toInstant).orElse(null), + Optional.ofNullable(exercise.getDueDate()).map(ChronoZonedDateTime::toInstant).orElse(null) + ); + // @formatter:on + } + +} 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/TextExerciseChatJob.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/TextExerciseChatJob.java new file mode 100644 index 000000000000..b7dd4bb50046 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/job/TextExerciseChatJob.java @@ -0,0 +1,20 @@ +package de.tum.cit.aet.artemis.iris.service.pyris.job; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record TextExerciseChatJob(String jobId, long courseId, long exerciseId, long sessionId) implements PyrisJob { + + @Override + public boolean canAccess(Course course) { + return course.getId().equals(courseId); + } + + @Override + public boolean canAccess(Exercise exercise) { + return exercise.getId().equals(exerciseId); + } +} 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 a2c404b13103..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; @@ -116,7 +111,8 @@ public void checkRateLimit(User user) { */ @Override public void requestAndHandleResponse(IrisCourseChatSession session) { - requestAndHandleResponse(session, "default", null); + var variant = irisSettingsService.getCombinedIrisSettingsFor(session.getCourse(), false).irisChatSettings().selectedVariant(); + requestAndHandleResponse(session, variant, null); } private void requestAndHandleResponse(IrisCourseChatSession session, String variant, CompetencyJol competencyJol) { @@ -125,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 cec0a9322134..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; @@ -142,13 +136,11 @@ public void requestAndHandleResponse(IrisExerciseChatSession session) { if (chatSession.getExercise().isExamExercise()) { throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); } - // TODO support more exercise types var exercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(chatSession.getExercise().getId()); var latestSubmission = getLatestSubmissionIfExists(exercise, chatSession.getUser()); - // TODO: Use settings to determine the variant - // var irisSettings = irisSettingsService.getCombinedIrisSettingsFor(chatSession.getExercise(), false); - pyrisPipelineService.executeExerciseChatPipeline("default", latestSubmission, exercise, chatSession); + var variant = irisSettingsService.getCombinedIrisSettingsFor(session.getExercise(), false).irisChatSettings().selectedVariant(); + pyrisPipelineService.executeExerciseChatPipeline(variant, latestSubmission, exercise, chatSession); } private Optional getLatestSubmissionIfExists(ProgrammingExercise exercise, User user) { @@ -160,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/IrisHestiaSessionService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisHestiaSessionService.java deleted file mode 100644 index 6762b6d23d43..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisHestiaSessionService.java +++ /dev/null @@ -1,113 +0,0 @@ -package de.tum.cit.aet.artemis.iris.service.session; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; - -import java.time.ZonedDateTime; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import de.tum.cit.aet.artemis.core.domain.User; -import de.tum.cit.aet.artemis.core.security.Role; -import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; -import de.tum.cit.aet.artemis.iris.domain.session.IrisHestiaSession; -import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; -import de.tum.cit.aet.artemis.iris.repository.IrisHestiaSessionRepository; -import de.tum.cit.aet.artemis.iris.repository.IrisSessionRepository; -import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorService; -import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; -import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; -import de.tum.cit.aet.artemis.programming.domain.hestia.CodeHint; - -/** - * Service to handle the Hestia integration of Iris. - */ -@Service -@Profile(PROFILE_IRIS) -public class IrisHestiaSessionService implements IrisButtonBasedFeatureInterface { - - private static final Logger log = LoggerFactory.getLogger(IrisHestiaSessionService.class); - - private final IrisSettingsService irisSettingsService; - - private final AuthorizationCheckService authCheckService; - - private final IrisSessionRepository irisSessionRepository; - - private final IrisHestiaSessionRepository irisHestiaSessionRepository; - - public IrisHestiaSessionService(PyrisConnectorService pyrisConnectorService, IrisSettingsService irisSettingsService, AuthorizationCheckService authCheckService, - IrisSessionRepository irisSessionRepository, IrisHestiaSessionRepository irisHestiaSessionRepository) { - this.irisSettingsService = irisSettingsService; - this.authCheckService = authCheckService; - this.irisSessionRepository = irisSessionRepository; - this.irisHestiaSessionRepository = irisHestiaSessionRepository; - } - - /** - * Creates a new Iris session for the given code hint. - * If there is already an existing session for the code hint from the last hour, it will be returned instead. - * - * @param codeHint The code hint to create the session for - * @return The Iris session for the code hint - */ - public IrisHestiaSession getOrCreateSession(CodeHint codeHint) { - var existingSessions = irisHestiaSessionRepository.findByCodeHintIdOrderByCreationDateDesc(codeHint.getId()); - // Return the newest session if there is one and it is not older than 1 hour - if (!existingSessions.isEmpty() && existingSessions.getFirst().getCreationDate().plusHours(1).isAfter(ZonedDateTime.now())) { - checkHasAccessTo(null, existingSessions.getFirst()); - return existingSessions.getFirst(); - } - - // Otherwise create a new session - var irisSession = new IrisHestiaSession(); - irisSession.setCodeHint(codeHint); - checkHasAccessTo(null, irisSession); - irisSession = irisSessionRepository.save(irisSession); - return irisSession; - } - - @JsonInclude(JsonInclude.Include.NON_EMPTY) - record HestiaDTO(CodeHint codeHint, IrisHestiaSession session, ProgrammingExercise exercise) { - } - - /** - * Generates the description and content for a code hint. - * It does not directly save the code hint, but instead returns it with the generated description and content. - * This way the instructor can still modify the code hint before saving it or discard the changes. - * - * @param session The Iris session to generate the description for - * @return The code hint with the generated description and content - */ - @Override - public CodeHint executeRequest(IrisHestiaSession session) { - // TODO: Re-add in a future PR. Remember to reenable the test cases! - return null; - } - - /** - * Checks if the user has at least the given role for the exercise of the code hint. - * - * @param user The user to check the access for - * @param session The Iris session to check the access for - */ - @Override - public void checkHasAccessTo(User user, IrisHestiaSession session) { - var exercise = session.getCodeHint().getExercise(); - authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, exercise, user); - } - - /** - * Not supported for Iris Hestia sessions. - * - * @param session The session to get a message for - */ - @Override - public void checkIsFeatureActivatedFor(IrisHestiaSession session) { - irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.HESTIA, session.getCodeHint().getExercise()); - } -} 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 new file mode 100644 index 000000000000..8702db7bdf54 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/session/IrisTextExerciseChatSessionService.java @@ -0,0 +1,174 @@ +package de.tum.cit.aet.artemis.iris.service.session; + +import java.util.Comparator; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.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.exercise.domain.Submission; +import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; +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.IrisTextExerciseChatSession; +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.PyrisJobService; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisPipelineService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatPipelineExecutionDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisMessageDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.data.PyrisTextExerciseDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; +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.text.domain.TextSubmission; +import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; + +@Service +@Profile("iris") +public class IrisTextExerciseChatSessionService implements IrisChatBasedFeatureInterface, IrisRateLimitedFeatureInterface { + + private final IrisSettingsService irisSettingsService; + + private final IrisSessionRepository irisSessionRepository; + + private final IrisRateLimitService rateLimitService; + + private final IrisMessageService irisMessageService; + + private final TextExerciseRepository textExerciseRepository; + + private final StudentParticipationRepository studentParticipationRepository; + + private final PyrisPipelineService pyrisPipelineService; + + private final PyrisJobService pyrisJobService; + + private final IrisChatWebsocketService irisChatWebsocketService; + + private final AuthorizationCheckService authCheckService; + + public IrisTextExerciseChatSessionService(IrisSettingsService irisSettingsService, IrisSessionRepository irisSessionRepository, IrisRateLimitService rateLimitService, + IrisMessageService irisMessageService, TextExerciseRepository textExerciseRepository, StudentParticipationRepository studentParticipationRepository, + PyrisPipelineService pyrisPipelineService, PyrisJobService pyrisJobService, IrisChatWebsocketService irisChatWebsocketService, + AuthorizationCheckService authCheckService) { + this.irisSettingsService = irisSettingsService; + this.irisSessionRepository = irisSessionRepository; + this.rateLimitService = rateLimitService; + this.irisMessageService = irisMessageService; + this.textExerciseRepository = textExerciseRepository; + this.studentParticipationRepository = studentParticipationRepository; + this.pyrisPipelineService = pyrisPipelineService; + this.pyrisJobService = pyrisJobService; + this.irisChatWebsocketService = irisChatWebsocketService; + this.authCheckService = authCheckService; + } + + @Override + public void sendOverWebsocket(IrisTextExerciseChatSession session, IrisMessage message) { + irisChatWebsocketService.sendMessage(session, message, null); + } + + @Override + public void requestAndHandleResponse(IrisTextExerciseChatSession irisSession) { + var session = (IrisTextExerciseChatSession) irisSessionRepository.findByIdWithMessagesAndContents(irisSession.getId()); + if (session.getExercise().isExamExercise()) { + throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); + } + var exercise = textExerciseRepository.findByIdElseThrow(session.getExercise().getId()); + if (!irisSettingsService.isEnabledFor(IrisSubSettingsType.TEXT_EXERCISE_CHAT, exercise)) { + throw new ConflictException("Iris is not enabled for this exercise", "Iris", "irisDisabled"); + } + var course = exercise.getCourseViaExerciseGroupOrCourseMember(); + // TODO: Once we can receive client form data through the IrisMessageResource, we should use that instead of fetching the latest submission to get the text + var participation = studentParticipationRepository.findWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), session.getUser().getLogin()); + var latestSubmission = participation.flatMap(p -> p.getSubmissions().stream().max(Comparator.comparingLong(Submission::getId))).orElse(null); + String latestSubmissionText; + if (latestSubmission instanceof TextSubmission textSubmission) { + latestSubmissionText = textSubmission.getText(); + } + else { + latestSubmissionText = null; + } + var conversation = session.getMessages().stream().map(PyrisMessageDTO::of).toList(); + // @formatter:off + pyrisPipelineService.executePipeline( + "text-exercise-chat", + "default", + pyrisJobService.createTokenForJob(token -> new TextExerciseChatJob(token, course.getId(), exercise.getId(), session.getId())), + dto -> new PyrisTextExerciseChatPipelineExecutionDTO(dto, PyrisTextExerciseDTO.of(exercise), conversation, latestSubmissionText), + stages -> irisChatWebsocketService.sendMessage(session, null, stages) + ); + // @formatter:on + } + + /** + * Handles the status update of a text exercise chat job. + * + * @param job The job that is updated + * @param statusUpdate The status update + * @return The same job that was passed in + */ + 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(); + message.addContent(new IrisTextMessageContent(statusUpdate.result())); + IrisMessage savedMessage = irisMessageService.saveMessage(message, session, IrisMessageSender.LLM); + irisChatWebsocketService.sendMessage(session, savedMessage, statusUpdate.stages()); + } + else { + irisChatWebsocketService.sendMessage(session, null, statusUpdate.stages()); + } + + return job; + } + + @Override + public void checkHasAccessTo(User user, IrisTextExerciseChatSession session) { + // TODO: This check is probably unnecessary since we are fetching the sessions from the database with the user ID already + if (!session.getUser().equals(user)) { + throw new AccessForbiddenException("Iris Text Exercise Chat Session", session.getId()); + } + // TODO: This check is probably unnecessary as the endpoint already checks it via the @EnforceAtLeastStudentInExercise annotation + authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, session.getExercise(), user); + } + + /** + * This method returns true if the user has access to the given session. + * The user has access iff the user is a student in the exercise's course, + * and they are the same user that created the session. + * + * @param user The user to check + * @param session The session to check + * @return True if the user has access, false otherwise + */ + public boolean hasAccess(User user, IrisTextExerciseChatSession session) { + try { + checkHasAccessTo(user, session); + return true; + } + catch (AccessForbiddenException e) { + return false; + } + } + + @Override + public void checkIsFeatureActivatedFor(IrisTextExerciseChatSession session) { + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.TEXT_EXERCISE_CHAT, session.getExercise()); + } + + @Override + public void checkRateLimit(User user) { + rateLimitService.checkRateLimitElseThrow(user); + } +} 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 39bb14ff31cd..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,11 @@ import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.Objects; -import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; import java.util.function.Supplier; import org.springframework.boot.context.event.ApplicationReadyEvent; @@ -17,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; @@ -24,20 +30,22 @@ import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.domain.Exercise; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; 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.IrisGlobalSettings; -import de.tum.cit.aet.artemis.iris.domain.settings.IrisHestiaSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; +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.iris.service.IrisDefaultTemplateService; +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}. @@ -54,32 +62,22 @@ public class IrisSettingsService { private final IrisSubSettingsService irisSubSettingsService; - private final IrisDefaultTemplateService irisDefaultTemplateService; - private final AuthorizationCheckService authCheckService; - public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, IrisDefaultTemplateService irisDefaultTemplateService, - AuthorizationCheckService authCheckService) { - this.irisSettingsRepository = irisSettingsRepository; - this.irisSubSettingsService = irisSubSettingsService; - this.irisDefaultTemplateService = irisDefaultTemplateService; - this.authCheckService = authCheckService; - } - - private Optional loadGlobalTemplateVersion() { - return irisDefaultTemplateService.loadGlobalTemplateVersion(); - } + private final ProgrammingExerciseRepository programmingExerciseRepository; - private IrisTemplate loadDefaultChatTemplate() { - return irisDefaultTemplateService.load("chat.hbs"); - } + private final ObjectMapper objectMapper; - private IrisTemplate loadDefaultHestiaTemplate() { - return irisDefaultTemplateService.load("hestia.hbs"); - } + private final TextExerciseRepository textExerciseRepository; - private IrisTemplate loadDefaultCompetencyGenerationTemplate() { - return irisDefaultTemplateService.load("competency-generation.hbs"); + 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; } /** @@ -98,10 +96,6 @@ public void execute(ApplicationReadyEvent event) throws Exception { if (allGlobalSettings.size() > 1) { var maxIdSettings = allGlobalSettings.stream().max(Comparator.comparingLong(IrisSettings::getId)).orElseThrow(); allGlobalSettings.stream().filter(settings -> !Objects.equals(settings.getId(), maxIdSettings.getId())).forEach(irisSettingsRepository::delete); - autoUpdateGlobalSettings(maxIdSettings); - } - else { - autoUpdateGlobalSettings(allGlobalSettings.stream().findFirst().get()); } } @@ -110,46 +104,21 @@ public void execute(ApplicationReadyEvent event) throws Exception { */ private void createInitialGlobalSettings() { var settings = new IrisGlobalSettings(); - settings.setCurrentVersion(loadGlobalTemplateVersion().orElse(0)); initializeIrisChatSettings(settings); + initializeIrisTextExerciseChatSettings(settings); initializeIrisLectureIngestionSettings(settings); - initializeIrisHestiaSettings(settings); initializeIrisCompetencyGenerationSettings(settings); irisSettingsRepository.save(settings); } - /** - * Auto updates the global IrisSettings object if the current version is outdated. - * - * @param settings The global IrisSettings object to update - */ - private void autoUpdateGlobalSettings(IrisGlobalSettings settings) { - Optional globalVersion = loadGlobalTemplateVersion(); - if (globalVersion.isEmpty() || settings.getCurrentVersion() < globalVersion.get()) { - if (settings.isEnableAutoUpdateChat() || settings.getIrisChatSettings() == null) { - initializeIrisChatSettings(settings); - } - if (settings.isEnableAutoUpdateLectureIngestion() || settings.getIrisLectureIngestionSettings() == null) { - initializeIrisLectureIngestionSettings(settings); - } - if (settings.isEnableAutoUpdateHestia() || settings.getIrisHestiaSettings() == null) { - initializeIrisHestiaSettings(settings); - } - if (settings.isEnableAutoUpdateCompetencyGeneration() || settings.getIrisCompetencyGenerationSettings() == null) { - initializeIrisCompetencyGenerationSettings(settings); - } - - globalVersion.ifPresent(settings::setCurrentVersion); - saveIrisSettings(settings); - } - } - private static T initializeSettings(T settings, Supplier constructor) { if (settings == null) { settings = constructor.get(); settings.setEnabled(false); + settings.setAllowedVariants(new TreeSet<>(Set.of("default"))); + settings.setSelectedVariant("default"); } return settings; } @@ -157,27 +126,24 @@ private static T initializeSettings(T settings, Supp private void initializeIrisChatSettings(IrisGlobalSettings settings) { var irisChatSettings = settings.getIrisChatSettings(); irisChatSettings = initializeSettings(irisChatSettings, IrisChatSubSettings::new); - irisChatSettings.setTemplate(loadDefaultChatTemplate()); settings.setIrisChatSettings(irisChatSettings); } + private void initializeIrisTextExerciseChatSettings(IrisGlobalSettings settings) { + var irisChatSettings = settings.getIrisTextExerciseChatSettings(); + irisChatSettings = initializeSettings(irisChatSettings, IrisTextExerciseChatSubSettings::new); + settings.setIrisTextExerciseChatSettings(irisChatSettings); + } + private void initializeIrisLectureIngestionSettings(IrisGlobalSettings settings) { var irisLectureIngestionSettings = settings.getIrisLectureIngestionSettings(); irisLectureIngestionSettings = initializeSettings(irisLectureIngestionSettings, IrisLectureIngestionSubSettings::new); settings.setIrisLectureIngestionSettings(irisLectureIngestionSettings); } - private void initializeIrisHestiaSettings(IrisGlobalSettings settings) { - var irisHestiaSettings = settings.getIrisHestiaSettings(); - irisHestiaSettings = initializeSettings(irisHestiaSettings, IrisHestiaSubSettings::new); - irisHestiaSettings.setTemplate(loadDefaultHestiaTemplate()); - settings.setIrisHestiaSettings(irisHestiaSettings); - } - private void initializeIrisCompetencyGenerationSettings(IrisGlobalSettings settings) { var irisCompetencyGenerationSettings = settings.getIrisCompetencyGenerationSettings(); irisCompetencyGenerationSettings = initializeSettings(irisCompetencyGenerationSettings, IrisCompetencyGenerationSubSettings::new); - irisCompetencyGenerationSettings.setTemplate(loadDefaultCompetencyGenerationTemplate()); settings.setIrisCompetencyGenerationSettings(irisCompetencyGenerationSettings); } @@ -214,9 +180,6 @@ private T saveNewIrisSettings(T settings) { if (settings instanceof IrisGlobalSettings) { throw new BadRequestAlertException("You can not create new global settings", "IrisSettings", "notGlobal"); } - if (!settings.isValid()) { - throw new BadRequestAlertException("New Iris settings are not valid", "IrisSettings", "notValid"); - } if (settings instanceof IrisCourseSettings courseSettings && irisSettingsRepository.findCourseSettings(courseSettings.getCourse().getId()).isPresent()) { throw new ConflictException("Iris settings for this course already exist", "IrisSettings", "alreadyExists"); } @@ -241,9 +204,6 @@ private T updateIrisSettings(long existingSettingsId, T if (!Objects.equals(existingSettingsId, settingsUpdate.getId())) { throw new ConflictException("Existing Iris settings ID does not match update ID", "IrisSettings", "idMismatch"); } - if (!settingsUpdate.isValid()) { - throw new BadRequestAlertException("Updated Iris settings are not valid", "IrisSettings", "notValid"); - } var existingSettings = irisSettingsRepository.findByIdElseThrow(existingSettingsId); @@ -269,19 +229,32 @@ else if (existingSettings instanceof IrisExerciseSettings exerciseSettings && se * @return The updated global Iris settings */ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSettings, IrisGlobalSettings settingsUpdate) { - existingSettings.setCurrentVersion(settingsUpdate.getCurrentVersion()); - - existingSettings.setEnableAutoUpdateChat(settingsUpdate.isEnableAutoUpdateChat()); - existingSettings.setEnableAutoUpdateLectureIngestion(settingsUpdate.isEnableAutoUpdateLectureIngestion()); - existingSettings.setEnableAutoUpdateHestia(settingsUpdate.isEnableAutoUpdateHestia()); - existingSettings.setEnableAutoUpdateCompetencyGeneration(settingsUpdate.isEnableAutoUpdateCompetencyGeneration()); - - existingSettings.setIrisLectureIngestionSettings( - irisSubSettingsService.update(existingSettings.getIrisLectureIngestionSettings(), settingsUpdate.getIrisLectureIngestionSettings(), null, GLOBAL)); - existingSettings.setIrisChatSettings(irisSubSettingsService.update(existingSettings.getIrisChatSettings(), settingsUpdate.getIrisChatSettings(), null, GLOBAL)); - existingSettings.setIrisHestiaSettings(irisSubSettingsService.update(existingSettings.getIrisHestiaSettings(), settingsUpdate.getIrisHestiaSettings(), null, GLOBAL)); - existingSettings.setIrisCompetencyGenerationSettings( - irisSubSettingsService.update(existingSettings.getIrisCompetencyGenerationSettings(), settingsUpdate.getIrisCompetencyGenerationSettings(), null, GLOBAL)); + // @formatter:off + existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisLectureIngestionSettings(), + settingsUpdate.getIrisLectureIngestionSettings(), + null, + GLOBAL + )); + existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + null, + GLOBAL + )); + existingSettings.setIrisChatSettings(irisSubSettingsService.update( + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + null, + GLOBAL + )); + existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( + existingSettings.getIrisCompetencyGenerationSettings(), + settingsUpdate.getIrisCompetencyGenerationSettings(), + null, + GLOBAL + )); + // @formatter:on return irisSettingsRepository.save(existingSettings); } @@ -294,19 +267,158 @@ 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(); - existingSettings.setIrisChatSettings( - irisSubSettingsService.update(existingSettings.getIrisChatSettings(), settingsUpdate.getIrisChatSettings(), parentSettings.irisChatSettings(), COURSE)); - existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update(existingSettings.getIrisLectureIngestionSettings(), - settingsUpdate.getIrisLectureIngestionSettings(), parentSettings.irisLectureIngestionSettings(), COURSE)); - existingSettings.setIrisHestiaSettings( - irisSubSettingsService.update(existingSettings.getIrisHestiaSettings(), settingsUpdate.getIrisHestiaSettings(), parentSettings.irisHestiaSettings(), COURSE)); - existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update(existingSettings.getIrisCompetencyGenerationSettings(), - settingsUpdate.getIrisCompetencyGenerationSettings(), parentSettings.irisCompetencyGenerationSettings(), COURSE)); + // @formatter:off + existingSettings.setIrisChatSettings(irisSubSettingsService.update( + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + parentSettings.irisChatSettings(), + COURSE + )); + existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + parentSettings.irisTextExerciseChatSettings(), + COURSE + )); + existingSettings.setIrisLectureIngestionSettings(irisSubSettingsService.update( + existingSettings.getIrisLectureIngestionSettings(), + settingsUpdate.getIrisLectureIngestionSettings(), + parentSettings.irisLectureIngestionSettings(), + COURSE + )); + existingSettings.setIrisCompetencyGenerationSettings(irisSubSettingsService.update( + existingSettings.getIrisCompetencyGenerationSettings(), + settingsUpdate.getIrisCompetencyGenerationSettings(), + parentSettings.irisCompetencyGenerationSettings(), + COURSE + )); + // @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. * @@ -316,8 +428,20 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti */ private IrisExerciseSettings updateExerciseSettings(IrisExerciseSettings existingSettings, IrisExerciseSettings settingsUpdate) { var parentSettings = getCombinedIrisSettingsFor(existingSettings.getExercise().getCourseViaExerciseGroupOrCourseMember(), false); - existingSettings.setIrisChatSettings( - irisSubSettingsService.update(existingSettings.getIrisChatSettings(), settingsUpdate.getIrisChatSettings(), parentSettings.irisChatSettings(), EXERCISE)); + // @formatter:off + existingSettings.setIrisChatSettings(irisSubSettingsService.update( + existingSettings.getIrisChatSettings(), + settingsUpdate.getIrisChatSettings(), + parentSettings.irisChatSettings(), + EXERCISE + )); + existingSettings.setIrisTextExerciseChatSettings(irisSubSettingsService.update( + existingSettings.getIrisTextExerciseChatSettings(), + settingsUpdate.getIrisTextExerciseChatSettings(), + parentSettings.irisTextExerciseChatSettings(), + EXERCISE + )); + // @formatter:on return irisSettingsRepository.save(existingSettings); } @@ -381,9 +505,14 @@ public IrisCombinedSettingsDTO getCombinedIrisGlobalSettings() { var settingsList = new ArrayList(); settingsList.add(getGlobalSettings()); - return new IrisCombinedSettingsDTO(irisSubSettingsService.combineChatSettings(settingsList, false), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), irisSubSettingsService.combineHestiaSettings(settingsList, false), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false)); + // @formatter:off + return new IrisCombinedSettingsDTO( + irisSubSettingsService.combineChatSettings(settingsList, false), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, false), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, false), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, false) + ); + // @formatter:on } /** @@ -401,9 +530,14 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Course course, boolean settingsList.add(getGlobalSettings()); settingsList.add(irisSettingsRepository.findCourseSettings(course.getId()).orElse(null)); - return new IrisCombinedSettingsDTO(irisSubSettingsService.combineChatSettings(settingsList, minimal), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), irisSubSettingsService.combineHestiaSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal)); + // @formatter:off + return new IrisCombinedSettingsDTO( + irisSubSettingsService.combineChatSettings(settingsList, minimal), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + ); + // @formatter:on } /** @@ -422,9 +556,14 @@ public IrisCombinedSettingsDTO getCombinedIrisSettingsFor(Exercise exercise, boo settingsList.add(getRawIrisSettingsFor(exercise.getCourseViaExerciseGroupOrCourseMember())); settingsList.add(getRawIrisSettingsFor(exercise)); - return new IrisCombinedSettingsDTO(irisSubSettingsService.combineChatSettings(settingsList, minimal), - irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), irisSubSettingsService.combineHestiaSettings(settingsList, minimal), - irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal)); + // @formatter:off + return new IrisCombinedSettingsDTO( + irisSubSettingsService.combineChatSettings(settingsList, minimal), + irisSubSettingsService.combineTextExerciseChatSettings(settingsList, minimal), + irisSubSettingsService.combineLectureIngestionSubSettings(settingsList, minimal), + irisSubSettingsService.combineCompetencyGenerationSettings(settingsList, minimal) + ); + // @formatter:on } /** @@ -450,8 +589,8 @@ public IrisCourseSettings getDefaultSettingsFor(Course course) { settings.setCourse(course); settings.setIrisLectureIngestionSettings(new IrisLectureIngestionSubSettings()); settings.setIrisChatSettings(new IrisChatSubSettings()); - settings.setIrisHestiaSettings(new IrisHestiaSubSettings()); settings.setIrisCompetencyGenerationSettings(new IrisCompetencyGenerationSubSettings()); + settings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings()); return settings; } @@ -466,6 +605,7 @@ public IrisExerciseSettings getDefaultSettingsFor(Exercise exercise) { var settings = new IrisExerciseSettings(); settings.setExercise(exercise); settings.setIrisChatSettings(new IrisChatSubSettings()); + settings.setIrisTextExerciseChatSettings(new IrisTextExerciseChatSubSettings()); return settings; } @@ -523,7 +663,7 @@ public void deleteSettingsFor(Exercise exercise) { private boolean isFeatureEnabledInSettings(IrisCombinedSettingsDTO settings, IrisSubSettingsType type) { return switch (type) { case CHAT -> settings.irisChatSettings().enabled(); - case HESTIA -> settings.irisHestiaSettings().enabled(); + case TEXT_EXERCISE_CHAT -> settings.irisTextExerciseChatSettings().enabled(); case COMPETENCY_GENERATION -> settings.irisCompetencyGenerationSettings().enabled(); case LECTURE_INGESTION -> settings.irisLectureIngestionSettings().enabled(); }; diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index dfd555bb59c5..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 @@ -15,19 +15,19 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; -import de.tum.cit.aet.artemis.iris.domain.IrisTemplate; 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.IrisHestiaSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettingsType; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedChatSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedCompetencyGenerationSubSettingsDTO; -import de.tum.cit.aet.artemis.iris.dto.IrisCombinedHestiaSubSettingsDTO; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedLectureIngestionSubSettingsDTO; +import de.tum.cit.aet.artemis.iris.dto.IrisCombinedTextExerciseChatSubSettingsDTO; /** * Service for handling {@link IrisSubSettings} objects. @@ -72,79 +72,85 @@ 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()); } - currentSettings.setAllowedModels(selectAllowedModels(currentSettings.getAllowedModels(), newSettings.getAllowedModels())); - currentSettings.setPreferredModel(validatePreferredModel(currentSettings.getPreferredModel(), newSettings.getPreferredModel(), currentSettings.getAllowedModels(), - parentSettings != null ? parentSettings.allowedModels() : null)); - currentSettings.setTemplate(newSettings.getTemplate()); + currentSettings.setAllowedVariants(selectAllowedVariants(currentSettings.getAllowedVariants(), newSettings.getAllowedVariants())); + currentSettings.setSelectedVariant(validateSelectedVariant(currentSettings.getSelectedVariant(), newSettings.getSelectedVariant(), currentSettings.getAllowedVariants(), + parentSettings != null ? parentSettings.allowedVariants() : null)); return currentSettings; } /** - * Updates a Lecture Ingestion sub settings object. - * If the new settings are null, the current settings will be deleted (except if the parent settings are null == if the settings are global). - * Special notes: + * Updates a text exercise chat sub settings object. * - * @param currentSettings Current Lecture Ingestion sub settings. - * @param newSettings Updated Lecture Ingestion sub settings. - * @param parentSettings Parent Lecture Ingestion sub settings. + * @param currentSettings Current chat sub settings. + * @param newSettings Updated chat sub settings. + * @param parentSettings Parent chat sub settings. * @param settingsType Type of the settings the sub settings belong to. - * @return Updated Lecture Ingestion sub settings. + * @return Updated chat sub settings. */ - public IrisLectureIngestionSubSettings update(IrisLectureIngestionSubSettings currentSettings, IrisLectureIngestionSubSettings newSettings, - IrisCombinedLectureIngestionSubSettingsDTO parentSettings, IrisSettingsType settingsType) { + public IrisTextExerciseChatSubSettings update(IrisTextExerciseChatSubSettings currentSettings, IrisTextExerciseChatSubSettings newSettings, + IrisCombinedTextExerciseChatSubSettingsDTO parentSettings, IrisSettingsType settingsType) { if (newSettings == null) { if (parentSettings == null) { - throw new IllegalArgumentException("Cannot delete the Lecture Ingestion settings"); + throw new IllegalArgumentException("Cannot delete the chat settings"); } return null; } if (currentSettings == null) { - currentSettings = new IrisLectureIngestionSubSettings(); + currentSettings = new IrisTextExerciseChatSubSettings(); } - - if (authCheckService.isAdmin() && (settingsType == IrisSettingsType.COURSE || settingsType == IrisSettingsType.GLOBAL)) { + if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); - currentSettings.setAutoIngestOnLectureAttachmentUpload(newSettings.getAutoIngestOnLectureAttachmentUpload()); } - + if (settingsType == IrisSettingsType.COURSE) { + var enabledForCategories = newSettings.getEnabledForCategories(); + currentSettings.setEnabledForCategories(enabledForCategories); + } + if (authCheckService.isAdmin()) { + currentSettings.setRateLimit(newSettings.getRateLimit()); + currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); + } + currentSettings.setAllowedVariants(selectAllowedVariants(currentSettings.getAllowedVariants(), newSettings.getAllowedVariants())); + currentSettings.setSelectedVariant(validateSelectedVariant(currentSettings.getSelectedVariant(), newSettings.getSelectedVariant(), currentSettings.getAllowedVariants(), + parentSettings != null ? parentSettings.allowedVariants() : null)); return currentSettings; } /** - * Updates a Hestia sub settings object. + * Updates a Lecture Ingestion sub settings object. * If the new settings are null, the current settings will be deleted (except if the parent settings are null == if the settings are global). * Special notes: - * - If the user is not an admin the allowed models will not be updated. - * - If the user is not an admin the preferred model will only be updated if it is included in the allowed models. * - * @param currentSettings Current Hestia sub settings. - * @param newSettings Updated Hestia sub settings. - * @param parentSettings Parent Hestia sub settings. + * @param currentSettings Current Lecture Ingestion sub settings. + * @param newSettings Updated Lecture Ingestion sub settings. + * @param parentSettings Parent Lecture Ingestion sub settings. * @param settingsType Type of the settings the sub settings belong to. - * @return Updated Hestia sub settings. + * @return Updated Lecture Ingestion sub settings. */ - public IrisHestiaSubSettings update(IrisHestiaSubSettings currentSettings, IrisHestiaSubSettings newSettings, IrisCombinedHestiaSubSettingsDTO parentSettings, - IrisSettingsType settingsType) { + public IrisLectureIngestionSubSettings update(IrisLectureIngestionSubSettings currentSettings, IrisLectureIngestionSubSettings newSettings, + IrisCombinedLectureIngestionSubSettingsDTO parentSettings, IrisSettingsType settingsType) { if (newSettings == null) { if (parentSettings == null) { - throw new IllegalArgumentException("Cannot delete the Hestia settings"); + throw new IllegalArgumentException("Cannot delete the Lecture Ingestion settings"); } return null; } if (currentSettings == null) { - currentSettings = new IrisHestiaSubSettings(); + currentSettings = new IrisLectureIngestionSubSettings(); } - if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { + + if (authCheckService.isAdmin() && (settingsType == IrisSettingsType.COURSE || settingsType == IrisSettingsType.GLOBAL)) { currentSettings.setEnabled(newSettings.isEnabled()); + currentSettings.setAutoIngestOnLectureAttachmentUpload(newSettings.getAutoIngestOnLectureAttachmentUpload()); } - currentSettings.setAllowedModels(selectAllowedModels(currentSettings.getAllowedModels(), newSettings.getAllowedModels())); - currentSettings.setPreferredModel(validatePreferredModel(currentSettings.getPreferredModel(), newSettings.getPreferredModel(), currentSettings.getAllowedModels(), - parentSettings != null ? parentSettings.allowedModels() : null)); - currentSettings.setTemplate(newSettings.getTemplate()); + return currentSettings; } @@ -174,11 +180,10 @@ public IrisCompetencyGenerationSubSettings update(IrisCompetencyGenerationSubSet } if (authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); - currentSettings.setAllowedModels(selectAllowedModels(currentSettings.getAllowedModels(), newSettings.getAllowedModels())); + currentSettings.setAllowedVariants(selectAllowedVariants(currentSettings.getAllowedVariants(), newSettings.getAllowedVariants())); } - currentSettings.setPreferredModel(validatePreferredModel(currentSettings.getPreferredModel(), newSettings.getPreferredModel(), currentSettings.getAllowedModels(), - parentSettings != null ? parentSettings.allowedModels() : null)); - currentSettings.setTemplate(newSettings.getTemplate()); + currentSettings.setSelectedVariant(validateSelectedVariant(currentSettings.getSelectedVariant(), newSettings.getSelectedVariant(), currentSettings.getAllowedVariants(), + parentSettings != null ? parentSettings.allowedVariants() : null)); return currentSettings; } @@ -187,12 +192,12 @@ public IrisCompetencyGenerationSubSettings update(IrisCompetencyGenerationSubSet * If the user is an admin, all models are allowed. * Otherwise, only models that are allowed by the parent settings or the current settings are allowed. * - * @param allowedModels The allowed models of the current settings. - * @param updatedAllowedModels The allowed models of the updated settings. + * @param allowedVariants The allowed models of the current settings. + * @param updatedAllowedVariants The allowed models of the updated settings. * @return The filtered allowed models. */ - private SortedSet selectAllowedModels(SortedSet allowedModels, SortedSet updatedAllowedModels) { - return authCheckService.isAdmin() ? updatedAllowedModels : allowedModels; + private SortedSet selectAllowedVariants(SortedSet allowedVariants, SortedSet updatedAllowedVariants) { + return authCheckService.isAdmin() ? updatedAllowedVariants : allowedVariants; } /** @@ -200,23 +205,23 @@ private SortedSet selectAllowedModels(SortedSet allowedModels, S * If the user is an admin, all models are allowed. * Otherwise, only models that are allowed by the current settings are allowed. * - * @param preferredModel The preferred model of the current settings. - * @param newPreferredModel The preferred model of the updated settings. - * @param allowedModels The allowed models of the current settings. - * @param parentAllowedModels The allowed models of the parent settings. + * @param selectedVariant The preferred model of the current settings. + * @param newSelectedVariant The preferred model of the updated settings. + * @param allowedVariants The allowed models of the current settings. + * @param parentAllowedVariants The allowed models of the parent settings. * @return The validated preferred model. */ - private String validatePreferredModel(String preferredModel, String newPreferredModel, Set allowedModels, Set parentAllowedModels) { - if (newPreferredModel == null || newPreferredModel.isBlank()) { + private String validateSelectedVariant(String selectedVariant, String newSelectedVariant, Set allowedVariants, Set parentAllowedVariants) { + if (newSelectedVariant == null || newSelectedVariant.isBlank()) { return null; } - var canChangePreferredModel = authCheckService.isAdmin() || (allowedModels != null && !allowedModels.isEmpty() && allowedModels.contains(newPreferredModel)) - || ((allowedModels == null || allowedModels.isEmpty()) && parentAllowedModels != null && parentAllowedModels.contains(newPreferredModel)); - if (canChangePreferredModel) { - return newPreferredModel; + var canChangeSelectedVariant = authCheckService.isAdmin() || (allowedVariants != null && !allowedVariants.isEmpty() && allowedVariants.contains(newSelectedVariant)) + || ((allowedVariants == null || allowedVariants.isEmpty()) && parentAllowedVariants != null && parentAllowedVariants.contains(newSelectedVariant)); + if (canChangeSelectedVariant) { + return newSelectedVariant; } - return preferredModel; + return selectedVariant; } /** @@ -228,45 +233,45 @@ private String validatePreferredModel(String preferredModel, String newPreferred * @param minimal Whether to return a minimal version of the combined settings. * @return Combined chat settings. */ - public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, boolean minimal) { - var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisChatSettings); + public IrisCombinedTextExerciseChatSubSettingsDTO combineTextExerciseChatSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisTextExerciseChatSettings); var rateLimit = getCombinedRateLimit(settingsList); - var allowedModels = minimal ? getCombinedAllowedModels(settingsList, IrisSettings::getIrisChatSettings) : null; - var preferredModel = minimal ? getCombinedPreferredModel(settingsList, IrisSettings::getIrisChatSettings) : null; - var template = minimal ? getCombinedTemplate(settingsList, IrisSettings::getIrisChatSettings, IrisChatSubSettings::getTemplate) : null; - return new IrisCombinedChatSubSettingsDTO(enabled, rateLimit, null, allowedModels, preferredModel, template); + var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; + var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; + var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; + return new IrisCombinedTextExerciseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); } /** - * Combines the Lecture Ingestion settings of multiple {@link IrisSettings} objects. + * Combines the chat settings of multiple {@link IrisSettings} objects. * If minimal is true, the returned object will only contain the enabled and rateLimit fields. * The minimal version can safely be sent to students. * * @param settingsList List of {@link IrisSettings} objects to combine. * @param minimal Whether to return a minimal version of the combined settings. - * @return Combined Lecture Ingestion settings. + * @return Combined chat settings. */ - public IrisCombinedLectureIngestionSubSettingsDTO combineLectureIngestionSubSettings(ArrayList settingsList, boolean minimal) { - var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisLectureIngestionSettings); - return new IrisCombinedLectureIngestionSubSettingsDTO(enabled); + public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisChatSettings); + var rateLimit = getCombinedRateLimit(settingsList); + var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; + var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; + var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; + return new IrisCombinedChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); } /** - * Combines the Hestia settings of multiple {@link IrisSettings} objects. - * If minimal is true, the returned object will only contain the enabled field. + * Combines the Lecture Ingestion settings of multiple {@link IrisSettings} objects. + * If minimal is true, the returned object will only contain the enabled and rateLimit fields. * The minimal version can safely be sent to students. * * @param settingsList List of {@link IrisSettings} objects to combine. * @param minimal Whether to return a minimal version of the combined settings. - * @return Combined Hestia settings. + * @return Combined Lecture Ingestion settings. */ - public IrisCombinedHestiaSubSettingsDTO combineHestiaSettings(ArrayList settingsList, boolean minimal) { - var actualSettingsList = settingsList.stream().filter(settings -> !(settings instanceof IrisExerciseSettings)).toList(); - var enabled = getCombinedEnabled(actualSettingsList, IrisSettings::getIrisHestiaSettings); - var allowedModels = minimal ? getCombinedAllowedModels(actualSettingsList, IrisSettings::getIrisHestiaSettings) : null; - var preferredModel = minimal ? getCombinedPreferredModel(actualSettingsList, IrisSettings::getIrisHestiaSettings) : null; - var template = minimal ? getCombinedTemplate(actualSettingsList, IrisSettings::getIrisHestiaSettings, IrisHestiaSubSettings::getTemplate) : null; - return new IrisCombinedHestiaSubSettingsDTO(enabled, allowedModels, preferredModel, template); + public IrisCombinedLectureIngestionSubSettingsDTO combineLectureIngestionSubSettings(ArrayList settingsList, boolean minimal) { + var enabled = getCombinedEnabled(settingsList, IrisSettings::getIrisLectureIngestionSettings); + return new IrisCombinedLectureIngestionSubSettingsDTO(enabled); } /** @@ -281,11 +286,9 @@ public IrisCombinedHestiaSubSettingsDTO combineHestiaSettings(ArrayList settingsList, boolean minimal) { var actualSettingsList = settingsList.stream().filter(settings -> !(settings instanceof IrisExerciseSettings)).toList(); var enabled = getCombinedEnabled(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings); - var allowedModels = minimal ? getCombinedAllowedModels(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings) : null; - var preferredModel = minimal ? getCombinedPreferredModel(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings) : null; - var template = minimal ? getCombinedTemplate(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings, IrisCompetencyGenerationSubSettings::getTemplate) - : null; - return new IrisCombinedCompetencyGenerationSubSettingsDTO(enabled, allowedModels, preferredModel, template); + var allowedVariants = !minimal ? getCombinedAllowedVariants(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings) : null; + var selectedVariant = !minimal ? getCombinedSelectedVariant(actualSettingsList, IrisSettings::getIrisCompetencyGenerationSettings) : null; + return new IrisCombinedCompetencyGenerationSubSettingsDTO(enabled, allowedVariants, selectedVariant); } /** @@ -322,43 +325,34 @@ private Integer getCombinedRateLimit(List settingsList) { } /** - * Combines the allowedModels field of multiple {@link IrisSettings} objects. - * Simply takes the last allowedModels. + * Combines the allowedVariants field of multiple {@link IrisSettings} objects. + * Simply takes the last allowedVariants. * * @param settingsList List of {@link IrisSettings} objects to combine. * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. - * @return Combined allowedModels field. + * @return Combined allowedVariants field. */ - private Set getCombinedAllowedModels(List settingsList, Function subSettingsFunction) { - return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getAllowedModels).filter(Objects::nonNull) + 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<>()); } /** - * Combines the preferredModel field of multiple {@link IrisSettings} objects. - * Simply takes the last preferredModel. - * TODO + * Combines the selectedVariant field of multiple {@link IrisSettings} objects. + * Simply takes the last selectedVariant. * * @param settingsList List of {@link IrisSettings} objects to combine. * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. - * @return Combined preferredModel field. + * @return Combined selectedVariant field. */ - private String getCombinedPreferredModel(List settingsList, Function subSettingsFunction) { - return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getPreferredModel) + private String getCombinedSelectedVariant(List settingsList, Function subSettingsFunction) { + 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); } - /** - * Combines the template field of multiple {@link IrisSettings} objects. - * Simply takes the last template. - * - * @param settingsList List of {@link IrisSettings} objects to combine. - * @param templateFunction Function to get the template from the sub settings from an IrisSettings object. - * @return Combined template field. - */ - private IrisTemplate getCombinedTemplate(List settingsList, Function subSettingsFunction, - Function templateFunction) { - return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(templateFunction) - .filter(template -> template != null && template.getContent() != null && !template.getContent().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/iris/web/IrisExerciseChatSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java index 55204aa63397..9e736ce8c358 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisExerciseChatSessionResource.java @@ -35,6 +35,7 @@ @Profile(PROFILE_IRIS) @RestController @RequestMapping("api/iris/exercise-chat/") +// TODO: Rename to IrisProgrammingExerciseChatSessionResource public class IrisExerciseChatSessionResource { protected final UserRepository userRepository; @@ -47,6 +48,7 @@ public class IrisExerciseChatSessionResource { protected final IrisRateLimitService irisRateLimitService; + // TODO: This could be a ProgrammingExerciseRepository protected final ExerciseRepository exerciseRepository; private final IrisExerciseChatSessionRepository irisExerciseChatSessionRepository; @@ -134,6 +136,7 @@ public ResponseEntity createSessionForExercise(@PathVar private static ProgrammingExercise validateExercise(Exercise exercise) { if (!(exercise instanceof ProgrammingExercise programmingExercise)) { + // TODO: Remove this once we are only fetching ProgrammingExercises from the DB anyway throw new ConflictException("Iris is only supported for programming exercises", "Iris", "irisProgrammingExercise"); } if (exercise.isExamExercise()) { diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisModelsResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisModelsResource.java deleted file mode 100644 index ae4bedb82493..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisModelsResource.java +++ /dev/null @@ -1,49 +0,0 @@ -package de.tum.cit.aet.artemis.iris.web; - -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; - -import java.util.List; - -import org.springframework.context.annotation.Profile; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import de.tum.cit.aet.artemis.core.exception.InternalServerErrorException; -import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; -import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorException; -import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorService; -import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisModelDTO; - -/** - * REST controller for managing the models Pyris provides. - */ -@Profile(PROFILE_IRIS) -@RestController -@RequestMapping("api/") -public class IrisModelsResource { - - private final PyrisConnectorService pyrisConnectorService; - - public IrisModelsResource(PyrisConnectorService pyrisConnectorService) { - this.pyrisConnectorService = pyrisConnectorService; - } - - /** - * GET iris/models: Retrieve all available models offered by Pyris - * - * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body a List of the models - */ - @GetMapping("iris/models") - @EnforceAtLeastEditor - public ResponseEntity> getAllModels() { - try { - var models = pyrisConnectorService.getOfferedModels(); - return ResponseEntity.ok(models); - } - catch (PyrisConnectorException e) { - throw new InternalServerErrorException("Could not fetch available Iris models"); - } - } -} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java index 32676da073bb..4f2c5416211d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisSettingsResource.java @@ -14,16 +14,20 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; -import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; 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.EnforceAtLeastEditorInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastInstructorInExercise; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastStudentInExercise; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; -import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; /** * REST controller for managing {@link IrisSettings}. @@ -41,15 +45,15 @@ public class IrisSettingsResource { private final AuthorizationCheckService authCheckService; - private final ProgrammingExerciseRepository programmingExerciseRepository; + private final ExerciseRepository exerciseRepository; public IrisSettingsResource(UserRepository userRepository, CourseRepository courseRepository, IrisSettingsService irisSettingsService, - AuthorizationCheckService authCheckService, ProgrammingExerciseRepository programmingExerciseRepository) { + AuthorizationCheckService authCheckService, ExerciseRepository exerciseRepository) { this.userRepository = userRepository; this.courseRepository = courseRepository; this.irisSettingsService = irisSettingsService; this.authCheckService = authCheckService; - this.programmingExerciseRepository = programmingExerciseRepository; + this.exerciseRepository = exerciseRepository; } /** @@ -71,24 +75,23 @@ public ResponseEntity getGlobalSettings() { * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the course could not be found. */ @GetMapping("courses/{courseId}/raw-iris-settings") - @EnforceAtLeastEditor + @EnforceAtLeastEditorInCourse public ResponseEntity getRawCourseSettings(@PathVariable Long courseId) { var course = courseRepository.findByIdElseThrow(courseId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); var irisSettings = irisSettingsService.getRawIrisSettingsFor(course); return ResponseEntity.ok(irisSettings); } /** - * GET programming-exercises/{exerciseId}/raw-iris-settings: Retrieve the raw iris settings for the programming exercise. + * GET exercises/{exerciseId}/raw-iris-settings: Retrieve the raw iris settings for the exercise. * - * @param exerciseId of the programming exercise + * @param exerciseId of the exercise * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the exercise could not be found. */ - @GetMapping("programming-exercises/{exerciseId}/raw-iris-settings") - @EnforceAtLeastEditor - public ResponseEntity getRawProgrammingExerciseSettings(@PathVariable Long exerciseId) { - var exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); + @GetMapping("exercises/{exerciseId}/raw-iris-settings") + @EnforceAtLeastEditorInExercise + public ResponseEntity getRawExerciseSettings(@PathVariable Long exerciseId) { + var exercise = exerciseRepository.findByIdElseThrow(exerciseId); var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, exercise, user); @@ -103,11 +106,10 @@ public ResponseEntity getRawProgrammingExerciseSettings(@PathVaria * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the course could not be found. */ @GetMapping("courses/{courseId}/iris-settings") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity getCourseSettings(@PathVariable Long courseId) { var course = courseRepository.findByIdElseThrow(courseId); var user = userRepository.getUserWithGroupsAndAuthorities(); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user); // Editors can see the full settings, students only the reduced settings var getReduced = !authCheckService.isAtLeastEditorInCourse(course, user); @@ -116,17 +118,16 @@ public ResponseEntity getCourseSettings(@PathVariable L } /** - * GET programming-exercises/{exerciseId}/iris-settings: Retrieve the actual iris settings for the programming exercise. + * GET exercises/{exerciseId}/iris-settings: Retrieve the actual iris settings for the exercise. * - * @param exerciseId of the programming exercise + * @param exerciseId of the exercise * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the settings, or with status {@code 404 (Not Found)} if the exercise could not be found. */ - @GetMapping("programming-exercises/{exerciseId}/iris-settings") - @EnforceAtLeastStudent - public ResponseEntity getProgrammingExerciseSettings(@PathVariable Long exerciseId) { - var exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); + @GetMapping("exercises/{exerciseId}/iris-settings") + @EnforceAtLeastStudentInExercise + public ResponseEntity getExerciseSettings(@PathVariable Long exerciseId) { + var exercise = exerciseRepository.findByIdElseThrow(exerciseId); var user = userRepository.getUserWithGroupsAndAuthorities(); - authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.STUDENT, exercise, user); var combinedIrisSettings = irisSettingsService.getCombinedIrisSettingsFor(exercise, irisSettingsService.shouldShowMinimalSettings(exercise, user)); return ResponseEntity.ok(combinedIrisSettings); @@ -140,29 +141,26 @@ public ResponseEntity getProgrammingExerciseSettings(@P * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the updated settings, or with status {@code 404 (Not Found)} if the course could not be found. */ @PutMapping("courses/{courseId}/raw-iris-settings") - @EnforceAtLeastEditor + @EnforceAtLeastInstructorInCourse public ResponseEntity updateCourseSettings(@PathVariable Long courseId, @RequestBody IrisCourseSettings settings) { var course = courseRepository.findByIdElseThrow(courseId); - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); settings.setCourse(course); var updatedSettings = irisSettingsService.saveIrisSettings(settings); return ResponseEntity.ok(updatedSettings); } /** - * PUT programming-exercises/{exerciseId}/raw-iris-settings: Update the raw iris settings for the programming exercise. + * PUT exercises/{exerciseId}/raw-iris-settings: Update the raw iris settings for the exercise. * - * @param exerciseId of the programming exercise + * @param exerciseId of the exercise * @param settings the settings to update * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the updated settings, or with status {@code 404 (Not Found)} if the exercise could not be * found. */ - @PutMapping("programming-exercises/{exerciseId}/raw-iris-settings") - @EnforceAtLeastInstructor - public ResponseEntity updateProgrammingExerciseSettings(@PathVariable Long exerciseId, @RequestBody IrisExerciseSettings settings) { - var exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); - var user = userRepository.getUserWithGroupsAndAuthorities(); - authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, exercise, user); + @PutMapping("exercises/{exerciseId}/raw-iris-settings") + @EnforceAtLeastInstructorInExercise + public ResponseEntity updateExerciseSettings(@PathVariable Long exerciseId, @RequestBody IrisExerciseSettings settings) { + var exercise = exerciseRepository.findByIdElseThrow(exerciseId); settings.setExercise(exercise); var updatedSettings = irisSettingsService.saveIrisSettings(settings); return ResponseEntity.ok(updatedSettings); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisTextExerciseChatSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisTextExerciseChatSessionResource.java new file mode 100644 index 000000000000..9ebb7aa647a2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisTextExerciseChatSessionResource.java @@ -0,0 +1,137 @@ +package de.tum.cit.aet.artemis.iris.web; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.exception.ConflictException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastStudentInExercise; +import de.tum.cit.aet.artemis.iris.domain.session.IrisTextExerciseChatSession; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; +import de.tum.cit.aet.artemis.iris.repository.IrisTextExerciseChatSessionRepository; +import de.tum.cit.aet.artemis.iris.service.IrisSessionService; +import de.tum.cit.aet.artemis.iris.service.session.IrisTextExerciseChatSessionService; +import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; + +/** + * REST controller for managing {@link IrisTextExerciseChatSession}. + */ +@Profile("iris") +@RestController +@RequestMapping("api/iris/text-exercise-chat/") +public class IrisTextExerciseChatSessionResource { + + private final UserRepository userRepository; + + private final IrisSessionService irisSessionService; + + private final IrisSettingsService irisSettingsService; + + private final TextExerciseRepository textExerciseRepository; + + private final IrisTextExerciseChatSessionService irisTextExerciseChatSessionService; + + private final IrisTextExerciseChatSessionRepository irisTextExerciseChatSessionRepository; + + protected IrisTextExerciseChatSessionResource(IrisTextExerciseChatSessionRepository irisTextExerciseChatSessionRepository, UserRepository userRepository, + TextExerciseRepository textExerciseRepository, IrisSessionService irisSessionService, IrisSettingsService irisSettingsService, + IrisTextExerciseChatSessionService irisTextExerciseChatSessionService) { + this.irisTextExerciseChatSessionRepository = irisTextExerciseChatSessionRepository; + this.userRepository = userRepository; + this.irisSessionService = irisSessionService; + this.irisSettingsService = irisSettingsService; + this.textExerciseRepository = textExerciseRepository; + this.irisTextExerciseChatSessionService = irisTextExerciseChatSessionService; + } + + /** + * GET exercise-chat/{exerciseId}/sessions/current: Retrieve the current iris session for the programming exercise. + * + * @param exerciseId of the exercise + * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the current iris session for the exercise or {@code 404 (Not Found)} if no session exists + */ + @PostMapping("{exerciseId}/sessions/current") + @EnforceAtLeastStudentInExercise + public ResponseEntity getCurrentSessionOrCreateIfNotExists(@PathVariable Long exerciseId) throws URISyntaxException { + var exercise = textExerciseRepository.findByIdElseThrow(exerciseId); + validateExercise(exercise); + + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.TEXT_EXERCISE_CHAT, exercise); + var user = userRepository.getUserWithGroupsAndAuthorities(); + + var sessionOptional = irisTextExerciseChatSessionRepository.findLatestByExerciseIdAndUserIdWithMessages(exercise.getId(), user.getId(), Pageable.ofSize(1)).stream() + .findFirst(); + if (sessionOptional.isPresent()) { + var session = sessionOptional.get(); + irisSessionService.checkHasAccessToIrisSession(session, user); + return ResponseEntity.ok(session); + } + + return createSessionForExercise(exerciseId); + } + + /** + * POST exercise-chat/{exerciseId}/session: Create a new iris session for an exercise and user. + * If there already exists an iris session for the exercise and user, a new one is created. + * Note: The old session including messages is not deleted and can still be retrieved + * + * @param exerciseId of the exercise + * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the new iris session for the exercise + */ + @PostMapping("{exerciseId}/sessions") + @EnforceAtLeastStudentInExercise + public ResponseEntity createSessionForExercise(@PathVariable Long exerciseId) throws URISyntaxException { + var textExercise = textExerciseRepository.findByIdElseThrow(exerciseId); + validateExercise(textExercise); + + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.TEXT_EXERCISE_CHAT, textExercise); + var user = userRepository.getUserWithGroupsAndAuthorities(); + user.hasAcceptedIrisElseThrow(); + + var session = irisTextExerciseChatSessionRepository.save(new IrisTextExerciseChatSession(textExercise, user)); + var uriString = "/api/iris/sessions/" + session.getId(); + + return ResponseEntity.created(new URI(uriString)).body(session); + } + + /** + * GET exercise-chat/{exerciseId}/sessions: Retrieve all Iris Sessions for the programming exercise + * + * @param exerciseId of the exercise + * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body a list of the iris sessions for the exercise or {@code 404 (Not Found)} if no session exists + */ + @GetMapping("{exerciseId}/sessions") + @EnforceAtLeastStudentInExercise + public ResponseEntity> getAllSessions(@PathVariable Long exerciseId) { + var exercise = textExerciseRepository.findByIdElseThrow(exerciseId); + validateExercise(exercise); + + irisSettingsService.isEnabledForElseThrow(IrisSubSettingsType.TEXT_EXERCISE_CHAT, exercise); + var user = userRepository.getUserWithGroupsAndAuthorities(); + user.hasAcceptedIrisElseThrow(); + + var sessions = irisTextExerciseChatSessionRepository.findByExerciseIdAndUserIdElseThrow(exercise.getId(), user.getId()); + // TODO: Discuss this with the team: should we filter out sessions where the user does not have access, or throw an exception? + // Access check might not even be necessary here -> see comments in hasAccess method + var filteredSessions = sessions.stream().filter(session -> irisTextExerciseChatSessionService.hasAccess(user, session)).toList(); + return ResponseEntity.ok(filteredSessions); + } + + private static void validateExercise(TextExercise exercise) { + if (exercise.isExamExercise()) { + throw new ConflictException("Iris is not supported for exam exercises", "Iris", "irisExamExercise"); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisVariantsResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisVariantsResource.java new file mode 100644 index 000000000000..9342d1522023 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/IrisVariantsResource.java @@ -0,0 +1,56 @@ +package de.tum.cit.aet.artemis.iris.web; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.exception.InternalServerErrorException; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorException; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisConnectorService; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.PyrisVariantDTO; + +/** + * REST controller for managing the variants Pyris provides. + */ +@Profile("iris") +@RestController +@RequestMapping("api/") +public class IrisVariantsResource { + + private static final Logger log = LoggerFactory.getLogger(IrisVariantsResource.class); + + private final PyrisConnectorService pyrisConnectorService; + + public IrisVariantsResource(PyrisConnectorService pyrisConnectorService) { + this.pyrisConnectorService = pyrisConnectorService; + } + + /** + * GET iris/variants/{feature}: Retrieve all available variants offered by Pyris for a certain feature + * + * @param featureRaw the feature for which to retrieve the variants + * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body a List of the variants + */ + @GetMapping("iris/variants/{feature}") + @EnforceAtLeastEditor + public ResponseEntity> getAllVariants(@PathVariable("feature") String featureRaw) { + var feature = IrisSubSettingsType.valueOf(featureRaw.toUpperCase().replace("-", "_")); + try { + var variants = pyrisConnectorService.getOfferedVariants(feature); + return ResponseEntity.ok(variants); + } + catch (PyrisConnectorException e) { + log.error("Could not fetch available variants for feature {}", feature, e); + throw new InternalServerErrorException("Could not fetch available variants for feature " + feature); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/admin/AdminIrisSettingsResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/admin/AdminIrisSettingsResource.java index b8ea1f92ba31..40da3e5ee431 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/admin/AdminIrisSettingsResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/admin/AdminIrisSettingsResource.java @@ -17,6 +17,7 @@ * REST controller for managing {@link IrisSettings}. */ @Profile(PROFILE_IRIS) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminIrisSettingsResource { @@ -34,7 +35,6 @@ public AdminIrisSettingsResource(IrisSettingsService irisSettingsService) { * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the updated settings. */ @PutMapping("iris/global-iris-settings") - @EnforceAdmin public ResponseEntity updateGlobalSettings(@RequestBody IrisSettings settings) { var updatedSettings = irisSettingsService.saveIrisSettings(settings); return ResponseEntity.ok(updatedSettings); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java index 05fecc5a287e..025a6ce4e897 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/web/open/PublicPyrisStatusUpdateResource.java @@ -20,6 +20,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.PyrisJobService; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisStatusUpdateService; import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.PyrisChatStatusUpdateDTO; +import de.tum.cit.aet.artemis.iris.service.pyris.dto.chat.textexercise.PyrisTextExerciseChatStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.competency.PyrisCompetencyStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.dto.lectureingestionwebhook.PyrisLectureIngestionStatusUpdateDTO; import de.tum.cit.aet.artemis.iris.service.pyris.job.CompetencyExtractionJob; @@ -27,6 +28,7 @@ import de.tum.cit.aet.artemis.iris.service.pyris.job.ExerciseChatJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.IngestionWebhookJob; import de.tum.cit.aet.artemis.iris.service.pyris.job.PyrisJob; +import de.tum.cit.aet.artemis.iris.service.pyris.job.TextExerciseChatJob; /** * REST controller for providing Pyris access to Artemis internal data and status updates. @@ -59,7 +61,7 @@ public PublicPyrisStatusUpdateResource(PyrisJobService pyrisJobService, PyrisSta * @throws AccessForbiddenException if the token is invalid * @return a {@link ResponseEntity} with status {@code 200 (OK)} */ - @PostMapping("pipelines/tutor-chat/runs/{runId}/status") // TODO: Rename this to 'exercise-chat' with next breaking Pyris version + @PostMapping("pipelines/tutor-chat/runs/{runId}/status") // TODO: Rename this to 'programming-exercise-chat' with next breaking Pyris version @EnforceNothing public ResponseEntity setStatusOfJob(@PathVariable String runId, @RequestBody PyrisChatStatusUpdateDTO statusUpdateDTO, HttpServletRequest request) { var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, ExerciseChatJob.class); @@ -123,6 +125,30 @@ public ResponseEntity setCompetencyExtractionJobStatus(@PathVariable Strin return ResponseEntity.ok().build(); } + /** + * {@code POST /api/public/pyris/pipelines/text-exercise-chat/runs/{runId}/status} : Set the status of a Text Exercise Chat job. + * + * @param runId the ID of the job + * @param statusUpdateDTO the status update + * @param request the HTTP request + * @return a {@link ResponseEntity} with status {@code 200 (OK)} + * @throws ConflictException if the run ID in the URL does not match the run ID in the request body + * @throws AccessForbiddenException if the token is invalid + */ + @PostMapping("pipelines/text-exercise-chat/runs/{runId}/status") + @EnforceNothing + public ResponseEntity respondInTextExerciseChat(@PathVariable String runId, @RequestBody PyrisTextExerciseChatStatusUpdateDTO statusUpdateDTO, + HttpServletRequest request) { + var job = pyrisJobService.getAndAuthenticateJobFromHeaderElseThrow(request, TextExerciseChatJob.class); + if (!Objects.equals(job.jobId(), runId)) { + throw new ConflictException("Run ID in URL does not match run ID in request body", "Job", "runIdMismatch"); + } + + pyrisStatusUpdateService.handleStatusUpdate(job, statusUpdateDTO); + + return ResponseEntity.ok().build(); + } + /** * {@code POST /api/public/pyris/webhooks/ingestion/runs/{runId}/status} : Set the status of an Ingestion job. * diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index 54ae76bf3bad..6ea319ae365a 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 @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.lecture.web; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import java.net.URI; import java.net.URISyntaxException; @@ -40,6 +41,7 @@ import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; 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.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -261,13 +263,15 @@ public ResponseEntity importLecture(@PathVariable long sourceLectureId, /** * POST /courses/{courseId}/ingest - * This endpooint is for starting the ingestion of all lectures or only one lecture when triggered in Artemis. + * This endpoint is for starting the ingestion of all lectures or only one lecture when triggered in Artemis. * * @param courseId the ID of the course for which all lectures should be ingested in pyris * @param lectureId If this id is present then only ingest this one lecture of the respective course * @return the ResponseEntity with status 200 (OK) and a message success or null if the operation failed */ + @Profile(PROFILE_IRIS) @PostMapping("courses/{courseId}/ingest") + @EnforceAtLeastInstructorInCourse public ResponseEntity ingestLectures(@PathVariable Long courseId, @RequestParam(required = false) Optional lectureId) { log.debug("REST request to ingest lectures of course : {}", courseId); Course course = courseRepository.findByIdWithLecturesAndLectureUnitsElseThrow(courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/lti/web/admin/AdminLtiConfigurationResource.java b/src/main/java/de/tum/cit/aet/artemis/lti/web/admin/AdminLtiConfigurationResource.java index 654facd44302..0b5d3f71c2f3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lti/web/admin/AdminLtiConfigurationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lti/web/admin/AdminLtiConfigurationResource.java @@ -33,6 +33,7 @@ * Handles administrative actions for LTI platforms, including configuration, deletion, and dynamic registration. */ @RestController +@EnforceAdmin @RequestMapping("api/admin/") @Profile(PROFILE_LTI) public class AdminLtiConfigurationResource { @@ -75,7 +76,6 @@ public AdminLtiConfigurationResource(LtiPlatformConfigurationRepository ltiPlatf * @return a {@code ResponseEntity} with an {@code Optional} and HTTP status. */ @GetMapping("lti-platform/{platformId}") - @EnforceAdmin public ResponseEntity getLtiPlatformConfiguration(@PathVariable("platformId") String platformId) { log.debug("REST request to configured lti platform"); LtiPlatformConfiguration platform = ltiPlatformConfigurationRepository.findByIdElseThrow(Long.parseLong(platformId)); @@ -89,7 +89,6 @@ public ResponseEntity getLtiPlatformConfiguration(@Pat * @return a {@code ResponseEntity} with status {@code 200 (OK)} and a header indicating the deletion. */ @DeleteMapping("lti-platform/{platformId}") - @EnforceAdmin public ResponseEntity deleteLtiPlatformConfiguration(@PathVariable("platformId") String platformId) { log.debug("REST request to configured lti platform"); LtiPlatformConfiguration platform = ltiPlatformConfigurationRepository.findByIdElseThrow(Long.parseLong(platformId)); @@ -105,7 +104,6 @@ public ResponseEntity deleteLtiPlatformConfiguration(@PathVariable("platfo * or with status 400 (Bad Request) if the provided platform configuration is invalid (e.g., missing ID) */ @PutMapping("lti-platform") - @EnforceAdmin public ResponseEntity updateLtiPlatformConfiguration(@RequestBody LtiPlatformConfiguration platform) { log.debug("REST request to update configured lti platform"); @@ -125,7 +123,6 @@ public ResponseEntity updateLtiPlatformConfiguration(@RequestBody LtiPlatf * or with status 400 (Bad Request) if the provided platform configuration is invalid (e.g., missing ID) */ @PostMapping("lti-platform") - @EnforceAdmin public ResponseEntity addLtiPlatformConfiguration(@RequestBody LtiPlatformConfiguration platform) { log.debug("REST request to add new lti platform"); @@ -147,7 +144,6 @@ public ResponseEntity addLtiPlatformConfiguration(@RequestBody LtiPlatform * @return a {@link ResponseEntity} with status 200 (OK) if the dynamic registration process was successful. */ @PostMapping("lti13/dynamic-registration") - @EnforceAdmin public ResponseEntity lti13DynamicRegistration(@RequestParam(name = "openid_configuration") String openIdConfiguration, @RequestParam(name = "registration_token", required = false) String registrationToken) { diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java index 320d041325e9..c8b4285de3b9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/service/ModelingSubmissionService.java @@ -131,12 +131,10 @@ public ModelingSubmission handleModelingSubmission(ModelingSubmission modelingSu } // if athena results are present, then create a new submission on submit - if (modelingSubmission.getParticipation() != null && modelingSubmission.getParticipation().getResults() != null - && !modelingSubmission.getParticipation().getResults().isEmpty()) { - log.debug("Creating a new submission due to Athena results for user: {}", user.getLogin()); + // If results exist for this submission, create a new submission by setting the ID to null + if (modelingSubmission.getId() != null && resultRepository.existsBySubmissionId(modelingSubmission.getId())) { modelingSubmission.setId(null); } - modelingSubmission = save(modelingSubmission, exercise, user, participation); return modelingSubmission; } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingSubmissionResource.java b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingSubmissionResource.java index e48f94b8e955..501309aea8e8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingSubmissionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/web/ModelingSubmissionResource.java @@ -126,7 +126,6 @@ public ResponseEntity createModelingSubmission(@PathVariable @PutMapping("exercises/{exerciseId}/modeling-submissions") @EnforceAtLeastStudent public ResponseEntity updateModelingSubmission(@PathVariable long exerciseId, @Valid @RequestBody ModelingSubmission modelingSubmission) { - log.debug("REST request to update modeling submission: {}", modelingSubmission.getModel()); if (modelingSubmission.getId() == null) { return createModelingSubmission(exerciseId, modelingSubmission); } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/web/admin/AdminModelingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/modeling/web/admin/AdminModelingExerciseResource.java index 215ea0a05293..cf3c6d2ee627 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/web/admin/AdminModelingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/web/admin/AdminModelingExerciseResource.java @@ -25,6 +25,7 @@ * REST controller for administrating ModelingExercise. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminModelingExerciseResource { @@ -59,7 +60,6 @@ public AdminModelingExerciseResource(ModelingExerciseRepository modelingExercise * @return the ResponseEntity with status 200 (OK) */ @GetMapping("modeling-exercises/{exerciseId}/check-clusters") - @EnforceAdmin public ResponseEntity checkClusters(@PathVariable Long exerciseId) { log.info("REST request to check clusters of ModelingExercise : {}", exerciseId); int clusterCount = modelClusterRepository.countByExerciseIdWithEagerElements(exerciseId); @@ -73,7 +73,6 @@ public ResponseEntity checkClusters(@PathVariable Long exerciseId) { * @return the ResponseEntity with status 200 (OK) */ @DeleteMapping("modeling-exercises/{exerciseId}/clusters") - @EnforceAdmin public ResponseEntity deleteModelingExerciseClustersAndElements(@PathVariable Long exerciseId) { log.info("REST request to delete ModelingExercise : {}", exerciseId); var modelingExercise = modelingExerciseRepository.findByIdElseThrow(exerciseId); @@ -91,7 +90,6 @@ public ResponseEntity deleteModelingExerciseClustersAndElements(@PathVaria * @return the ResponseEntity with status 200 (OK) */ @PostMapping("modeling-exercises/{exerciseId}/trigger-automatic-assessment") - @EnforceAdmin public ResponseEntity triggerAutomaticAssessment(@PathVariable Long exerciseId) { instanceMessageSendService.sendModelingExerciseInstantClustering(exerciseId); return ResponseEntity.ok().build(); diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java index 9fe9b1cc0f8c..e0707ca62144 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java @@ -30,6 +30,7 @@ import de.jplag.Language; import de.jplag.c.CLanguage; import de.jplag.clustering.ClusteringOptions; +import de.jplag.cpp.CPPLanguage; import de.jplag.exceptions.ExitException; import de.jplag.java.JavaLanguage; import de.jplag.javascript.JavaScriptLanguage; @@ -37,8 +38,10 @@ import de.jplag.options.JPlagOptions; import de.jplag.python3.PythonLanguage; import de.jplag.reporting.reportobject.ReportObjectFactory; +import de.jplag.rlang.RLanguage; import de.jplag.rust.RustLanguage; import de.jplag.swift.SwiftLanguage; +import de.jplag.typescript.TypeScriptLanguage; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.GitException; import de.tum.cit.aet.artemis.core.service.FileService; @@ -310,16 +313,18 @@ public void deleteTempLocalRepository(Repository repository) { private Language getJPlagProgrammingLanguage(ProgrammingExercise programmingExercise) { return switch (programmingExercise.getProgrammingLanguage()) { - case JAVA -> new JavaLanguage(); case C -> new CLanguage(); - case PYTHON -> new PythonLanguage(); - case SWIFT -> new SwiftLanguage(); + case C_PLUS_PLUS -> new CPPLanguage(); + case JAVA -> new JavaLanguage(); + case JAVASCRIPT -> new JavaScriptLanguage(); case KOTLIN -> new KotlinLanguage(); + case PYTHON -> new PythonLanguage(); + case R -> new RLanguage(); case RUST -> new RustLanguage(); - case JAVASCRIPT -> new JavaScriptLanguage(); - case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, VHDL, RUBY, POWERSHELL, ADA -> - throw new BadRequestAlertException("Programming language " + programmingExercise.getProgrammingLanguage() + " not supported for plagiarism check.", - "ProgrammingExercise", "notSupported"); + case SWIFT -> new SwiftLanguage(); + case TYPESCRIPT -> new TypeScriptLanguage(); + case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, SQL, GO, MATLAB, BASH, VHDL, RUBY, POWERSHELL, ADA -> throw new BadRequestAlertException( + "Programming language " + programmingExercise.getProgrammingLanguage() + " not supported for plagiarism check.", "ProgrammingExercise", "notSupported"); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java index 3caec801ed53..4f00e1bb117f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java @@ -20,5 +20,9 @@ public enum AuthenticationMechanism { /** * The user used the artemis client code editor to authenticate to the LocalVC */ - CODE_EDITOR + CODE_EDITOR, + /** + * The user attempted to authenticate to the LocalVC using either a user token or a participation token + */ + VCS_ACCESS_TOKEN, } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java index df7911670a22..c2a4666c7c1b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java @@ -712,8 +712,7 @@ private boolean checkForRatedAndAssessedResult(Result result) { * @return true if the result is manual and the assessment is over, or it is an automatic result, false otherwise */ private boolean checkForAssessedResult(Result result) { - return result.getCompletionDate() != null - && ((result.isManual() && ExerciseDateService.isAfterAssessmentDueDate(this)) || result.isAutomatic() || result.isAthenaAutomatic()); + return result.getCompletionDate() != null && ((result.isManual() && ExerciseDateService.isAfterAssessmentDueDate(this)) || result.isAutomatic() || result.isAthenaBased()); } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java index c36fc8f7d62b..39b4c211d143 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java @@ -50,7 +50,13 @@ public class ProgrammingExerciseBuildConfig extends DomainObject { private boolean checkoutSolutionRepository = false; @Column(name = "checkout_path") - private String checkoutPath; + private String testCheckoutPath; + + @Column(name = "assignment_checkout_path") + private String assignmentCheckoutPath; + + @Column(name = "solution_checkout_path") + private String solutionCheckoutPath; @Column(name = "timeout_seconds") private int timeoutSeconds; @@ -85,7 +91,9 @@ public ProgrammingExerciseBuildConfig() { public ProgrammingExerciseBuildConfig(ProgrammingExerciseBuildConfig originalBuildConfig) { this.setBranch(originalBuildConfig.getBranch()); this.setBuildPlanConfiguration(originalBuildConfig.getBuildPlanConfiguration()); - this.setCheckoutPath(originalBuildConfig.getCheckoutPath()); + this.setTestCheckoutPath(originalBuildConfig.getTestCheckoutPath()); + this.setAssignmentCheckoutPath(originalBuildConfig.getAssignmentCheckoutPath()); + this.setSolutionCheckoutPath(originalBuildConfig.getSolutionCheckoutPath()); this.setCheckoutSolutionRepository(originalBuildConfig.getCheckoutSolutionRepository()); this.setDockerFlags(originalBuildConfig.getDockerFlags()); this.setSequentialTestRuns(originalBuildConfig.hasSequentialTestRuns()); @@ -166,12 +174,12 @@ public void setCheckoutSolutionRepository(boolean checkoutSolutionRepository) { this.checkoutSolutionRepository = checkoutSolutionRepository; } - public String getCheckoutPath() { - return checkoutPath; + public String getTestCheckoutPath() { + return testCheckoutPath; } - public void setCheckoutPath(String checkoutPath) { - this.checkoutPath = checkoutPath; + public void setTestCheckoutPath(String testCheckoutPath) { + this.testCheckoutPath = testCheckoutPath; } public int getTimeoutSeconds() { @@ -268,11 +276,27 @@ public void generateAndSetBuildPlanAccessSecret() { buildPlanAccessSecret = UUID.randomUUID().toString(); } + public String getAssignmentCheckoutPath() { + return assignmentCheckoutPath; + } + + public void setAssignmentCheckoutPath(String assignmentCheckoutPath) { + this.assignmentCheckoutPath = assignmentCheckoutPath; + } + + public String getSolutionCheckoutPath() { + return solutionCheckoutPath; + } + + public void setSolutionCheckoutPath(String solutionCheckoutPath) { + this.solutionCheckoutPath = solutionCheckoutPath; + } + @Override public String toString() { return "BuildJobConfig{" + "id=" + getId() + ", sequentialTestRuns=" + sequentialTestRuns + ", branch='" + branch + '\'' + ", buildPlanConfiguration='" + buildPlanConfiguration + '\'' + ", buildScript='" + buildScript + '\'' + ", checkoutSolutionRepository=" + checkoutSolutionRepository + ", checkoutPath='" - + checkoutPath + '\'' + ", timeoutSeconds=" + timeoutSeconds + ", dockerFlags='" + dockerFlags + '\'' + ", testwiseCoverageEnabled=" + testwiseCoverageEnabled + + testCheckoutPath + '\'' + ", timeoutSeconds=" + timeoutSeconds + ", dockerFlags='" + dockerFlags + '\'' + ", testwiseCoverageEnabled=" + testwiseCoverageEnabled + ", theiaImage='" + theiaImage + '\'' + ", allowBranching=" + allowBranching + ", branchRegex='" + branchRegex + '\'' + '}'; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java index 4206bfe15dbc..71f00210c2df 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java @@ -38,18 +38,21 @@ public enum ProgrammingLanguage { PHP("php"); private static final Set ENABLED_LANGUAGES = Set.of( - EMPTY, - JAVA, - PYTHON, + ASSEMBLER, C, + C_PLUS_PLUS, HASKELL, + JAVA, + JAVASCRIPT, KOTLIN, - VHDL, - ASSEMBLER, - SWIFT, OCAML, + PYTHON, + R, RUST, - JAVASCRIPT + SWIFT, + TYPESCRIPT, + VHDL, + EMPTY ); // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java index 560ca52a31c1..9d5155d63f14 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java @@ -77,6 +77,10 @@ public void setCommitHash(String commitHash) { this.commitHash = commitHash; } + public void setRepositoryActionType(RepositoryActionType repositoryActionType) { + this.repositoryActionType = repositoryActionType; + } + public User getUser() { return user; } 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/BuildJobRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java index 7c341585f60f..d7a5e662c744 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java @@ -89,4 +89,19 @@ default BuildJob findByBuildJobIdElseThrow(String buildJobId) { return getValueElseThrow(findByBuildJobId(buildJobId)); } + /** + * Get the number of build jobs for a list of exercise ids. + * + * @param exerciseIds the list of exercise ids + * @return the number of build jobs + */ + @Query(""" + SELECT COUNT(b) + FROM BuildJob b + LEFT JOIN Result r ON b.result.id = r.id + LEFT JOIN Participation p ON r.participation.id = p.id + LEFT JOIN Exercise e ON p.exercise.id = e.id + WHERE e.id IN :exerciseIds + """) + long countBuildJobsByExerciseIds(@Param("exerciseIds") List exerciseIds); } 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 ab64ca6e53a8..578ebde6c8fd 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 @@ -108,17 +108,25 @@ 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", "competencies", "buildConfig", "categories" }) + Optional findForUpdateById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") Optional findWithSubmissionPolicyById(long exerciseId); List findAllByProjectKey(String projectKey); + 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. * @@ -594,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); } /** @@ -741,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. *

    @@ -986,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/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index 3739ed8dff71..cc1f57c533fa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -82,6 +82,12 @@ default ProgrammingExerciseStudentParticipation findByExerciseIdAndStudentLoginO return getValueElseThrow(findByExerciseIdAndStudentLogin(exerciseId, username)); } + Optional findByRepositoryUri(String repositoryUri); + + default ProgrammingExerciseStudentParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "submissions" }) Optional findWithSubmissionsByExerciseIdAndStudentLogin(long exerciseId, String username); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java index 2949576a13cb..4297b39f9ea9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java @@ -43,6 +43,12 @@ public interface SolutionProgrammingExerciseParticipationRepository """) Optional findByBuildPlanIdWithResults(@Param("buildPlanId") String buildPlanId); + Optional findByRepositoryUri(String repositoryUri); + + default SolutionProgrammingExerciseParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "results", "submissions", "submissions.results" }) Optional findWithEagerResultsAndSubmissionsByProgrammingExerciseId(long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java index bc609dcd06fa..67795b58ae54 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java @@ -48,6 +48,12 @@ default TemplateProgrammingExerciseParticipation findWithEagerResultsAndSubmissi return getValueElseThrow(findWithEagerResultsAndSubmissionsByProgrammingExerciseId(exerciseId)); } + Optional findByRepositoryUri(String repositoryUri); + + default TemplateProgrammingExerciseParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "results", "results.feedbacks", "results.feedbacks.testCase", "submissions" }) Optional findWithEagerResultsAndFeedbacksAndTestCasesAndSubmissionsByProgrammingExerciseId(long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java index af342179e111..628019f34eaa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java @@ -33,14 +33,14 @@ public interface VcsAccessLogRepository extends ArtemisJpaRepository findNewestByParticipationIdWhereCommitHashIsNull(@Param("participationId") long participationId); + FROM VcsAccessLog vcsAccessLog + WHERE vcsAccessLog.participation.id = :participationId + ORDER BY vcsAccessLog.timestamp DESC + LIMIT 1 + """) + Optional findNewestByParticipationId(@Param("participationId") long participationId); /** * Retrieves a list of {@link VcsAccessLog} entities associated with the specified participation ID. @@ -62,7 +62,6 @@ public interface VcsAccessLogRepository 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/AuxiliaryRepositoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/AuxiliaryRepositoryService.java index 7d9a21297e75..23aa653d7301 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/AuxiliaryRepositoryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/AuxiliaryRepositoryService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.service; +import static de.tum.cit.aet.artemis.core.config.Constants.ALLOWED_CHECKOUT_DIRECTORY; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.util.ArrayList; @@ -7,7 +8,6 @@ import java.util.Map; import java.util.Objects; import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -27,8 +27,6 @@ public class AuxiliaryRepositoryService { private static final String AUX_REPO_ENTITY_NAME = "programmingExercise"; - private static final Pattern ALLOWED_CHECKOUT_DIRECTORY = Pattern.compile("[\\w-]+(/[\\w-]+)*$"); - private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; public AuxiliaryRepositoryService(AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildScriptProviderService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildScriptProviderService.java index db9e37239d9e..487c92b3c21d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildScriptProviderService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/BuildScriptProviderService.java @@ -10,6 +10,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; @@ -18,6 +19,8 @@ import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.core.config.Constants; +import de.tum.cit.aet.artemis.core.service.ProfileService; import de.tum.cit.aet.artemis.core.service.ResourceLoaderService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; @@ -39,14 +42,17 @@ public class BuildScriptProviderService { private final Map scriptCache = new ConcurrentHashMap<>(); + private final ProfileService profileService; + /** * Constructor for BuildScriptProvider, which loads all scripts into the cache to speed up retrieval * during the runtime of the application * * @param resourceLoaderService resourceLoaderService */ - public BuildScriptProviderService(ResourceLoaderService resourceLoaderService) { + public BuildScriptProviderService(ResourceLoaderService resourceLoaderService, ProfileService profileService) { this.resourceLoaderService = resourceLoaderService; + this.profileService = profileService; } /** @@ -69,6 +75,9 @@ public void cacheOnBoot() { String uniqueKey = directory + "_" + filename; byte[] fileContent = IOUtils.toByteArray(resource.getInputStream()); String script = new String(fileContent, StandardCharsets.UTF_8); + if (!profileService.isLocalCiActive()) { + script = replacePlaceholders(script, null, null, null); + } scriptCache.put(uniqueKey, script); } catch (IOException e) { @@ -112,6 +121,9 @@ public String getScriptFor(ProgrammingLanguage programmingLanguage, Optional projectType, Boolean stati } return String.join("_", fileNameComponents) + "." + fileExtension; } + + /** + * Replaces placeholders in the given result paths with the actual paths. + * + * @param resultPaths the result paths to replace the placeholders in + * @param buildConfig the build configuration containing the actual paths + * @return the result paths with the placeholders replaced + */ + public List replaceResultPathsPlaceholders(List resultPaths, ProgrammingExerciseBuildConfig buildConfig) { + List replacedResultPaths = new ArrayList<>(); + for (String resultPath : resultPaths) { + String replacedResultPath = replacePlaceholders(resultPath, buildConfig.getAssignmentCheckoutPath(), buildConfig.getSolutionCheckoutPath(), + buildConfig.getTestCheckoutPath()); + replacedResultPaths.add(replacedResultPath); + } + return replacedResultPaths; + } + + /** + * Replaces placeholders in the given original string with the actual paths. + * + * @param originalString the original string to replace the placeholders in + * @param assignmentRepo the assignment repository name + * @param solutionRepo the solution repository name + * @param testRepo the test repository name + * @return the original string with the placeholders replaced + */ + public String replacePlaceholders(String originalString, String assignmentRepo, String solutionRepo, String testRepo) { + assignmentRepo = !StringUtils.isBlank(assignmentRepo) ? assignmentRepo : Constants.ASSIGNMENT_REPO_NAME; + solutionRepo = solutionRepo != null && !solutionRepo.isBlank() ? solutionRepo : Constants.SOLUTION_REPO_NAME; + testRepo = testRepo != null && !testRepo.isBlank() ? testRepo : Constants.TEST_REPO_NAME; + + String replacedResultPath = originalString.replace(Constants.ASSIGNMENT_REPO_PARENT_PLACEHOLDER, assignmentRepo); + replacedResultPath = replacedResultPath.replace(Constants.ASSIGNMENT_REPO_PLACEHOLDER, "/" + assignmentRepo + "/src"); + replacedResultPath = replacedResultPath.replace(Constants.SOLUTION_REPO_PLACEHOLDER, solutionRepo); + replacedResultPath = replacedResultPath.replace(Constants.TEST_REPO_PLACEHOLDER, testRepo); + return replacedResultPath; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java index 8c92446d22d0..935a3412b10e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java @@ -4,6 +4,7 @@ import static java.time.ZonedDateTime.now; import java.time.ZonedDateTime; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -11,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -59,6 +61,9 @@ public class ProgrammingExerciseCodeReviewFeedbackService { private final ProgrammingMessagingService programmingMessagingService; + @Value("${artemis.athena.allowed-feedback-attempts:20}") + private int allowedFeedbackAttempts; + public ProgrammingExerciseCodeReviewFeedbackService(GroupNotificationService groupNotificationService, Optional athenaFeedbackSuggestionsService, SubmissionService submissionService, ResultService resultService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ResultRepository resultRepository, @@ -111,14 +116,14 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici var submissionOptional = programmingExerciseParticipationService.findProgrammingExerciseParticipationWithLatestSubmissionAndResult(participation.getId()) .findLatestSubmission(); if (submissionOptional.isEmpty()) { - throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission"); + throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmissionExists"); } var submission = submissionOptional.get(); // save result and transmit it over websockets to notify the client about the status var automaticResult = this.submissionService.saveNewEmptyResult(submission); automaticResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); - automaticResult.setRated(false); + automaticResult.setRated(true); // we want to use this feedback to give the grade in the future automaticResult.setScore(100.0); automaticResult.setSuccessful(null); automaticResult.setCompletionDate(ZonedDateTime.now().plusMinutes(5)); // we do not want to show dates without a completion date, but we want the students to know their @@ -127,7 +132,6 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici try { - setIndividualDueDateAndLockRepository(participation, programmingExercise, false); this.programmingMessagingService.notifyUserAboutNewResult(automaticResult, participation); // now the client should be able to see new result @@ -158,9 +162,10 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici feedback.setDetailText(individualFeedbackItem.description()); feedback.setHasLongFeedbackText(false); feedback.setType(FeedbackType.AUTOMATIC); - feedback.setCredits(0.0); + feedback.setCredits(individualFeedbackItem.credits()); return feedback; - }).toList(); + }).sorted(Comparator.comparing(Feedback::getCredits, Comparator.nullsLast(Comparator.naturalOrder()))).toList(); + ; automaticResult.setSuccessful(true); automaticResult.setCompletionDate(ZonedDateTime.now()); @@ -176,9 +181,6 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici this.resultRepository.save(automaticResult); this.programmingMessagingService.notifyUserAboutNewResult(automaticResult, participation); } - finally { - unlockRepository(participation, programmingExercise); - } } /** @@ -225,15 +227,10 @@ private void checkRateLimitOrThrow(ProgrammingExerciseStudentParticipation parti List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); - long countOfAthenaResultsInProcessOrSuccessful = athenaResults.stream().filter(result -> result.isSuccessful() == null || result.isSuccessful() == Boolean.TRUE).count(); - long countOfSuccessfulRequests = athenaResults.stream().filter(result -> result.isSuccessful() == Boolean.TRUE).count(); - if (countOfAthenaResultsInProcessOrSuccessful >= 3) { - throw new BadRequestAlertException("Cannot send additional AI feedback requests now. Try again later!", "participation", "preconditions not met"); - } - if (countOfSuccessfulRequests >= 20) { - throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met"); + if (countOfSuccessfulRequests >= this.allowedFeedbackAttempts) { + throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "maxAthenaResultsReached", true); } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java index 4731295ed3c3..bd8db678b5a2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java @@ -31,6 +31,7 @@ import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; @@ -452,7 +453,7 @@ public void resetRepository(VcsRepositoryUri targetURL, VcsRepositoryUri sourceU * @return the participation. * @throws EntityNotFoundException if the participation could not be found. */ - public ProgrammingExerciseParticipation getParticipationForRepository(ProgrammingExercise exercise, String repositoryTypeOrUserName, boolean isPracticeRepository, + public ProgrammingExerciseParticipation retrieveParticipationForRepository(ProgrammingExercise exercise, String repositoryTypeOrUserName, boolean isPracticeRepository, boolean withSubmissions) { boolean isAuxiliaryRepository = auxiliaryRepositoryService.isAuxiliaryRepositoryOfExercise(repositoryTypeOrUserName, exercise); @@ -501,6 +502,27 @@ public ProgrammingExerciseParticipation getParticipationForRepository(Programmin return findStudentParticipationByExerciseAndStudentLoginAndTestRunOrThrow(exercise, repositoryTypeOrUserName, isPracticeRepository, withSubmissions); } + /** + * Get the participation for a given repository url and a repository type or user name. This method is used by the local VC system to get the + * participation for logging operations on the repository. + * + * @param repositoryTypeOrUserName the name of the user or the type of the repository + * @param repositoryURI the participation's repository URL + * @return the participation belonging to the provided repositoryURI and repository type or username + */ + public ProgrammingExerciseParticipation retrieveParticipationForRepository(String repositoryTypeOrUserName, String repositoryURI) { + if (repositoryTypeOrUserName.equals(RepositoryType.SOLUTION.toString()) || repositoryTypeOrUserName.equals(RepositoryType.TESTS.toString())) { + return solutionParticipationRepository.findByRepositoryUriElseThrow(repositoryURI); + } + if (repositoryTypeOrUserName.equals(RepositoryType.TEMPLATE.toString())) { + return templateParticipationRepository.findByRepositoryUriElseThrow(repositoryURI); + } + if (repositoryTypeOrUserName.equals(RepositoryType.AUXILIARY.toString())) { + throw new EntityNotFoundException("Auxiliary repositories do not have participations."); + } + return studentParticipationRepository.findByRepositoryUriElseThrow(repositoryURI); + } + /** * Get the commits information for the given participation. * @@ -517,6 +539,22 @@ public List getCommitInfos(ProgrammingExerciseParticipation parti } } + /** + * Get the commits information for the given auxiliary repository. + * + * @param auxiliaryRepository the auxiliary repository for which to get the commits. + * @return a list of CommitInfo DTOs containing author, timestamp, commit-hash and commit message. + */ + public List getAuxiliaryRepositoryCommitInfos(AuxiliaryRepository auxiliaryRepository) { + try { + return gitService.getCommitInfos(auxiliaryRepository.getVcsRepositoryUri()); + } + catch (GitAPIException e) { + log.error("Could not get commit infos for auxiliaryRepository {} with repository uri {}", auxiliaryRepository.getId(), auxiliaryRepository.getVcsRepositoryUri()); + return List.of(); + } + } + /** * Get the commits information for the test repository of the given participation's exercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java index db7803c21c22..c302c5f147e5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseRepositoryService.java @@ -17,6 +17,7 @@ import java.util.Optional; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; import org.eclipse.jgit.api.errors.GitAPIException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -662,8 +663,32 @@ void replacePlaceholders(final ProgrammingExercise programmingExercise, final Re replacements.put("${exerciseNamePomXml}", programmingExercise.getTitle().replace(" ", "-")); // Used e.g. in artifactId replacements.put("${exerciseName}", programmingExercise.getTitle()); - replacements.put("${studentWorkingDirectory}", Constants.STUDENT_WORKING_DIRECTORY); replacements.put("${packaging}", programmingExercise.getBuildConfig().hasSequentialTestRuns() ? "pom" : "jar"); + + var buildConfig = programmingExercise.getBuildConfig(); + + // replace checkout directory placeholders + String studentWorkingDirectory = !StringUtils.isBlank(buildConfig.getAssignmentCheckoutPath()) ? buildConfig.getAssignmentCheckoutPath() : Constants.ASSIGNMENT_REPO_NAME; + if (studentWorkingDirectory.startsWith("/")) { + studentWorkingDirectory = studentWorkingDirectory.substring(1); + } + String testWorkingDirectory = buildConfig.getTestCheckoutPath() != null && !buildConfig.getTestCheckoutPath().isBlank() ? buildConfig.getTestCheckoutPath() + : Constants.TEST_REPO_NAME; + String solutionWorkingDirectory = buildConfig.getSolutionCheckoutPath() != null && !buildConfig.getSolutionCheckoutPath().isBlank() ? buildConfig.getSolutionCheckoutPath() + : Constants.SOLUTION_REPO_NAME; + + if (programmingLanguage == ProgrammingLanguage.PYTHON) { + replacements.put(Constants.ASSIGNMENT_REPO_PARENT_PLACEHOLDER, studentWorkingDirectory.replace("/", ".")); + } + else { + replacements.put(Constants.ASSIGNMENT_REPO_PARENT_PLACEHOLDER, studentWorkingDirectory); + } + replacements.put(Constants.ASSIGNMENT_REPO_PLACEHOLDER, "/" + studentWorkingDirectory + "/src"); + replacements.put(Constants.TEST_REPO_PLACEHOLDER, testWorkingDirectory); + replacements.put(Constants.SOLUTION_REPO_PLACEHOLDER, solutionWorkingDirectory); + if ((programmingLanguage == ProgrammingLanguage.JAVA && programmingExercise.getProjectType().isGradle()) || programmingLanguage == ProgrammingLanguage.RUST) { + replacements.put(Constants.ASSIGNMENT_REPO_PLACEHOLDER_NO_SLASH, studentWorkingDirectory + "/src"); + } fileService.replaceVariablesInFileRecursive(repository.getLocalPath().toAbsolutePath(), replacements, List.of("gradle-wrapper.jar")); } 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 00e854f1d218..a591ce4363bb 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 @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.service; +import static de.tum.cit.aet.artemis.core.config.Constants.ALLOWED_CHECKOUT_DIRECTORY; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.SOLUTION; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.TEMPLATE; @@ -17,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; @@ -337,6 +339,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); } @@ -362,6 +369,7 @@ public void validateNewProgrammingExerciseSettings(ProgrammingExercise programmi programmingExercise.validateGeneralSettings(); programmingExercise.validateProgrammingSettings(); programmingExercise.validateSettingsForFeedbackRequest(); + validateCustomCheckoutPaths(programmingExercise); auxiliaryRepositoryService.validateAndAddAuxiliaryRepositoriesOfProgrammingExercise(programmingExercise, programmingExercise.getAuxiliaryRepositories()); submissionPolicyService.validateSubmissionPolicyCreation(programmingExercise); @@ -427,6 +435,27 @@ private void validatePackageName(ProgrammingExercise programmingExercise, Progra } } + private void validateCustomCheckoutPaths(ProgrammingExercise programmingExercise) { + var buildConfig = programmingExercise.getBuildConfig(); + + boolean assignmentCheckoutPathIsValid = isValidCheckoutPath(buildConfig.getAssignmentCheckoutPath()); + boolean solutionCheckoutPathIsValid = isValidCheckoutPath(buildConfig.getSolutionCheckoutPath()); + boolean testCheckoutPathIsValid = isValidCheckoutPath(buildConfig.getTestCheckoutPath()); + + if (!assignmentCheckoutPathIsValid || !solutionCheckoutPathIsValid || !testCheckoutPathIsValid) { + throw new BadRequestAlertException("The custom checkout paths are invalid", "Exercise", "checkoutDirectoriesInvalid"); + } + } + + private boolean isValidCheckoutPath(String checkoutPath) { + // Checkout paths are optional for the assignment, solution, and test repositories. If not set, the default path is used. + if (checkoutPath == null) { + return true; + } + Matcher matcher = ALLOWED_CHECKOUT_DIRECTORY.matcher(checkoutPath); + return matcher.matches(); + } + /** * Validates static code analysis settings * @@ -438,6 +467,22 @@ public void validateStaticCodeAnalysisSettings(ProgrammingExercise programmingEx programmingExercise.validateStaticCodeAnalysisSettings(programmingLanguageFeature); } + /** + * Validates the settings of an updated programming exercise. Checks if the custom checkout paths have changed. + * + * @param originalProgrammingExercise The original programming exercise + * @param updatedProgrammingExercise The updated programming exercise + */ + public void validateCheckoutDirectoriesUnchanged(ProgrammingExercise originalProgrammingExercise, ProgrammingExercise updatedProgrammingExercise) { + var originalBuildConfig = originalProgrammingExercise.getBuildConfig(); + var updatedBuildConfig = updatedProgrammingExercise.getBuildConfig(); + if (!Objects.equals(originalBuildConfig.getAssignmentCheckoutPath(), updatedBuildConfig.getAssignmentCheckoutPath()) + || !Objects.equals(originalBuildConfig.getSolutionCheckoutPath(), updatedBuildConfig.getSolutionCheckoutPath()) + || !Objects.equals(originalBuildConfig.getTestCheckoutPath(), updatedBuildConfig.getTestCheckoutPath())) { + throw new BadRequestAlertException("The custom checkout paths cannot be changed!", "programmingExercise", "checkoutDirectoriesChanged"); + } + } + /** * Creates build plans for a new programming exercise. * 1. Create the project for the exercise on the CI Server @@ -580,6 +625,9 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program competencyProgressService.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); + irisSettingsService + .ifPresent(settingsService -> settingsService.setEnabledForExerciseByCategories(savedProgrammingExercise, programmingExerciseBeforeUpdate.getCategories())); + return savedProgrammingExercise; } @@ -965,7 +1013,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); @@ -1009,4 +1057,15 @@ public ProgrammingExercise loadProgrammingExercise(long exerciseId, boolean with programmingExerciseTaskService.replaceTestIdsWithNames(programmingExercise); return programmingExercise; } + + /** + * Load a programming exercise, only with eager auxiliary repositories + * + * @param exerciseId the ID of the programming exercise to load + * @return the loaded programming exercise entity + */ + public ProgrammingExercise loadProgrammingExerciseWithAuxiliaryRepositories(long exerciseId) { + final Set fetchOptions = Set.of(AuxiliaryRepositories); + return programmingExerciseRepository.findByIdWithDynamicFetchElseThrow(exerciseId, fetchOptions); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java index ed5dd6cbae45..9833a9ae040b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/RepositoryService.java @@ -250,27 +250,6 @@ public byte[] getFile(Repository repository, String filename) throws IOException return fileInBytes; } - /** - * Get the mimetype of a single file from the repository - * - * @param repository in which the requested file is located. - * @param filename of the file to be probed. - * @return The mimetype of the file if found or throw an exception. - * @throws IOException if the file can't be found, is corrupt, etc. - */ - public String getFileType(Repository repository, String filename) throws IOException { - Optional file = gitService.getFileByName(repository, filename); - if (file.isEmpty()) { - throw new FileNotFoundException(); - } - String type = Files.probeContentType(file.get().toPath()); - // fallback to text/plain in case content type can not be determined - if (type == null) { - return "text/plain"; - } - return type; - } - /** * Gets the files of the repository and checks whether they were changed during a student participation. * Compares the files from the students' repository with the files of the template repository. @@ -519,8 +498,6 @@ public boolean isClean(VcsRepositoryUri repositoryUri, String defaultBranch) thr public ResponseEntity getFileFromRepository(String filename, Repository repository) throws IOException { byte[] out = getFile(repository, filename); HttpHeaders responseHeaders = new HttpHeaders(); - var contentType = getFileType(repository, filename); - responseHeaders.add("Content-Type", contentType); // Prevent the file from being interpreted as HTML by the browser when opened directly: responseHeaders.setContentDisposition(ContentDisposition.builder("attachment").filename(filename).build()); return new ResponseEntity<>(out, responseHeaders, HttpStatus.OK); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java index 16285e6a0695..07dc44fada0e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java @@ -32,8 +32,8 @@ public TemplateUpgradePolicyService(JavaTemplateUpgradeService javaRepositoryUpg public TemplateUpgradeService getUpgradeService(ProgrammingLanguage programmingLanguage) { return switch (programmingLanguage) { case JAVA -> javaRepositoryUpgradeService; - case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT -> defaultRepositoryUpgradeService; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT -> defaultRepositoryUpgradeService; + case C_SHARP, SQL, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/aeolus/AeolusTemplateService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/aeolus/AeolusTemplateService.java index 14bde3cf26b1..64321ad3b61d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/aeolus/AeolusTemplateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/aeolus/AeolusTemplateService.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import de.tum.cit.aet.artemis.core.config.ProgrammingLanguageConfiguration; +import de.tum.cit.aet.artemis.core.service.ProfileService; import de.tum.cit.aet.artemis.core.service.ResourceLoaderService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; @@ -46,13 +47,16 @@ public class AeolusTemplateService { private final BuildScriptProviderService buildScriptProviderService; + private final ProfileService profileService; + private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); public AeolusTemplateService(ProgrammingLanguageConfiguration programmingLanguageConfiguration, ResourceLoaderService resourceLoaderService, - BuildScriptProviderService buildScriptProviderService) { + BuildScriptProviderService buildScriptProviderService, ProfileService profileService) { this.programmingLanguageConfiguration = programmingLanguageConfiguration; this.resourceLoaderService = resourceLoaderService; this.buildScriptProviderService = buildScriptProviderService; + this.profileService = profileService; } /** @@ -76,6 +80,9 @@ public void cacheOnBoot() { String uniqueKey = directory + "_" + filename; byte[] fileContent = IOUtils.toByteArray(resource.getInputStream()); String script = new String(fileContent, StandardCharsets.UTF_8); + if (!profileService.isLocalCiActive()) { + script = buildScriptProviderService.replacePlaceholders(script, null, null, null); + } Windfile windfile = readWindfile(script); this.addInstanceVariablesToWindfile(windfile, ProgrammingLanguage.valueOf(directory.toUpperCase()), optionalProjectType); templateCache.put(uniqueKey, windfile); @@ -140,6 +147,9 @@ public Windfile getWindfileFor(ProgrammingLanguage programmingLanguage, Optional log.error("No windfile found for key {}", uniqueKey); return null; } + if (!profileService.isLocalCiActive()) { + scriptCache = buildScriptProviderService.replacePlaceholders(scriptCache, null, null, null); + } Windfile windfile = readWindfile(scriptCache); this.addInstanceVariablesToWindfile(windfile, programmingLanguage, projectType); templateCache.put(uniqueKey, windfile); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java index b4f67794c073..ec8e2165c46a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java @@ -219,8 +219,8 @@ enum RepositoryCheckoutPath implements CustomizableCheckoutPath { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT -> "assignment"; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT -> "assignment"; + case C_SHARP, SQL, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } @@ -230,9 +230,9 @@ public String forProgrammingLanguage(ProgrammingLanguage language) { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT -> ""; + case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT -> ""; case C, VHDL, ASSEMBLER, OCAML -> "tests"; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case C_SHARP, SQL, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java index a92bcdd26cb5..0c71114e13bb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java @@ -25,7 +25,7 @@ public class GitLabCIProgrammingLanguageFeatureService extends ProgrammingLangua public GitLabCIProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, false, false, false, true, false, List.of(PLAIN_MAVEN, MAVEN_MAVEN), false, false)); - programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); } } 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/CodeHintService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/CodeHintService.java index ac73fae28454..f04de8a56db9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/CodeHintService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/CodeHintService.java @@ -13,8 +13,6 @@ import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; -import de.tum.cit.aet.artemis.iris.domain.session.IrisHestiaSession; -import de.tum.cit.aet.artemis.iris.service.session.IrisHestiaSessionService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.hestia.CodeHint; import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseSolutionEntry; @@ -29,17 +27,14 @@ public class CodeHintService { private static final Logger log = LoggerFactory.getLogger(CodeHintService.class); - private final Optional irisHestiaSessionService; - private final CodeHintRepository codeHintRepository; private final ProgrammingExerciseTaskRepository taskRepository; private final ProgrammingExerciseSolutionEntryRepository solutionEntryRepository; - public CodeHintService(Optional irisHestiaSessionService, CodeHintRepository codeHintRepository, ProgrammingExerciseTaskRepository taskRepository, + public CodeHintService(CodeHintRepository codeHintRepository, ProgrammingExerciseTaskRepository taskRepository, ProgrammingExerciseSolutionEntryRepository solutionEntryRepository) { - this.irisHestiaSessionService = irisHestiaSessionService; this.codeHintRepository = codeHintRepository; this.taskRepository = taskRepository; this.solutionEntryRepository = solutionEntryRepository; @@ -189,17 +184,4 @@ public void updateSolutionEntriesForCodeHint(CodeHint hint) { codeHintRepository.save(hint); } - - /** - * Generates a description and content for a code hint using the Iris subsystem. - * See {@link IrisHestiaSessionService#executeRequest(IrisHestiaSession)} for more information. - * - * @param codeHint The code hint to be generated - * @return The code hint with description and content - */ - public CodeHint generateDescriptionWithIris(CodeHint codeHint) { - var irisService = irisHestiaSessionService.orElseThrow(); - var session = irisService.getOrCreateSession(codeHint); - return irisService.executeRequest(session); - } } 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/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java index 38893ea41093..9c318267953e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java @@ -1,14 +1,17 @@ package de.tum.cit.aet.artemis.programming.service.jenkins; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.C; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.C_PLUS_PLUS; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.EMPTY; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.HASKELL; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVA; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVASCRIPT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.TYPESCRIPT; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.FACT; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.GCC; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.GRADLE_GRADLE; @@ -33,15 +36,18 @@ public class JenkinsProgrammingLanguageFeatureService extends ProgrammingLanguag public JenkinsProgrammingLanguageFeatureService() { // Must be extended once a new programming language is added programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false, false)); + programmingLanguageFeatures.put(C_PLUS_PLUS, new ProgrammingLanguageFeature(C_PLUS_PLUS, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, false, false, false, false, true, List.of(), false, false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, true, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN, MAVEN_BLACKBOX), true, false)); + programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, true, false, true, true, false, List.of(), true, false)); programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); // Jenkins is not supporting XCODE at the moment programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, true, true, true, false, List.of(PLAIN), false, false)); - programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false, false)); - programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, false, false, false, false, true, List.of(), false, false)); - programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); - programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(TYPESCRIPT, new ProgrammingLanguageFeature(TYPESCRIPT, false, false, true, false, false, List.of(), false, false)); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java index 6e904910ca57..6dc40e173e4e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java @@ -184,8 +184,8 @@ private JenkinsXmlConfigBuilder builderFor(ProgrammingLanguage programmingLangua throw new UnsupportedOperationException("Xcode templates are not available for Jenkins."); } return switch (programmingLanguage) { - case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT -> jenkinsBuildPlanCreator; - case VHDL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS, TYPESCRIPT -> jenkinsBuildPlanCreator; + case VHDL, ASSEMBLER, OCAML, C_SHARP, SQL, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException(programmingLanguage + " templates are not available for Jenkins."); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIBuildConfigurationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIBuildConfigurationService.java index d8c03392661e..e4803619c97d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIBuildConfigurationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIBuildConfigurationService.java @@ -11,6 +11,7 @@ import de.tum.cit.aet.artemis.core.exception.LocalCIException; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; +import de.tum.cit.aet.artemis.programming.service.BuildScriptProviderService; import de.tum.cit.aet.artemis.programming.service.aeolus.AeolusTemplateService; import de.tum.cit.aet.artemis.programming.service.aeolus.ScriptAction; import de.tum.cit.aet.artemis.programming.service.aeolus.Windfile; @@ -21,8 +22,11 @@ public class LocalCIBuildConfigurationService { private final AeolusTemplateService aeolusTemplateService; - public LocalCIBuildConfigurationService(AeolusTemplateService aeolusTemplateService) { + private final BuildScriptProviderService buildScriptProviderService; + + public LocalCIBuildConfigurationService(AeolusTemplateService aeolusTemplateService, BuildScriptProviderService buildScriptProviderService) { this.aeolusTemplateService = aeolusTemplateService; + this.buildScriptProviderService = buildScriptProviderService; } /** @@ -34,15 +38,15 @@ public LocalCIBuildConfigurationService(AeolusTemplateService aeolusTemplateServ */ public String createBuildScript(ProgrammingExercise programmingExercise) { - StringBuilder buildScript = new StringBuilder(); - buildScript.append("#!/bin/bash\n"); - buildScript.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir\n"); + StringBuilder buildScriptBuilder = new StringBuilder(); + buildScriptBuilder.append("#!/bin/bash\n"); + buildScriptBuilder.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir\n"); ProgrammingExerciseBuildConfig buildConfig = programmingExercise.getBuildConfig(); String customScript = buildConfig.getBuildScript(); // Todo: get default script if custom script is null before trying to get actions from windfile if (customScript != null) { - buildScript.append(customScript); + buildScriptBuilder.append(customScript); } else { List actions; @@ -62,16 +66,16 @@ public String createBuildScript(ProgrammingExercise programmingExercise) { actions.forEach(action -> { String workdir = action.getWorkdir(); if (workdir != null) { - buildScript.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir/").append(workdir).append("\n"); + buildScriptBuilder.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir/").append(workdir).append("\n"); } - buildScript.append(action.getScript()).append("\n"); + buildScriptBuilder.append(action.getScript()).append("\n"); if (workdir != null) { - buildScript.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir\n"); + buildScriptBuilder.append("cd ").append(LOCALCI_WORKING_DIRECTORY).append("/testing-dir\n"); } }); } - return buildScript.toString(); + return buildScriptProviderService.replacePlaceholders(buildScriptBuilder.toString(), programmingExercise.getBuildConfig().getAssignmentCheckoutPath(), + programmingExercise.getBuildConfig().getSolutionCheckoutPath(), programmingExercise.getBuildConfig().getTestCheckoutPath()); } - } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java index 525170cca334..d86199310720 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALCI; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.ASSEMBLER; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.C; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.C_PLUS_PLUS; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.EMPTY; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.HASKELL; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVA; @@ -10,8 +11,10 @@ import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.OCAML; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.TYPESCRIPT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.VHDL; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.FACT; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.GCC; @@ -39,17 +42,20 @@ public class LocalCIProgrammingLanguageFeatureService extends ProgrammingLanguag public LocalCIProgrammingLanguageFeatureService() { // Must be extended once a new programming language is added programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(ASSEMBLER, new ProgrammingLanguageFeature(ASSEMBLER, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, true, true, false, false, List.of(FACT, GCC), false, true)); + programmingLanguageFeatures.put(C_PLUS_PLUS, new ProgrammingLanguageFeature(C_PLUS_PLUS, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, true, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, true, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN), false, true)); - programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, true, true, false, false, List.of(FACT, GCC), false, true)); - programmingLanguageFeatures.put(ASSEMBLER, new ProgrammingLanguageFeature(ASSEMBLER, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, true)); programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, false, false, true, true, false, List.of(), false, true)); - programmingLanguageFeatures.put(VHDL, new ProgrammingLanguageFeature(VHDL, false, false, false, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, true, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(OCAML, new ProgrammingLanguageFeature(OCAML, false, false, false, false, true, List.of(), false, true)); - programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true)); + programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false, true)); programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true)); + programmingLanguageFeatures.put(TYPESCRIPT, new ProgrammingLanguageFeature(TYPESCRIPT, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(VHDL, new ProgrammingLanguageFeature(VHDL, false, false, false, false, false, List.of(), false, true)); } } 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 4507f1a4e76d..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 @@ -7,19 +7,21 @@ import java.util.UUID; import java.util.concurrent.CancellationException; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +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.collection.IQueue; import com.hazelcast.collection.ItemEvent; import com.hazelcast.collection.ItemListener; import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; import com.hazelcast.map.IMap; import de.tum.cit.aet.artemis.assessment.domain.Result; @@ -90,16 +92,28 @@ public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastI /** * Initializes the result queue, build agent information map and the locks. */ - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void init() { this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); this.buildAgentInformation = this.hazelcastInstance.getMap("buildAgentInformation"); this.listenerId = resultQueue.addItemListener(new ResultQueueListener(), true); } + /** + * Removes the item listener from the Hazelcast result queue if the instance is active. + * Logs an error if Hazelcast is not running. + */ @PreDestroy public void removeListener() { - this.resultQueue.removeItemListener(this.listenerId); + // check if Hazelcast is still active, before invoking this + try { + if (hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning()) { + this.resultQueue.removeItemListener(this.listenerId); + } + } + catch (HazelcastInstanceNotActiveException e) { + log.error("Could not remove listener as hazelcast instance is not active."); + } } /** @@ -211,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++) { @@ -221,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 10569adbbf30..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; @@ -42,6 +43,7 @@ import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; +import de.tum.cit.aet.artemis.programming.service.BuildScriptProviderService; import de.tum.cit.aet.artemis.programming.service.GitService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.aeolus.AeolusResult; @@ -75,6 +77,8 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final AeolusTemplateService aeolusTemplateService; + private final BuildScriptProviderService buildScriptProviderService; + private final ProgrammingLanguageConfiguration programmingLanguageConfiguration; private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; @@ -102,7 +106,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h LocalCIProgrammingLanguageFeatureService programmingLanguageFeatureService, Optional versionControlService, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, LocalCIBuildConfigurationService localCIBuildConfigurationService, GitService gitService, ExerciseDateService exerciseDateService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService) { this.hazelcastInstance = hazelcastInstance; this.aeolusTemplateService = aeolusTemplateService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; @@ -114,6 +118,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h this.gitService = gitService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; this.exerciseDateService = exerciseDateService; + this.buildScriptProviderService = buildScriptProviderService; } @PostConstruct @@ -192,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); @@ -304,13 +311,15 @@ private BuildConfig getBuildConfig(ProgrammingExerciseParticipation participatio } List resultPaths = getTestResultPaths(windfile); + resultPaths = buildScriptProviderService.replaceResultPathsPlaceholders(resultPaths, buildConfig); // Todo: If build agent does not have access to filesystem, we need to send the build script to the build agent and execute it there. programmingExercise.setBuildConfig(buildConfig); String buildScript = localCIBuildConfigurationService.createBuildScript(programmingExercise); return new BuildConfig(buildScript, dockerImage, commitHashToBuild, assignmentCommitHash, testCommitHash, branch, programmingLanguage, projectType, - staticCodeAnalysisEnabled, sequentialTestRunsEnabled, testwiseCoverageEnabled, resultPaths); + staticCodeAnalysisEnabled, sequentialTestRunsEnabled, testwiseCoverageEnabled, resultPaths, buildConfig.getTimeoutSeconds(), + buildConfig.getAssignmentCheckoutPath(), buildConfig.getTestCheckoutPath(), buildConfig.getSolutionCheckoutPath()); } private ProgrammingExerciseBuildConfig loadBuildConfig(ProgrammingExercise programmingExercise) { 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 a99766da005b..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 @@ -67,6 +67,10 @@ public class SharedQueueManagementService { private ITopic canceledBuildJobsTopic; + private ITopic pauseBuildAgentTopic; + + private ITopic resumeBuildAgentTopic; + public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProfileService profileService) { this.buildJobRepository = buildJobRepository; this.hazelcastInstance = hazelcastInstance; @@ -83,6 +87,8 @@ public void init() { this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.canceledBuildJobsTopic = hazelcastInstance.getTopic("canceledBuildJobsTopic"); this.dockerImageCleanupInfo = this.hazelcastInstance.getMap("dockerImageCleanupInfo"); + this.pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); + this.resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); } /** @@ -131,10 +137,18 @@ 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(); } + public void pauseBuildAgent(String agent) { + pauseBuildAgentTopic.publish(agent); + } + + public void resumeBuildAgent(String agent) { + resumeBuildAgentTopic.publish(agent); + } + /** * Cancel a build job by removing it from the queue or stopping the build process. * @@ -194,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/ArtemisGitServletService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ArtemisGitServletService.java index b64406840602..0bfbbbe700e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ArtemisGitServletService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ArtemisGitServletService.java @@ -6,6 +6,7 @@ import org.eclipse.jgit.http.server.GitServlet; import org.eclipse.jgit.transport.ReceivePack; +import org.eclipse.jgit.transport.UploadPack; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -31,6 +32,13 @@ public ArtemisGitServletService(LocalVCServletService localVCServletService) { /** * Initialize the ArtemisGitServlet by setting the repository resolver and adding filters for fetch and push requests. + * Sets the pre/post receive/upload hooks. + *

    + * For general information on the different hooks and git packs see the git documentation: + *

    + * https://git-scm.com/docs/git-receive-pack + *

    + * https://git-scm.com/docs/git-upload-pack */ @PostConstruct @Override @@ -55,5 +63,13 @@ public void init() { receivePack.setPostReceiveHook(new LocalVCPostPushHook(localVCServletService)); return receivePack; }); + + this.setUploadPackFactory((request, repository) -> { + UploadPack uploadPack = new UploadPack(repository); + + // Add the custom pre-upload hook, to distinguish between clone and pull operations + uploadPack.setPreUploadHook(new LocalVCFetchPreUploadHook(localVCServletService, request)); + return uploadPack; + }); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java index 504355f1cdf2..e789bf4c5e78 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.filter.OncePerRequestFilter; import de.tum.cit.aet.artemis.core.exception.localvc.LocalVCAuthException; @@ -44,6 +45,11 @@ public void doFilterInternal(HttpServletRequest servletRequest, HttpServletRespo servletResponse.setStatus(localVCServletService.getHttpStatusForException(e, servletRequest.getRequestURI())); return; } + catch (AuthenticationException e) { + // intercept failed authentication to log it in the VCS access log + localVCServletService.createVCSAccessLogForFailedAuthenticationAttempt(servletRequest); + throw e; + } filterChain.doFilter(servletRequest, servletResponse); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java new file mode 100644 index 000000000000..97e7523991d7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import java.util.Collection; + +import jakarta.servlet.http.HttpServletRequest; + +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.transport.PreUploadHook; +import org.eclipse.jgit.transport.UploadPack; + +public class LocalVCFetchPreUploadHook implements PreUploadHook { + + private final LocalVCServletService localVCServletService; + + private final HttpServletRequest request; + + public LocalVCFetchPreUploadHook(LocalVCServletService localVCServletService, HttpServletRequest request) { + this.localVCServletService = localVCServletService; + this.request = request; + } + + @Override + public void onBeginNegotiateRound(UploadPack uploadPack, Collection collection, int clientOffered) { + localVCServletService.updateVCSAccessLogForCloneAndPullHTTPS(request, clientOffered); + } + + @Override + public void onEndNegotiateRound(UploadPack uploadPack, Collection collection, int i, int i1, boolean b) { + } + + @Override + public void onSendPack(UploadPack uploadPack, Collection collection, Collection collection1) { + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java new file mode 100644 index 000000000000..09f79348c180 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java @@ -0,0 +1,37 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import java.nio.file.Path; +import java.util.Collection; + +import org.apache.sshd.server.session.ServerSession; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.transport.PreUploadHook; +import org.eclipse.jgit.transport.UploadPack; + +public class LocalVCFetchPreUploadHookSSH implements PreUploadHook { + + private final LocalVCServletService localVCServletService; + + private final ServerSession serverSession; + + private final Path rootDir; + + public LocalVCFetchPreUploadHookSSH(LocalVCServletService localVCServletService, ServerSession serverSession, Path rootDir) { + this.localVCServletService = localVCServletService; + this.serverSession = serverSession; + this.rootDir = rootDir; + } + + @Override + public void onBeginNegotiateRound(UploadPack uploadPack, Collection collection, int clientOffered) { + localVCServletService.updateVCSAccessLogForCloneAndPullSSH(serverSession, rootDir, clientOffered); + } + + @Override + public void onEndNegotiateRound(UploadPack uploadPack, Collection collection, int i, int i1, boolean b) { + } + + @Override + public void onSendPack(UploadPack uploadPack, Collection collection, Collection collection1) { + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java index 3d178b998cdd..914205081e60 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java @@ -47,6 +47,7 @@ public void doFilterInternal(HttpServletRequest servletRequest, HttpServletRespo servletResponse.setStatus(localVCServletService.getHttpStatusForException(e, servletRequest.getRequestURI())); return; } + this.localVCServletService.updateVCSAccessLogForPushHTTPS(servletRequest); filterChain.doFilter(servletRequest, servletResponse); 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 470b7f815322..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 @@ -16,10 +16,12 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.server.session.ServerSession; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.errors.RepositoryNotFoundException; @@ -32,6 +34,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.AuthenticationException; @@ -66,6 +69,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingTriggerService; import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService; import de.tum.cit.aet.artemis.programming.service.ci.ContinuousIntegrationTriggerService; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType; /** @@ -127,6 +131,8 @@ public void setLocalVCBaseUrl(URL localVCBaseUrl) { */ public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BUILD_USER_NAME = "buildjob_user"; + // Cache the retrieved repositories for quicker access. // The resolveRepository method is called multiple times per request. // Key: repositoryPath --> Value: Repository @@ -207,7 +213,8 @@ public Repository resolveRepository(String repositoryPath) throws RepositoryNotF * @throws LocalVCForbiddenException If the user is not allowed to access the repository, e.g. because offline IDE usage is not allowed or the due date has passed. * @throws LocalVCInternalException If an internal error occurs, e.g. because the LocalVCRepositoryUri could not be created. */ - public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, RepositoryActionType repositoryAction) throws LocalVCAuthException, LocalVCForbiddenException { + public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, RepositoryActionType repositoryAction) + throws LocalVCAuthException, LocalVCForbiddenException, AuthenticationException { long timeNanoStart = System.nanoTime(); @@ -274,7 +281,8 @@ private AuthenticationMechanism resolveAuthenticationMechanism(String authorizat return AuthenticationMechanism.PARTICIPATION_VCS_ACCESS_TOKEN; } - private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) throws LocalVCAuthException { + private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) + throws LocalVCAuthException, AuthenticationException { UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); @@ -428,8 +436,11 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), - false); + 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( @@ -442,20 +453,40 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin catch (AccessForbiddenException e) { throw new LocalVCForbiddenException(e); } - // TODO: retrieving the git commit hash should be done ASYNC together with storing the log in the database to avoid long waiting times during permission check - String commitHash = null; + + // Asynchronously store an VCS access log entry + CompletableFuture.runAsync(() -> storeAccessLogAsync(user, participation, repositoryActionType, authenticationMechanism, ipAddress, localVCRepositoryUri)) + .exceptionally(ex -> { + log.warn("Failed to asynchronously obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath(), + ex.getMessage()); + return null; + }); + } + + /** + * Asynchronously retrieves the latest commit hash from the specified repository and logs the access to the repository. + * This method runs without blocking the user during repository access checks. + * + * @param user the user accessing the repository + * @param participation the participation associated with the repository + * @param repositoryActionType the action performed on the repository (READ or WRITE) + * @param authenticationMechanism the mechanism used for authentication (e.g., token, basic auth) + * @param ipAddress the IP address of the user accessing the repository + * @param localVCRepositoryUri the URI of the localVC repository + */ + private void storeAccessLogAsync(User user, ProgrammingExerciseParticipation participation, RepositoryActionType repositoryActionType, + AuthenticationMechanism authenticationMechanism, String ipAddress, LocalVCRepositoryUri localVCRepositoryUri) { try { - if (repositoryActionType == RepositoryActionType.READ) { - String relativeRepositoryPath = localVCRepositoryUri.getRelativeRepositoryPath().toString(); - try (Repository repository = resolveRepository(relativeRepositoryPath)) { - commitHash = getLatestCommitHash(repository); - } + String commitHash; + String relativeRepositoryPath = localVCRepositoryUri.getRelativeRepositoryPath().toString(); + try (Repository repository = resolveRepository(relativeRepositoryPath)) { + commitHash = getLatestCommitHash(repository); } - // Write a access log entry to the database - String finalCommitHash = commitHash; - vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, repositoryActionType, authenticationMechanism, finalCommitHash, ipAddress)); + + RepositoryActionType finalRepositoryActionType = repositoryActionType == RepositoryActionType.READ ? RepositoryActionType.PULL : RepositoryActionType.PUSH; + vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, finalRepositoryActionType, authenticationMechanism, commitHash, ipAddress)); + } - // NOTE: we intentionally catch all issues here to avoid that the user is blocked from accessing the repository catch (Exception e) { log.warn("Failed to obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath().toString(), e.getMessage()); } @@ -526,16 +557,9 @@ public void processNewPush(String commitHash, Repository repository) { // Process push to any repository other than the test repository. processNewPushToRepository(participation, commit); - try { - // For push the correct commitHash is only available here, therefore the preliminary null value is overwritten - String finalCommitHash = commitHash; - vcsAccessLogService.ifPresent(service -> service.updateCommitHash(participation, finalCommitHash)); - } - // NOTE: we intentionally catch all issues here to avoid that the user is blocked from accessing the repository - catch (Exception e) { - log.warn("Failed to obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath().toString(), - e.getMessage()); - } + // For push the correct commitHash is only available here, therefore the preliminary null value is overwritten + String finalCommitHash = commitHash; + vcsAccessLogService.ifPresent(service -> service.updateCommitHash(participation, finalCommitHash)); } catch (GitAPIException | IOException e) { // This catch clause does not catch exceptions that happen during runBuildJob() as that method is called asynchronously. @@ -552,8 +576,8 @@ private ProgrammingExerciseParticipation getProgrammingExerciseParticipation(Loc ProgrammingExercise exercise) { ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), - true); + participation = programmingExerciseParticipationService.retrieveParticipationForRepository(exercise, repositoryTypeOrUserName, + localVCRepositoryUri.isPracticeRepository(), true); } catch (EntityNotFoundException e) { throw new VersionControlException("Could not find participation for repository " + repositoryTypeOrUserName + " of exercise " + exercise, e); @@ -704,6 +728,29 @@ private Commit extractCommitInfo(String commitHash, Repository repository) throw return new Commit(commitHash, author.getName(), revCommit.getFullMessage(), author.getEmailAddress(), branch); } + /** + * Retrieves the participation for a programming exercise based on the repository URI. + * + * @param localVCRepositoryUri the {@link LocalVCRepositoryUri} containing details about the repository. + * @return the {@link ProgrammingExerciseParticipation} corresponding to the repository URI. + */ + private ProgrammingExerciseParticipation retrieveParticipationFromLocalVCRepositoryUri(LocalVCRepositoryUri localVCRepositoryUri) { + String repositoryTypeOrUserName = localVCRepositoryUri.getRepositoryTypeOrUserName(); + var repositoryURL = localVCRepositoryUri.toString().replace("/git-upload-pack", "").replace("/git-receive-pack", ""); + return programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, repositoryURL); + } + + /** + * Retrieves the participation for a programming exercise based on the HTTP request. + * + * @param request the {@link HttpServletRequest} containing the repository URI. + * @return the {@link ProgrammingExerciseParticipation} corresponding to the repository details in the request. + */ + private ProgrammingExerciseParticipation getExerciseParticipationFromRequest(HttpServletRequest request) { + LocalVCRepositoryUri localVCRepositoryUri = parseRepositoryUri(request); + return retrieveParticipationFromLocalVCRepositoryUri(localVCRepositoryUri); + } + /** * Determine the default branch of the given repository. * @@ -715,6 +762,127 @@ public static String getDefaultBranchOfRepository(Repository repository) { return LocalVCService.getDefaultBranchOfRepository(repositoryFolderPath.toString()); } + /** + * Updates the VCS (Version Control System) access log for clone and pull actions using HTTPS. + *

    + * This method logs the access information based on the incoming HTTP request. It checks if the action + * is performed by a build job user and, if not, records the user's repository action (clone or pull). + * The action type is determined based on the number of offers (`clientOffered`). + * + * @param request the {@link HttpServletRequest} containing the HTTP request data, including headers. + * @param clientOffered the number of objects offered by the client in the operation, used to determine + * if the action is a clone (if 0) or a pull (if greater than 0). + */ + @Async + public void updateVCSAccessLogForCloneAndPullHTTPS(HttpServletRequest request, int clientOffered) { + try { + String authorizationHeader = request.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + String userName = usernameAndPassword.username(); + if (userName.equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = getRepositoryActionReadType(clientOffered); + var participation = getExerciseParticipationFromRequest(request); + + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Updates the VCS access log for a push action using HTTPS. + *

    + * This method logs the access information if the HTTP request is a POST request and the action + * is not performed by a build job user. The repository action type is set as a push action. + * + * This method is asynchronous. + * + * @param request the {@link HttpServletRequest} containing the HTTP request data, including headers. + */ + @Async + public void updateVCSAccessLogForPushHTTPS(HttpServletRequest request) { + if (!request.getMethod().equals("POST")) { + return; + } + try { + String authorizationHeader = request.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + String userName = usernameAndPassword.username(); + if (userName.equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = RepositoryActionType.PUSH; + var participation = getExerciseParticipationFromRequest(request); + + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Updates the VCS access log for clone and pull actions performed over SSH. + *

    + * This method logs access information based on the SSH session and the root directory of the repository. + * It determines the repository action (clone or pull) based on the number of offers (`clientOffered`) and + * fetches participation details from the local VC repository URI. + * + * @param session the {@link ServerSession} representing the SSH session. + * @param rootDir the {@link Path} to the root directory of the repository. + * @param clientOffered the number of objects offered by the client in the operation, used to determine + * if the action is a clone (if 0) or a pull (if greater than 0). + */ + @Async + public void updateVCSAccessLogForCloneAndPullSSH(ServerSession session, Path rootDir, int clientOffered) { + try { + if (session.getAttribute(SshConstants.USER_KEY).getName().equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = getRepositoryActionReadType(clientOffered); + var participation = retrieveParticipationFromLocalVCRepositoryUri(getLocalVCRepositoryUri(rootDir)); + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Adds a failed VCS access attempt to the log. + *

    + * This method logs a failed clone attempt, associating it with the user and participation retrieved + * from the incoming HTTP request. It assumes that the failed attempt used password authentication. + * + * @param servletRequest the {@link HttpServletRequest} containing the HTTP request data. + */ + public void createVCSAccessLogForFailedAuthenticationAttempt(HttpServletRequest servletRequest) { + try { + String authorizationHeader = servletRequest.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + User user = userRepository.findOneByLogin(usernameAndPassword.username()).orElseThrow(LocalVCAuthException::new); + AuthenticationMechanism mechanism = usernameAndPassword.password().startsWith("vcpat-") ? AuthenticationMechanism.VCS_ACCESS_TOKEN : AuthenticationMechanism.PASSWORD; + var participation = getExerciseParticipationFromRequest(servletRequest); + var ipAddress = servletRequest.getRemoteAddr(); + vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, RepositoryActionType.CLONE_FAIL, mechanism, "", ipAddress)); + } + catch (LocalVCAuthException ignored) { + } + } + + /** + * Determines the repository action type for read operations (clone or pull). + *

    + * This method returns a {@link RepositoryActionType} based on the number of objects offered. + * If no objects are offered (0), it is considered a clone; otherwise, it is a pull action. + * + * @param clientOffered the number of objects offered to the client in the operation. + * @return the {@link RepositoryActionType} based on the number of objects offered (clone if 0, pull if greater than 0). + */ + private RepositoryActionType getRepositoryActionReadType(int clientOffered) { + return clientOffered == 0 ? RepositoryActionType.CLONE : RepositoryActionType.PULL; + } + record UsernameAndPassword(String username, String password) { } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java index a61712685ef7..d45bfda1c179 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java @@ -70,12 +70,12 @@ public Path resolveRootDirectory(String command, String[] args, ServerSession se // git-upload-pack means fetch (read operation), git-receive-pack means push (write operation) final var repositoryAction = gitCommand.equals("git-upload-pack") ? RepositoryActionType.READ : gitCommand.equals("git-receive-pack") ? RepositoryActionType.WRITE : null; + final var user = session.getAttribute(SshConstants.USER_KEY); if (session.getAttribute(SshConstants.IS_BUILD_AGENT_KEY) && repositoryAction == RepositoryActionType.READ) { // We already checked for build agent authenticity } else { - final var user = session.getAttribute(SshConstants.USER_KEY); try { localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, AuthenticationMechanism.SSH, session.getClientAddress().toString(), localVCRepositoryUri); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java index 57330b4d51e0..af1972ce2e1c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java @@ -7,6 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.domain.User; @@ -44,7 +45,7 @@ public class VcsAccessLogService { * @param commitHash The latest commit hash * @param ipAddress The ip address of the user accessing the repository */ - // TODO: this should be ASYNC to avoid long waiting times during permission check + @Async public void storeAccessLog(User user, ProgrammingExerciseParticipation participation, RepositoryActionType actionType, AuthenticationMechanism authenticationMechanism, String commitHash, String ipAddress) { log.debug("Storing access operation for user {}", user); @@ -55,19 +56,32 @@ public void storeAccessLog(User user, ProgrammingExerciseParticipation participa } /** - * Updates the commit hash after a successful push + * Updates the commit hash of the newest log entry * * @param participation The participation to which the repository belongs to * @param commitHash The newest commit hash which should get set for the access log entry */ - // TODO: this should be ASYNC to avoid long waiting times during permission check + @Async public void updateCommitHash(ProgrammingExerciseParticipation participation, String commitHash) { - vcsAccessLogRepository.findNewestByParticipationIdWhereCommitHashIsNull(participation.getId()).ifPresent(entry -> { + vcsAccessLogRepository.findNewestByParticipationId(participation.getId()).ifPresent(entry -> { entry.setCommitHash(commitHash); vcsAccessLogRepository.save(entry); }); } + /** + * Updates the repository action type of the newest log entry. This method is not Async, as it should already be called from an @Async context + * + * @param participation The participation to which the repository belongs to + * @param repositoryActionType The repositoryActionType which should get set for the newest access log entry + */ + public void updateRepositoryActionType(ProgrammingExerciseParticipation participation, RepositoryActionType repositoryActionType) { + vcsAccessLogRepository.findNewestByParticipationId(participation.getId()).ifPresent(entry -> { + entry.setRepositoryActionType(repositoryActionType); + vcsAccessLogRepository.save(entry); + }); + } + /** * Stores the log for a push from the code editor. * @@ -81,7 +95,11 @@ public void storeCodeEditorAccessLog(Repository repo, User user, Long participat String lastCommitHash = git.log().setMaxCount(1).call().iterator().next().getName(); var participation = participationRepository.findById(participationId); if (participation.isPresent() && participation.get() instanceof ProgrammingExerciseParticipation programmingParticipation) { - storeAccessLog(user, programmingParticipation, RepositoryActionType.WRITE, AuthenticationMechanism.CODE_EDITOR, lastCommitHash, null); + log.debug("Storing access operation for user {}", user); + + VcsAccessLog accessLogEntry = new VcsAccessLog(user, (Participation) programmingParticipation, user.getName(), user.getEmail(), RepositoryActionType.WRITE, + AuthenticationMechanism.CODE_EDITOR, lastCommitHash, null); + vcsAccessLogRepository.save(accessLogEntry); } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java index b0307ec38bb4..7ec968646217 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java @@ -21,6 +21,7 @@ import org.eclipse.jgit.util.FS; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCFetchPreUploadHookSSH; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPostPushHook; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPrePushHook; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCServletService; @@ -84,6 +85,7 @@ public void run() { if (GenericUtils.isNotBlank(protocol)) { uploadPack.setExtraParameters(Collections.singleton(protocol)); } + uploadPack.setPreUploadHook(new LocalVCFetchPreUploadHookSSH(localVCServletService, getServerSession(), rootDir)); uploadPack.upload(getInputStream(), getOutputStream(), getErrorStream()); } else if (RemoteConfig.DEFAULT_RECEIVE_PACK.equals(subCommand)) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index 2222e4a5f3d9..a55c44871619 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -86,6 +86,7 @@ * REST controller for managing ProgrammingExercise. */ @Profile(PROFILE_CORE) +@FeatureToggle(Feature.ProgrammingExercises) @RestController @RequestMapping("api/") public class ProgrammingExerciseExportImportResource { @@ -184,7 +185,6 @@ private void validateStaticCodeAnalysisSettings(ProgrammingExercise programmingE */ @PostMapping("programming-exercises/import/{sourceExerciseId}") @EnforceAtLeastEditor - @FeatureToggle(Feature.ProgrammingExercises) public ResponseEntity importProgrammingExercise(@PathVariable long sourceExerciseId, @RequestBody ProgrammingExercise newExercise, @RequestParam(defaultValue = "false") boolean recreateBuildPlans, @RequestParam(defaultValue = "false") boolean updateTemplate, @RequestParam(defaultValue = "false") boolean setTestCaseVisibilityToAfterDueDate) throws JsonProcessingException { @@ -291,7 +291,6 @@ public ResponseEntity importProgrammingExercise(@PathVariab */ @PostMapping("courses/{courseId}/programming-exercises/import-from-file") @EnforceAtLeastEditor - @FeatureToggle(Feature.ProgrammingExercises) public ResponseEntity importProgrammingExerciseFromFile(@PathVariable long courseId, @RequestPart("programmingExercise") ProgrammingExercise programmingExercise, @RequestPart("file") MultipartFile zipFile) { final var user = userRepository.getUserWithGroupsAndAuthorities(); @@ -318,7 +317,7 @@ public ResponseEntity importProgrammingExerciseFromFile(@Pa */ @GetMapping("programming-exercises/{exerciseId}/export-instructor-exercise") @EnforceAtLeastInstructor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportInstructorExercise(@PathVariable long exerciseId) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdWithPlagiarismDetectionConfigTeamConfigAndBuildConfigElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, programmingExercise, null); @@ -352,7 +351,7 @@ public ResponseEntity exportInstructorExercise(@PathVariable long exer */ @GetMapping("programming-exercises/{exerciseId}/export-instructor-repository/{repositoryType}") @EnforceAtLeastTutor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportInstructorRepository(@PathVariable long exerciseId, @PathVariable RepositoryType repositoryType) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, programmingExercise, null); @@ -373,7 +372,7 @@ public ResponseEntity exportInstructorRepository(@PathVariable long ex */ @GetMapping("programming-exercises/{exerciseId}/export-instructor-auxiliary-repository/{repositoryId}") @EnforceAtLeastTutor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportInstructorAuxiliaryRepository(@PathVariable long exerciseId, @PathVariable long repositoryId) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.TEACHING_ASSISTANT, programmingExercise, null); @@ -419,7 +418,7 @@ private ResponseEntity returnZipFileForRepositoryExport(Optional */ @PostMapping("programming-exercises/{exerciseId}/export-repos-by-participant-identifiers/{participantIdentifiers}") @EnforceAtLeastTutor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportSubmissionsByStudentLogins(@PathVariable long exerciseId, @PathVariable String participantIdentifiers, @RequestBody RepositoryExportOptionsDTO repositoryExportOptions) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdWithStudentParticipationsAndLegalSubmissionsElseThrow(exerciseId); @@ -464,7 +463,7 @@ public ResponseEntity exportSubmissionsByStudentLogins(@PathVariable l */ @PostMapping("programming-exercises/{exerciseId}/export-repos-by-participation-ids/{participationIds}") @EnforceAtLeastTutor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportSubmissionsByParticipationIds(@PathVariable long exerciseId, @PathVariable String participationIds, @RequestBody RepositoryExportOptionsDTO repositoryExportOptions) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdWithStudentParticipationsAndLegalSubmissionsElseThrow(exerciseId); @@ -523,7 +522,7 @@ private ResponseEntity provideZipForParticipations(@NotNull List exportStudentRequestedRepository(@PathVariable long exerciseId, @RequestParam() boolean includeTests) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); if (programmingExercise.isExamExercise()) { @@ -550,7 +549,7 @@ public ResponseEntity exportStudentRequestedRepository(@PathVariable l */ @GetMapping("programming-exercises/{exerciseId}/export-student-repository/{participationId}") @EnforceAtLeastStudent - @FeatureToggle({ Feature.ProgrammingExercises, Feature.Exports }) + @FeatureToggle(Feature.Exports) public ResponseEntity exportStudentRepository(@PathVariable long exerciseId, @PathVariable long participationId) throws IOException { var programmingExercise = programmingExerciseRepository.findByIdWithStudentParticipationsAndLegalSubmissionsElseThrow(exerciseId); var studentParticipation = programmingExercise.getStudentParticipations().stream().filter(p -> p.getId().equals(participationId)) diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index d06fdf5fb975..be1c99c67be6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -49,6 +49,7 @@ import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; import de.tum.cit.aet.artemis.programming.dto.VcsAccessLogDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; 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.VcsAccessLogRepository; @@ -89,11 +90,13 @@ public class ProgrammingExerciseParticipationResource { private final Optional vcsAccessLogRepository; + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -105,6 +108,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.participationAuthCheckService = participationAuthCheckService; this.repositoryService = repositoryService; this.studentExamRepository = studentExamRepository; + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; } @@ -339,22 +343,25 @@ public ResponseEntity> getVcsAccessLogForParticipationRepo /** * GET /programming-exercise/{exerciseID}/commit-history/{repositoryType} : Get the commit history of a programming exercise repository. The repository type can be TEMPLATE or - * SOLUTION or TESTS. + * SOLUTION, TESTS or AUXILIARY. * Here we check is at least a teaching assistant for the exercise. * * @param exerciseID the id of the exercise for which to retrieve the commit history * @param repositoryType the type of the repository for which to retrieve the commit history + * @param repositoryId the id of the repository * @return the ResponseEntity with status 200 (OK) and with body a list of commitInfo DTOs with the commits information of the repository */ @GetMapping("programming-exercise/{exerciseID}/commit-history/{repositoryType}") @EnforceAtLeastTutor - public ResponseEntity> getCommitHistoryForTemplateSolutionOrTestRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType) { + public ResponseEntity> getCommitHistoryForTemplateSolutionTestOrAuxRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType, + @RequestParam Optional repositoryId) { boolean isTemplateRepository = repositoryType.equals(RepositoryType.TEMPLATE); boolean isSolutionRepository = repositoryType.equals(RepositoryType.SOLUTION); boolean isTestRepository = repositoryType.equals(RepositoryType.TESTS); + boolean isAuxiliaryRepository = repositoryType.equals(RepositoryType.AUXILIARY); ProgrammingExerciseParticipation participation; - if (!isTemplateRepository && !isSolutionRepository && !isTestRepository) { + if (!isTemplateRepository && !isSolutionRepository && !isTestRepository && !isAuxiliaryRepository) { throw new BadRequestAlertException("Invalid repository type", ENTITY_NAME, "invalidRepositoryType"); } else if (isTemplateRepository) { @@ -364,6 +371,15 @@ else if (isTemplateRepository) { participation = programmingExerciseParticipationService.findSolutionParticipationByProgrammingExerciseId(exerciseID); } participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + + if (isAuxiliaryRepository) { + var auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(repositoryId.orElseThrow()); + if (!auxiliaryRepo.getExercise().getId().equals(exerciseID)) { + throw new BadRequestAlertException("Invalid repository id", ENTITY_NAME, "invalidRepositoryId"); + } + return ResponseEntity.ok(programmingExerciseParticipationService.getAuxiliaryRepositoryCommitInfos(auxiliaryRepo)); + } + if (isTestRepository) { return ResponseEntity.ok(programmingExerciseParticipationService.getCommitInfosTestRepo(participation)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExercisePlagiarismResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExercisePlagiarismResource.java index 4388e386ab39..95e60a0ec89c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExercisePlagiarismResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExercisePlagiarismResource.java @@ -40,6 +40,7 @@ * REST controller for managing ProgrammingExercise. */ @Profile(PROFILE_CORE) +@FeatureToggle(Feature.ProgrammingExercises) @RestController @RequestMapping("api/") public class ProgrammingExercisePlagiarismResource { @@ -72,7 +73,6 @@ public ProgrammingExercisePlagiarismResource(ProgrammingExerciseRepository progr */ @GetMapping("programming-exercises/{exerciseId}/plagiarism-result") @EnforceAtLeastEditor - @FeatureToggle(Feature.ProgrammingExercises) public ResponseEntity> getPlagiarismResult(@PathVariable long exerciseId) { log.debug("REST request to get the latest plagiarism result for the programming exercise with id: {}", exerciseId); ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); @@ -95,7 +95,7 @@ public ResponseEntity> getPlagiarismRe */ @GetMapping("programming-exercises/{exerciseId}/check-plagiarism") @EnforceAtLeastEditor - @FeatureToggle({ Feature.ProgrammingExercises, Feature.PlagiarismChecks }) + @FeatureToggle(Feature.PlagiarismChecks) public ResponseEntity> checkPlagiarism(@PathVariable long exerciseId, @RequestParam int similarityThreshold, @RequestParam int minimumScore, @RequestParam int minimumSize) throws ExitException, IOException { ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); @@ -128,7 +128,6 @@ public ResponseEntity> checkPlagiarism */ @GetMapping(value = "programming-exercises/{exerciseId}/check-plagiarism-jplag-report") @EnforceAtLeastEditor - @FeatureToggle(Feature.ProgrammingExercises) public ResponseEntity checkPlagiarismWithJPlagReport(@PathVariable long exerciseId, @RequestParam int similarityThreshold, @RequestParam int minimumScore, @RequestParam int minimumSize) throws IOException { log.debug("REST request to check plagiarism for ProgrammingExercise with id: {}", exerciseId); 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 4fec9ec7d43e..cfb9bbc4025d 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 @@ -304,8 +304,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"); } @@ -333,6 +332,9 @@ public ResponseEntity updateProgrammingExercise(@RequestBod } } + // Verify that the checkout directories have not been changed. This is required since the buildScript and result paths are determined during the creation of the exercise. + programmingExerciseService.validateCheckoutDirectoriesUnchanged(programmingExerciseBeforeUpdate, updatedProgrammingExercise); + // Verify that a theia image is provided when the online IDE is enabled if (updatedProgrammingExercise.isAllowOnlineIde() && updatedProgrammingExercise.getBuildConfig().getTheiaImage() == null) { throw new BadRequestAlertException("You need to provide a Theia image when the online IDE is enabled", ENTITY_NAME, "noTheiaImageProvided"); @@ -552,6 +554,21 @@ public ResponseEntity getProgrammingExerciseWithTemplateAnd return ResponseEntity.ok(programmingExercise); } + /** + * GET /programming-exercises/:exerciseId/with-auxiliary-repository + * + * @param exerciseId the id of the programmingExercise to retrieve + * @return the ResponseEntity with status 200 (OK) and the programming exercise with template and solution participation, or with status 404 (Not Found) + */ + @GetMapping("programming-exercises/{exerciseId}/with-auxiliary-repository") + @EnforceAtLeastTutorInExercise + public ResponseEntity getProgrammingExerciseWithAuxiliaryRepository(@PathVariable long exerciseId) { + + log.debug("REST request to get programming exercise with auxiliary repositories: {}", exerciseId); + final var programmingExercise = programmingExerciseService.loadProgrammingExerciseWithAuxiliaryRepositories(exerciseId); + return ResponseEntity.ok(programmingExercise); + } + /** * DELETE /programming-exercises/:id : delete the "id" programmingExercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/CodeHintResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/CodeHintResource.java index 297e784f1e2b..122aa11b7a17 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/CodeHintResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/hestia/CodeHintResource.java @@ -1,11 +1,9 @@ package de.tum.cit.aet.artemis.programming.web.hestia; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_IRIS; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.Set; import org.slf4j.Logger; @@ -23,8 +21,6 @@ import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.exception.ConflictException; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; -import de.tum.cit.aet.artemis.iris.domain.settings.IrisSubSettingsType; -import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.hestia.CodeHint; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; @@ -50,15 +46,12 @@ public class CodeHintResource { private final CodeHintService codeHintService; - private final Optional irisSettingsService; - public CodeHintResource(ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingExerciseSolutionEntryRepository solutionEntryRepository, - CodeHintRepository codeHintRepository, CodeHintService codeHintService, Optional irisSettingsService) { + CodeHintRepository codeHintRepository, CodeHintService codeHintService) { this.programmingExerciseRepository = programmingExerciseRepository; this.solutionEntryRepository = solutionEntryRepository; this.codeHintRepository = codeHintRepository; this.codeHintService = codeHintService; - this.irisSettingsService = irisSettingsService; } /** @@ -98,41 +91,6 @@ public ResponseEntity> generateCodeHintsForExercise(@PathVariable return ResponseEntity.ok(codeHints); } - /** - * {@code POST programming-exercises/:exerciseId/code-hints/:codeHintId/generate-description} : Generate a description for a code hint using Iris. - * - * @param exerciseId The id of the exercise of the code hint - * @param codeHintId The id of the code hint - * @return the {@link ResponseEntity} with status {@code 200 (Ok)} and with body the updated code hint - */ - // TODO: move into some IrisResource - @Profile(PROFILE_IRIS) - @PostMapping("programming-exercises/{exerciseId}/code-hints/{codeHintId}/generate-description") - @EnforceAtLeastEditorInExercise - public ResponseEntity generateDescriptionForCodeHint(@PathVariable Long exerciseId, @PathVariable Long codeHintId) { - log.debug("REST request to generate description with Iris for CodeHint: {}", codeHintId); - - ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(exerciseId); - irisSettingsService.orElseThrow().isEnabledForElseThrow(IrisSubSettingsType.HESTIA, exercise); - - // Hints for exam exercises are not supported at the moment - if (exercise.isExamExercise()) { - throw new AccessForbiddenException("Code hints for exams are currently not supported"); - } - - var codeHint = codeHintRepository.findByIdWithSolutionEntriesElseThrow(codeHintId); - if (!Objects.equals(codeHint.getExercise().getId(), exercise.getId())) { - throw new ConflictException("The code hint does not belong to the exercise", "CodeHint", "codeHintExerciseConflict"); - } - - if (codeHint.getSolutionEntries().isEmpty()) { - throw new ConflictException("The code hint does not have any solution entries", "CodeHint", "codeHintNoSolutionEntries"); - } - - codeHint = codeHintService.generateDescriptionWithIris(codeHint); - return ResponseEntity.ok(codeHint); - } - /** * {@code DELETE programming-exercises/:exerciseId/code-hints/:codeHintId/solution-entries/:solutionEntryId} : * Removes a solution entry from a code hint. 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/programming/web/repository/AuxiliaryRepositoryResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java new file mode 100644 index 000000000000..8c03cf7dae19 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java @@ -0,0 +1,220 @@ +package de.tum.cit.aet.artemis.programming.web.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.eclipse.jgit.api.errors.CheckoutConflictException; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; +import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.core.service.feature.Feature; +import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; +import de.tum.cit.aet.artemis.programming.domain.FileType; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.Repository; +import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; +import de.tum.cit.aet.artemis.programming.dto.FileMove; +import de.tum.cit.aet.artemis.programming.dto.RepositoryStatusDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.programming.service.GitService; +import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService; +import de.tum.cit.aet.artemis.programming.service.RepositoryService; +import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCServletService; +import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlService; + +/** + * Executes requested actions on the auxiliary repository of a programming exercise. Only available to TAs, Instructors and Admins. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/auxiliary-repository/") +public class AuxiliaryRepositoryResource extends RepositoryResource { + + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + + public AuxiliaryRepositoryResource(ProfileService profileService, UserRepository userRepository, AuthorizationCheckService authCheckService, GitService gitService, + RepositoryService repositoryService, Optional versionControlService, ProgrammingExerciseRepository programmingExerciseRepository, + RepositoryAccessService repositoryAccessService, Optional localVCServletService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { + super(profileService, userRepository, authCheckService, gitService, repositoryService, versionControlService, programmingExerciseRepository, repositoryAccessService, + localVCServletService); + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; + } + + @Override + Repository getRepository(Long auxiliaryRepositoryId, RepositoryActionType repositoryActionType, boolean pullOnGet) throws GitAPIException { + final var auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + User user = userRepository.getUserWithGroupsAndAuthorities(); + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepository.getExercise(), user, "auxiliary"); + final var repoUri = auxiliaryRepository.getVcsRepositoryUri(); + return gitService.getOrCheckoutRepository(repoUri, pullOnGet); + } + + @Override + VcsRepositoryUri getRepositoryUri(Long auxiliaryRepositoryId) { + var auxRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + return auxRepo.getVcsRepositoryUri(); + } + + @Override + boolean canAccessRepository(Long auxiliaryRepositoryId) { + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId).getExercise(), + userRepository.getUserWithGroupsAndAuthorities(), "auxiliary"); + } + catch (AccessForbiddenException e) { + return false; + } + return true; + } + + @Override + String getOrRetrieveBranchOfDomainObject(Long auxiliaryRepositoryId) { + AuxiliaryRepository auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(auxiliaryRepo.getExercise().getId()); + return versionControlService.orElseThrow().getOrRetrieveBranchOfExercise(exercise); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/files", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity> getFiles(@PathVariable Long auxiliaryRepositoryId) { + return super.getFiles(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.getFile(auxiliaryRepositoryId, filename); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filePath, HttpServletRequest request) { + return super.createFile(auxiliaryRepositoryId, filePath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/folder", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFolder(@PathVariable Long auxiliaryRepositoryId, @RequestParam("folder") String folderPath, HttpServletRequest request) { + return super.createFolder(auxiliaryRepositoryId, folderPath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/rename-file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity renameFile(@PathVariable Long auxiliaryRepositoryId, @RequestBody FileMove fileMove) { + return super.renameFile(auxiliaryRepositoryId, fileMove); + } + + @Override + @DeleteMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity deleteFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.deleteFile(auxiliaryRepositoryId, filename); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/pull", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity pullChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.pullChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/commit", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity commitChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.commitChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/reset", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity resetToLastCommit(@PathVariable Long auxiliaryRepositoryId) { + return super.resetToLastCommit(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getStatus(@PathVariable Long auxiliaryRepositoryId) throws GitAPIException { + return super.getStatus(auxiliaryRepositoryId); + } + + /** + * Update a list of files in an auxiliary repository based on the submission's content. + * + * @param auxiliaryRepositoryId of exercise to which the files belong + * @param submissions information about the file updates + * @param commit whether to commit after updating the files + * @param principal used to check if the user can update the files + * @return {Map} file submissions or the appropriate http error + */ + @PutMapping("{auxiliaryRepositoryId}/files") + @EnforceAtLeastTutor + public ResponseEntity> updateAuxiliaryFiles(@PathVariable("auxiliaryRepositoryId") Long auxiliaryRepositoryId, + @RequestBody List submissions, @RequestParam Boolean commit, Principal principal) { + + if (versionControlService.isEmpty()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "VCSNotPresent"); + } + AuxiliaryRepository auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = auxiliaryRepository.getExercise(); + + Repository repository; + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(true, exercise, userRepository.getUserWithGroupsAndAuthorities(principal.getName()), "test"); + repository = gitService.getOrCheckoutRepository(auxiliaryRepository.getVcsRepositoryUri(), true); + } + catch (AccessForbiddenException e) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "noPermissions"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, error.getMessage(), error); + } + catch (CheckoutConflictException | WrongRepositoryStateException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutConflict"); + throw new ResponseStatusException(HttpStatus.CONFLICT, error.getMessage(), error); + } + catch (GitAPIException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutFailed"); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, error.getMessage(), error); + } + return saveFilesAndCommitChanges(auxiliaryRepositoryId, submissions, commit, repository); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java index f7f62ebb4989..8d803cc66a53 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java @@ -4,5 +4,5 @@ * Determines if a repository action only reads (e.g. get a file from the repo) or updates (e.g. create a new file in the repo). */ public enum RepositoryActionType { - READ, WRITE, RESET + READ, WRITE, RESET, CLONE, PULL, PUSH, CLONE_FAIL, PULL_FAIL, PUSH_FAIL } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizSubmissionRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizSubmissionRepository.java index 4202d525190b..39cd106f67b2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizSubmissionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizSubmissionRepository.java @@ -3,6 +3,7 @@ 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.List; import java.util.Optional; import java.util.Set; @@ -37,7 +38,16 @@ public interface QuizSubmissionRepository 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/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/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..5b94c29fca9a 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,8 +35,8 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findByCourseIdWithCategories(@Param("courseId") long courseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies" }) - Optional findWithEagerCompetenciesById(long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "competencies", "categories" }) + Optional findWithEagerCompetenciesAndCategoriesById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencies" }) Optional findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesById(long exerciseId); @@ -110,8 +110,8 @@ default TextExercise findWithGradingCriteriaByIdElseThrow(long exerciseId) { } @NotNull - default TextExercise findWithEagerCompetenciesByIdElseThrow(long exerciseId) { - return getValueElseThrow(findWithEagerCompetenciesById(exerciseId), exerciseId); + default TextExercise findWithEagerCompetenciesAndCategoriesByIdElseThrow(long exerciseId) { + return getValueElseThrow(findWithEagerCompetenciesAndCategoriesById(exerciseId), exerciseId); } @NotNull @@ -128,4 +128,7 @@ default TextExercise findByIdWithExampleSubmissionsAndResultsAndGradingCriteriaE default TextExercise findByIdWithStudentParticipationsAndSubmissionsElseThrow(long exerciseId) { return getValueElseThrow(findWithStudentParticipationsAndSubmissionsById(exerciseId), exerciseId); } + + @EntityGraph(type = LOAD, attributePaths = { "categories" }) + List 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/web/TextExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java index 8f98cd911c51..f9894c729a4f 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; } /** @@ -229,6 +234,8 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise 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. @@ -288,6 +295,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 +422,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/java/de/tum/cit/aet/artemis/text/web/admin/AdminTextAssessmentEventResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/admin/AdminTextAssessmentEventResource.java index 78b6a9f8871b..acecfb3b1e56 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/admin/AdminTextAssessmentEventResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/admin/AdminTextAssessmentEventResource.java @@ -19,6 +19,7 @@ * REST controller for administrating TextAssessmentEventResource. */ @Profile(PROFILE_CORE) +@EnforceAdmin @RestController @RequestMapping("api/admin/") public class AdminTextAssessmentEventResource { @@ -36,7 +37,6 @@ public AdminTextAssessmentEventResource(TextAssessmentEventRepository textAssess * @return returns a List of TextAssessmentEvent's */ @GetMapping("event-insights/text-assessment/events/{courseId}") - @EnforceAdmin public ResponseEntity> getEventsByCourseId(@PathVariable Long courseId) { List events = textAssessmentEventRepository.findAllByCourseId(courseId); return ResponseEntity.ok().body(events); diff --git a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupFreePeriodResource.java b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupFreePeriodResource.java index d8a59414d71c..791eea50d82e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupFreePeriodResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupFreePeriodResource.java @@ -37,6 +37,7 @@ import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupFreePeriodService; @Profile(PROFILE_CORE) +@FeatureToggle(Feature.TutorialGroups) @RestController @RequestMapping("api/") public class TutorialGroupFreePeriodResource { @@ -73,7 +74,6 @@ public TutorialGroupFreePeriodResource(TutorialGroupsConfigurationRepository tut */ @GetMapping("courses/{courseId}/tutorial-groups-configuration/{tutorialGroupsConfigurationId}/tutorial-free-periods/{tutorialGroupFreePeriodId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity getOneOfConfiguration(@PathVariable Long courseId, @PathVariable Long tutorialGroupsConfigurationId, @PathVariable Long tutorialGroupFreePeriodId) { log.debug("REST request to get tutorial group free period: {} of tutorial group configuration {} of course: {}", tutorialGroupFreePeriodId, tutorialGroupsConfigurationId, @@ -96,7 +96,6 @@ public ResponseEntity getOneOfConfiguration(@PathVariab */ @PutMapping("courses/{courseId}/tutorial-groups-configuration/{tutorialGroupsConfigurationId}/tutorial-free-periods/{tutorialGroupFreePeriodId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity update(@PathVariable Long courseId, @PathVariable Long tutorialGroupsConfigurationId, @PathVariable Long tutorialGroupFreePeriodId, @RequestBody @Valid TutorialGroupFreePeriodDTO tutorialGroupFreePeriod) throws URISyntaxException { log.debug("REST request to update TutorialGroupFreePeriod: {} for tutorial group configuration: {} of course: {}", tutorialGroupFreePeriodId, tutorialGroupsConfigurationId, @@ -144,7 +143,6 @@ public ResponseEntity update(@PathVariable Long courseI */ @PostMapping("courses/{courseId}/tutorial-groups-configuration/{tutorialGroupsConfigurationId}/tutorial-free-periods") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity create(@PathVariable Long courseId, @PathVariable Long tutorialGroupsConfigurationId, @RequestBody @Valid TutorialGroupFreePeriodDTO tutorialGroupFreePeriod) throws URISyntaxException { log.debug("REST request to create TutorialGroupFreePeriod: {} for tutorial group configuration: {} of course: {}", tutorialGroupFreePeriod, tutorialGroupsConfigurationId, @@ -189,7 +187,6 @@ public ResponseEntity create(@PathVariable Long courseI */ @DeleteMapping("courses/{courseId}/tutorial-groups-configuration/{tutorialGroupsConfigurationId}/tutorial-free-periods/{tutorialGroupFreePeriodId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity delete(@PathVariable Long courseId, @PathVariable Long tutorialGroupsConfigurationId, @PathVariable Long tutorialGroupFreePeriodId) throws URISyntaxException { log.debug("REST request to delete TutorialGroupFreePeriod: {} of tutorial group configuration {} of course: {}", tutorialGroupFreePeriodId, tutorialGroupsConfigurationId, diff --git a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupResource.java b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupResource.java index daa50435cf94..a82e0c9297ad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupResource.java @@ -71,6 +71,7 @@ import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupService; @Profile(PROFILE_CORE) +@FeatureToggle(Feature.TutorialGroups) @RestController @RequestMapping("api/") public class TutorialGroupResource { @@ -131,7 +132,6 @@ public TutorialGroupResource(AuthorizationCheckService authorizationCheckService */ @GetMapping("tutorial-groups/{tutorialGroupId}/title") @EnforceAtLeastStudent - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity getTitle(@PathVariable Long tutorialGroupId) { log.debug("REST request to get title of TutorialGroup : {}", tutorialGroupId); return tutorialGroupRepository.getTutorialGroupTitle(tutorialGroupId).map(ResponseEntity::ok) @@ -147,7 +147,6 @@ public ResponseEntity getTitle(@PathVariable Long tutorialGroupId) { */ @GetMapping("courses/{courseId}/tutorial-groups/campus-values") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity> getUniqueCampusValues(@PathVariable Long courseId) { log.debug("REST request to get unique campus values used for tutorial groups in course : {}", courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -165,7 +164,6 @@ public ResponseEntity> getUniqueCampusValues(@PathVariable Long cour */ @GetMapping("courses/{courseId}/tutorial-groups/language-values") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity> getUniqueLanguageValues(@PathVariable Long courseId) { log.debug("REST request to get unique language values used for tutorial groups in course : {}", courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -182,7 +180,6 @@ public ResponseEntity> getUniqueLanguageValues(@PathVariable Long co */ @GetMapping("courses/{courseId}/tutorial-groups") @EnforceAtLeastStudent - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity> getAllForCourse(@PathVariable Long courseId) { log.debug("REST request to get all tutorial groups of course with id: {}", courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -202,7 +199,6 @@ public ResponseEntity> getAllForCourse(@PathVariable Long co */ @GetMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}") @EnforceAtLeastStudent - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity getOneOfCourse(@PathVariable Long courseId, @PathVariable Long tutorialGroupId) { log.debug("REST request to get tutorial group: {} of course: {}", tutorialGroupId, courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -221,7 +217,6 @@ public ResponseEntity getOneOfCourse(@PathVariable Long courseId, */ @PostMapping("courses/{courseId}/tutorial-groups") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity create(@PathVariable Long courseId, @RequestBody @Valid TutorialGroup tutorialGroup) throws URISyntaxException { log.debug("REST request to create TutorialGroup: {} in course: {}", tutorialGroup, courseId); if (tutorialGroup.getId() != null) { @@ -282,7 +277,6 @@ public ResponseEntity create(@PathVariable Long courseId, @Reques */ @DeleteMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity delete(@PathVariable Long courseId, @PathVariable Long tutorialGroupId) { log.info("REST request to delete a TutorialGroup: {} of course: {}", tutorialGroupId, courseId); var tutorialGroupFromDatabase = this.tutorialGroupRepository.findByIdWithTeachingAssistantAndRegistrationsElseThrow(tutorialGroupId); @@ -317,7 +311,6 @@ public record TutorialGroupUpdateDTO(@Valid @NotNull TutorialGroup tutorialGroup */ @PutMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity update(@PathVariable long courseId, @PathVariable long tutorialGroupId, @RequestBody @Valid TutorialGroupUpdateDTO tutorialGroupUpdateDTO) { TutorialGroup updatedTutorialGroup = tutorialGroupUpdateDTO.tutorialGroup(); @@ -405,7 +398,6 @@ public ResponseEntity update(@PathVariable long courseId, @PathVa */ @DeleteMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/deregister/{studentLogin:" + Constants.LOGIN_REGEX + "}") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity deregisterStudent(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable String studentLogin) { log.debug("REST request to deregister {} student from tutorial group : {}", studentLogin, tutorialGroupId); var tutorialGroupFromDatabase = this.tutorialGroupRepository.findByIdElseThrow(tutorialGroupId); @@ -427,7 +419,6 @@ public ResponseEntity deregisterStudent(@PathVariable Long courseId, @Path */ @PostMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/register/{studentLogin:" + Constants.LOGIN_REGEX + "}") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity registerStudent(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable String studentLogin) { log.debug("REST request to register {} student to tutorial group : {}", studentLogin, tutorialGroupId); var tutorialGroupFromDatabase = this.tutorialGroupRepository.findByIdElseThrow(tutorialGroupId); @@ -454,7 +445,6 @@ public ResponseEntity registerStudent(@PathVariable Long courseId, @PathVa */ @PostMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/register-multiple") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity> registerMultipleStudentsToTutorialGroup(@PathVariable long courseId, @PathVariable long tutorialGroupId, @RequestBody Set studentDtos) { log.debug("REST request to register {} to tutorial group {}", studentDtos, tutorialGroupId); @@ -476,7 +466,6 @@ public ResponseEntity> registerMultipleStudentsToTutorialGroup(@ */ @PostMapping("courses/{courseId}/tutorial-groups/import") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity> importRegistrations(@PathVariable Long courseId, @RequestBody @Valid Set importDTOs) { log.debug("REST request to import registrations {} to course {}", importDTOs, courseId); @@ -549,7 +538,6 @@ private void checkEntityIdMatchesPathIds(TutorialGroup tutorialGroup, Optional exportTutorialGroupsToCSV(@PathVariable Long courseId, @RequestParam List fields) { log.debug("REST request to export TutorialGroups to CSV for course: {}", courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -580,7 +568,6 @@ public ResponseEntity exportTutorialGroupsToCSV(@PathVariable Long cours */ @GetMapping(value = "courses/{courseId}/tutorial-groups/export/json", produces = MediaType.APPLICATION_JSON_VALUE) @EnforceAtLeastInstructorInCourse - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity exportTutorialGroupsToJSON(@PathVariable Long courseId, @RequestParam List fields) { log.debug("REST request to export TutorialGroups to JSON for course: {}", courseId); try { diff --git a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupSessionResource.java b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupSessionResource.java index 54790e628ba9..4d9c9459f65c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupSessionResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupSessionResource.java @@ -56,6 +56,7 @@ import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupService; @Profile(PROFILE_CORE) +@FeatureToggle(Feature.TutorialGroups) @RestController @RequestMapping("api/") public class TutorialGroupSessionResource { @@ -101,7 +102,6 @@ public TutorialGroupSessionResource(TutorialGroupSessionRepository tutorialGroup */ @GetMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}") @EnforceAtLeastStudent - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity getOneOfTutorialGroup(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable Long sessionId) { log.debug("REST request to get session: {} of tutorial group: {} of course {}", sessionId, tutorialGroupId, courseId); var session = tutorialGroupSessionRepository.findByIdElseThrow(sessionId); @@ -124,7 +124,6 @@ public ResponseEntity getOneOfTutorialGroup(@PathVariable */ @PutMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity update(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable Long sessionId, @RequestBody @Valid TutorialGroupSessionDTO tutorialGroupSessionDTO) { log.debug("REST request to update session: {} of tutorial group: {} of course {}", sessionId, tutorialGroupId, courseId); @@ -170,7 +169,6 @@ public ResponseEntity update(@PathVariable Long courseId, */ @PatchMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}/attendance-count") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity updateAttendanceCount(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable Long sessionId, @RequestParam(required = false) @Min(0) @Max(3000) Integer attendanceCount) { log.debug("REST request to update attendance count of session: {} of tutorial group: {} of course {} to {}", sessionId, tutorialGroupId, courseId, attendanceCount); @@ -192,7 +190,6 @@ public ResponseEntity updateAttendanceCount(@PathVariable */ @DeleteMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity deleteSession(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable Long sessionId) { log.debug("REST request to delete session: {} of tutorial group: {} of course {}", sessionId, tutorialGroupId, courseId); var sessionFromDatabase = this.tutorialGroupSessionRepository.findByIdElseThrow(sessionId); @@ -212,7 +209,6 @@ public ResponseEntity deleteSession(@PathVariable Long courseId, @PathVari */ @PostMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity create(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @RequestBody @Valid TutorialGroupSessionDTO tutorialGroupSessionDTO) throws URISyntaxException { log.debug("REST request to create TutorialGroupSession: {} for tutorial group: {}", tutorialGroupSessionDTO, tutorialGroupId); @@ -255,7 +251,6 @@ private TutorialGroupsConfiguration validateTutorialGroupConfiguration(@PathVari */ @PostMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}/cancel") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity cancel(@PathVariable Long courseId, @PathVariable Long tutorialGroupId, @PathVariable Long sessionId, @RequestBody TutorialGroupStatusDTO tutorialGroupStatusDTO) throws URISyntaxException { log.debug("REST request to cancel session: {} of tutorial group: {} of course {}", sessionId, tutorialGroupId, courseId); @@ -283,7 +278,6 @@ public ResponseEntity cancel(@PathVariable Long courseId, */ @PostMapping("courses/{courseId}/tutorial-groups/{tutorialGroupId}/sessions/{sessionId}/activate") @EnforceAtLeastTutor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity activate(@PathVariable long courseId, @PathVariable long tutorialGroupId, @PathVariable long sessionId) throws URISyntaxException { log.debug("REST request to activate session: {} of tutorial group: {} of course {}", sessionId, tutorialGroupId, courseId); var sessionToActivate = tutorialGroupSessionRepository.findByIdElseThrow(sessionId); diff --git a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupsConfigurationResource.java b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupsConfigurationResource.java index 4e9c9212ee4b..dc26c5bd4e09 100644 --- a/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupsConfigurationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/tutorialgroup/web/TutorialGroupsConfigurationResource.java @@ -36,6 +36,7 @@ import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService; @Profile(PROFILE_CORE) +@FeatureToggle(Feature.TutorialGroups) @RestController @RequestMapping("api/") public class TutorialGroupsConfigurationResource { @@ -68,7 +69,6 @@ public TutorialGroupsConfigurationResource(TutorialGroupsConfigurationRepository */ @GetMapping("courses/{courseId}/tutorial-groups-configuration") @EnforceAtLeastStudent - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity getOneOfCourse(@PathVariable Long courseId) { log.debug("REST request to get tutorial groups configuration of course: {}", courseId); var course = courseRepository.findByIdElseThrow(courseId); @@ -85,7 +85,6 @@ public ResponseEntity getOneOfCourse(@PathVariable */ @PostMapping("courses/{courseId}/tutorial-groups-configuration") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity create(@PathVariable Long courseId, @RequestBody @Valid TutorialGroupsConfiguration tutorialGroupsConfiguration) throws URISyntaxException { log.debug("REST request to create TutorialGroupsConfiguration: {} for course: {}", tutorialGroupsConfiguration, courseId); @@ -120,7 +119,6 @@ public ResponseEntity create(@PathVariable Long cou */ @PutMapping("courses/{courseId}/tutorial-groups-configuration/{tutorialGroupsConfigurationId}") @EnforceAtLeastInstructor - @FeatureToggle(Feature.TutorialGroups) public ResponseEntity update(@PathVariable Long courseId, @PathVariable Long tutorialGroupsConfigurationId, @RequestBody @Valid TutorialGroupsConfiguration updatedTutorialGroupConfiguration) { log.debug("REST request to update TutorialGroupsConfiguration: {} of course: {}", updatedTutorialGroupConfiguration, courseId); diff --git a/src/main/resources/config/application-buildagent.yml b/src/main/resources/config/application-buildagent.yml index b013910100e5..fc3e4847f25e 100644 --- a/src/main/resources/config/application-buildagent.yml +++ b/src/main/resources/config/application-buildagent.yml @@ -18,7 +18,7 @@ artemis: specify-concurrent-builds: false concurrent-build-size: 1 asynchronous: true - timeout-seconds: 240 + timeout-seconds: 120 build-container-prefix: local-ci- proxies: use-system-proxy: false @@ -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 924d087ec8f2..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 @@ -91,6 +91,21 @@ artemis: default: "ghcr.io/ls1intum/artemis-rust-docker:v0.9.70" javascript: default: "ghcr.io/ls1intum/artemis-javascript-docker:v1.0.0" + r: + default: "ghcr.io/ls1intum/artemis-r-docker:v1.0.0" + c_plus_plus: + default: "ghcr.io/ls1intum/artemis-cpp-docker:v1.0.0" + 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: diff --git a/src/main/resources/config/liquibase/changelog/20240816150000_changelog.xml b/src/main/resources/config/liquibase/changelog/20240816150000_changelog.xml new file mode 100644 index 000000000000..b3fa6c9e1c47 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240816150000_changelog.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20240825191919_changelog.xml b/src/main/resources/config/liquibase/changelog/20240825191919_changelog.xml new file mode 100644 index 000000000000..3ae7fd7ea038 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240825191919_changelog.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + DELETE FROM iris_sub_settings WHERE discriminator = 'HESTIA'; + + + + + + + + + + + UPDATE iris_sub_settings + SET allowed_variants = 'default', selected_variant = 'default' + WHERE id IN ( + SELECT iris_chat_settings_id FROM iris_settings WHERE discriminator = 'GLOBAL' + UNION + SELECT iris_competency_generation_settings_id FROM iris_settings WHERE discriminator = 'GLOBAL' + UNION + SELECT iris_lecture_ingestion_settings_id FROM iris_settings WHERE discriminator = 'GLOBAL' + ); + + + + + + + + + + + + + + DELETE FROM iris_json_message_content WHERE id IN ( + SELECT iris_message_content.id FROM iris_message_content + JOIN iris_message ON iris_message_content.message_id = iris_message.id + JOIN iris_session ON iris_message.session_id = iris_session.id + WHERE iris_session.discriminator = 'HESTIA' + ); + DELETE FROM iris_text_message_content WHERE id IN ( + SELECT iris_message_content.id FROM iris_message_content + JOIN iris_message ON iris_message_content.message_id = iris_message.id + JOIN iris_session ON iris_message.session_id = iris_session.id + WHERE iris_session.discriminator = 'HESTIA' + ); + DELETE FROM iris_message_content WHERE message_id IN ( + SELECT iris_message.id FROM iris_message + JOIN iris_session ON iris_message.session_id = iris_session.id + WHERE iris_session.discriminator = 'HESTIA' + ); + DELETE FROM iris_message WHERE session_id IN ( + SELECT id FROM iris_session WHERE discriminator = 'HESTIA' + ); + DELETE FROM iris_session WHERE discriminator = 'HESTIA'; + + + + + + + diff --git a/src/main/resources/config/liquibase/changelog/20240909090909_changelog.xml b/src/main/resources/config/liquibase/changelog/20240909090909_changelog.xml new file mode 100644 index 000000000000..86b235b1d655 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240909090909_changelog.xml @@ -0,0 +1,11 @@ + + + + + + + + + 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/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 49f3eeee4d63..81930cba5026 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -22,9 +22,15 @@ + + + + + + diff --git a/src/main/resources/public/videos/course-competencies/create-competencies.gif b/src/main/resources/public/videos/course-competencies/create-competencies.gif new file mode 100644 index 000000000000..fcf6de03985b Binary files /dev/null and b/src/main/resources/public/videos/course-competencies/create-competencies.gif differ diff --git a/src/main/resources/public/videos/course-competencies/create-course-competency-relations.gif b/src/main/resources/public/videos/course-competencies/create-course-competency-relations.gif new file mode 100644 index 000000000000..072796051ef7 Binary files /dev/null and b/src/main/resources/public/videos/course-competencies/create-course-competency-relations.gif differ diff --git a/src/main/resources/templates/aeolus/assembler/default.sh b/src/main/resources/templates/aeolus/assembler/default.sh index cca62d776297..e5a322facd87 100644 --- a/src/main/resources/templates/aeolus/assembler/default.sh +++ b/src/main/resources/templates/aeolus/assembler/default.sh @@ -8,15 +8,15 @@ provide_environment_information () { python3 --version pip3 --version echo "--------------------Contents of tests repository--------------------" - ls -la tests + ls -la ${testWorkingDirectory} echo "---------------------------------------------" echo "--------------------Contents of assignment repository--------------------" - ls -la assignment + ls -la ${studentParentWorkingDirectoryName} echo "---------------------------------------------" #Fallback in case Docker does not work as intended - REQ_FILE=tests/requirements.txt + REQ_FILE=${testWorkingDirectory}/requirements.txt if [ -f "$REQ_FILE" ]; then - pip3 install --user -r tests/requirements.txt + pip3 install --user -r ${testWorkingDirectory}/requirements.txt else echo "$REQ_FILE does not exist" fi @@ -25,18 +25,18 @@ provide_environment_information () { prepare_makefile () { echo '⚙️ executing prepare_makefile' #!/usr/bin/env bash - rm -f assignment/{GNUmakefile, Makefile, makefile} - rm -f assignment/io.inc - cp -f tests/Makefile assignment/Makefile || exit 2 - cp -f tests/io.inc assignment/io.inc || exit 2 + rm -f ${studentParentWorkingDirectoryName}/{GNUmakefile, Makefile, makefile} + rm -f ${studentParentWorkingDirectoryName}/io.inc + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + cp -f ${testWorkingDirectory}/io.inc ${studentParentWorkingDirectoryName}/io.inc || exit 2 } run_and_compile () { echo '⚙️ executing run_and_compile' - cd tests - python3 compileTest.py ../assignment/ + cd ${testWorkingDirectory} + python3 compileTest.py ../${studentParentWorkingDirectoryName}/ rm compileTest.py - cp result.xml ../assignment/result.xml + cp result.xml ../${studentParentWorkingDirectoryName}/result.xml } junit () { diff --git a/src/main/resources/templates/aeolus/assembler/default.yaml b/src/main/resources/templates/aeolus/assembler/default.yaml index 3017be0a331b..7649ec646a51 100644 --- a/src/main/resources/templates/aeolus/assembler/default.yaml +++ b/src/main/resources/templates/aeolus/assembler/default.yaml @@ -7,15 +7,15 @@ actions: python3 --version pip3 --version echo "--------------------Contents of tests repository--------------------" - ls -la tests + ls -la ${testWorkingDirectory} echo "---------------------------------------------" echo "--------------------Contents of assignment repository--------------------" - ls -la assignment + ls -la ${studentParentWorkingDirectoryName} echo "---------------------------------------------" #Fallback in case Docker does not work as intended - REQ_FILE=tests/requirements.txt + REQ_FILE=${testWorkingDirectory}/requirements.txt if [ -f "$REQ_FILE" ]; then - pip3 install --user -r tests/requirements.txt + pip3 install --user -r ${testWorkingDirectory}/requirements.txt else echo "$REQ_FILE does not exist" fi @@ -23,17 +23,17 @@ actions: - name: prepare_makefile script: |- #!/usr/bin/env bash - rm -f assignment/{GNUmakefile, Makefile, makefile} - rm -f assignment/io.inc - cp -f tests/Makefile assignment/Makefile || exit 2 - cp -f tests/io.inc assignment/io.inc || exit 2 + rm -f ${studentParentWorkingDirectoryName}/{GNUmakefile, Makefile, makefile} + rm -f ${studentParentWorkingDirectoryName}/io.inc + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + cp -f ${testWorkingDirectory}/io.inc ${studentParentWorkingDirectoryName}/io.inc || exit 2 runAlways: false - name: run_and_compile script: |- - cd tests - python3 compileTest.py ../assignment/ + cd ${testWorkingDirectory} + python3 compileTest.py ../${studentParentWorkingDirectoryName}/ rm compileTest.py - cp result.xml ../assignment/result.xml + cp result.xml ../${studentParentWorkingDirectoryName}/result.xml runAlways: false - name: junit script: |- @@ -41,6 +41,6 @@ actions: runAlways: true results: - name: junit_result.xml - path: assignment/result.xml + path: ${studentParentWorkingDirectoryName}/result.xml type: junit before: true diff --git a/src/main/resources/templates/aeolus/c/fact.sh b/src/main/resources/templates/aeolus/c/fact.sh index 3596904b322b..4f7252eefaba 100644 --- a/src/main/resources/templates/aeolus/c/fact.sh +++ b/src/main/resources/templates/aeolus/c/fact.sh @@ -8,8 +8,8 @@ setup_the_build_environment () { # Task Description: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R || true + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R || true sudo mkdir test-reports sudo chown artemis_user:artemis_user test-reports/ -R || true } @@ -22,13 +22,13 @@ build_and_run_all_tests () { # Build and run all tests # ------------------------------ - rm -f assignment/GNUmakefile - rm -f assignment/Makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - cd tests + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + cd ${testWorkingDirectory} python3 Tests.py rm Tests.py - rm -rf ./tests || true + rm -rf ./${testWorkingDirectory} || true } main () { diff --git a/src/main/resources/templates/aeolus/c/fact.yaml b/src/main/resources/templates/aeolus/c/fact.yaml index 2cca1f9526b3..4278be956fac 100644 --- a/src/main/resources/templates/aeolus/c/fact.yaml +++ b/src/main/resources/templates/aeolus/c/fact.yaml @@ -7,8 +7,8 @@ actions: # Task Description: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R || true + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R || true sudo mkdir test-reports sudo chown artemis_user:artemis_user test-reports/ -R || true runAlways: false @@ -20,16 +20,15 @@ actions: # Build and run all tests # ------------------------------ - rm -f assignment/GNUmakefile - rm -f assignment/Makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - cd tests + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + cd ${testWorkingDirectory} python3 Tests.py rm Tests.py - rm -rf ./tests || true + rm -rf ./${testWorkingDirectory} || true runAlways: false results: - name: junit_test-reports/tests-results.xml path: test-reports/tests-results.xml type: junit - diff --git a/src/main/resources/templates/aeolus/c/gcc.sh b/src/main/resources/templates/aeolus/c/gcc.sh index 259f0bd886e0..3272660657a1 100644 --- a/src/main/resources/templates/aeolus/c/gcc.sh +++ b/src/main/resources/templates/aeolus/c/gcc.sh @@ -10,13 +10,13 @@ setup_the_build_environment () { # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R # assignment - cd tests + cd ${testWorkingDirectory} REQ_FILE=requirements.txt if [ -f "$REQ_FILE" ]; then pip3 install --user -r requirements.txt || true @@ -35,18 +35,18 @@ setup_makefile () { # Setup makefile # ------------------------------ - shadowFilePath="../tests/testUtils/c/shadow_exec.c" + shadowFilePath="../${testWorkingDirectory}/testUtils/c/shadow_exec.c" - foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' assignment/Makefile` + foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' ${studentParentWorkingDirectoryName}/Makefile` - foundSource=`grep -m 1 'SOURCE\s*=' assignment/Makefile` + foundSource=`grep -m 1 'SOURCE\s*=' ${studentParentWorkingDirectoryName}/Makefile` foundSource="$foundSource $shadowFilePath" - rm -f assignment/GNUmakefile - rm -f assignment/makefile + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" assignment/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" ${studentParentWorkingDirectoryName}/Makefile } build_and_run_all_tests () { @@ -58,10 +58,10 @@ build_and_run_all_tests () { # Build and run all tests if the compilation succeeds # ------------------------------ sudo chown artemis_user:artemis_user . - gcc -c -Wall assignment/*.c || error=true + gcc -c -Wall ${studentParentWorkingDirectoryName}/*.c || error=true if [ ! $error ] then - cd tests || exit 0 + cd ${testWorkingDirectory} || exit 0 python3 Tests.py || true fi } diff --git a/src/main/resources/templates/aeolus/c/gcc.yaml b/src/main/resources/templates/aeolus/c/gcc.yaml index 622b2148279d..31cafd647000 100644 --- a/src/main/resources/templates/aeolus/c/gcc.yaml +++ b/src/main/resources/templates/aeolus/c/gcc.yaml @@ -9,13 +9,13 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R # assignment - cd tests + cd ${testWorkingDirectory} REQ_FILE=requirements.txt if [ -f "$REQ_FILE" ]; then pip3 install --user -r requirements.txt || true @@ -33,18 +33,18 @@ actions: # Setup makefile # ------------------------------ - shadowFilePath="../tests/testUtils/c/shadow_exec.c" + shadowFilePath="../${testWorkingDirectory}/testUtils/c/shadow_exec.c" - foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' assignment/Makefile` + foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' ${studentParentWorkingDirectoryName}/Makefile` - foundSource=`grep -m 1 'SOURCE\s*=' assignment/Makefile` + foundSource=`grep -m 1 'SOURCE\s*=' ${studentParentWorkingDirectoryName}/Makefile` foundSource="$foundSource $shadowFilePath" - rm -f assignment/GNUmakefile - rm -f assignment/makefile + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" assignment/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" ${studentParentWorkingDirectoryName}/Makefile runAlways: false - name: build_and_run_all_tests script: |- @@ -55,10 +55,10 @@ actions: # Build and run all tests if the compilation succeeds # ------------------------------ sudo chown artemis_user:artemis_user . - gcc -c -Wall assignment/*.c || error=true + gcc -c -Wall ${studentParentWorkingDirectoryName}/*.c || error=true if [ ! $error ] then - cd tests || exit 0 + cd ${testWorkingDirectory} || exit 0 python3 Tests.py || true fi runAlways: false diff --git a/src/main/resources/templates/aeolus/c/gcc_static.sh b/src/main/resources/templates/aeolus/c/gcc_static.sh index 96847a3bbc92..2dfa84c2a569 100644 --- a/src/main/resources/templates/aeolus/c/gcc_static.sh +++ b/src/main/resources/templates/aeolus/c/gcc_static.sh @@ -10,13 +10,13 @@ setup_the_build_environment () { # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R # assignment - cd tests + cd ${testWorkingDirectory} REQ_FILE=requirements.txt if [ -f "$REQ_FILE" ]; then pip3 install --user -r requirements.txt || true @@ -35,18 +35,18 @@ setup_makefile () { # Setup makefile # ------------------------------ - shadowFilePath="../tests/testUtils/c/shadow_exec.c" + shadowFilePath="../${testWorkingDirectory}/testUtils/c/shadow_exec.c" - foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' assignment/Makefile` + foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' ${studentParentWorkingDirectoryName}/Makefile` - foundSource=`grep -m 1 'SOURCE\s*=' assignment/Makefile` + foundSource=`grep -m 1 'SOURCE\s*=' ${studentParentWorkingDirectoryName}/Makefile` foundSource="$foundSource $shadowFilePath" - rm -f assignment/GNUmakefile - rm -f assignment/makefile + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" assignment/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" ${studentParentWorkingDirectoryName}/Makefile } build_and_run_all_tests () { @@ -58,10 +58,10 @@ build_and_run_all_tests () { # Build and run all tests if the compilation succeeds # ------------------------------ sudo chown artemis_user:artemis_user . - gcc -c -Wall assignment/*.c || error=true + gcc -c -Wall ${studentParentWorkingDirectoryName}/*.c || error=true if [ ! $error ] then - cd tests || exit 0 + cd ${testWorkingDirectory} || exit 0 python3 Tests.py || true else exit 1 diff --git a/src/main/resources/templates/aeolus/c/gcc_static.yaml b/src/main/resources/templates/aeolus/c/gcc_static.yaml index be9e9eb1dc2f..06ad3136f9aa 100644 --- a/src/main/resources/templates/aeolus/c/gcc_static.yaml +++ b/src/main/resources/templates/aeolus/c/gcc_static.yaml @@ -9,13 +9,13 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... - sudo chown artemis_user:artemis_user assignment/ -R + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... + sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R # assignment - cd tests + cd ${testWorkingDirectory} REQ_FILE=requirements.txt if [ -f "$REQ_FILE" ]; then pip3 install --user -r requirements.txt || true @@ -33,18 +33,18 @@ actions: # Setup makefile # ------------------------------ - shadowFilePath="../tests/testUtils/c/shadow_exec.c" + shadowFilePath="../${testWorkingDirectory}/testUtils/c/shadow_exec.c" - foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' assignment/Makefile` + foundIncludeDirs=`grep -m 1 'INCLUDEDIRS\s*=' ${studentParentWorkingDirectoryName}/Makefile` - foundSource=`grep -m 1 'SOURCE\s*=' assignment/Makefile` + foundSource=`grep -m 1 'SOURCE\s*=' ${studentParentWorkingDirectoryName}/Makefile` foundSource="$foundSource $shadowFilePath" - rm -f assignment/GNUmakefile - rm -f assignment/makefile + rm -f ${studentParentWorkingDirectoryName}/GNUmakefile + rm -f ${studentParentWorkingDirectoryName}/makefile - cp -f tests/Makefile assignment/Makefile || exit 2 - sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" assignment/Makefile + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 + sed -i "s~\bINCLUDEDIRS\s*=.*~${foundIncludeDirs}~; s~\bSOURCE\s*=.*~${foundSource}~" ${studentParentWorkingDirectoryName}/Makefile runAlways: false - name: build_and_run_all_tests script: |- @@ -55,10 +55,10 @@ actions: # Build and run all tests if the compilation succeeds # ------------------------------ sudo chown artemis_user:artemis_user . - gcc -c -Wall assignment/*.c || error=true + gcc -c -Wall ${studentParentWorkingDirectoryName}/*.c || error=true if [ ! $error ] then - cd tests || exit 0 + cd ${testWorkingDirectory} || exit 0 python3 Tests.py || true else exit 1 diff --git a/src/main/resources/templates/aeolus/c_plus_plus/default.sh b/src/main/resources/templates/aeolus/c_plus_plus/default.sh new file mode 100644 index 000000000000..aa91d2f607d0 --- /dev/null +++ b/src/main/resources/templates/aeolus/c_plus_plus/default.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -e +export AEOLUS_INITIAL_DIRECTORY=${PWD} +setup_the_build_environment () { + echo '⚙️ executing setup_the_build_environment' + #!/usr/bin/env bash + + # ------------------------------ + # Task Description: + # Setup the build environment + # ------------------------------ + + mkdir test-reports + + # Updating ownership... + chown -R artemis_user:artemis_user . + + REQ_FILE=requirements.txt + if [ -f "$REQ_FILE" ]; then + python3 -m venv /venv + /venv/bin/pip3 install -r "$REQ_FILE" + else + echo "$REQ_FILE does not exist" + fi +} + +build_and_run_all_tests () { + echo '⚙️ executing build_and_run_all_tests' + #!/usr/bin/env bash + + # ------------------------------ + # Task Description: + # Build and run all tests + # ------------------------------ + + if [ -d /venv ]; then + . /venv/bin/activate + fi + + # Run tests as unprivileged user + runuser -u artemis_user python3 Tests.py +} + +main () { + if [[ "${1}" == "aeolus_sourcing" ]]; then + return 0 # just source to use the methods in the subshell, no execution + fi + local _script_name + _script_name=${BASH_SOURCE[0]:-$0} + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; setup_the_build_environment" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; build_and_run_all_tests" +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/c_plus_plus/default.yaml b/src/main/resources/templates/aeolus/c_plus_plus/default.yaml new file mode 100644 index 000000000000..ac71eb79779f --- /dev/null +++ b/src/main/resources/templates/aeolus/c_plus_plus/default.yaml @@ -0,0 +1,48 @@ +api: v0.0.1 +metadata: + name: C++ + id: c_plus_plus + description: Test using the GBS Tester +actions: + - name: setup_the_build_environment + script: |- + #!/usr/bin/env bash + + # ------------------------------ + # Task Description: + # Setup the build environment + # ------------------------------ + + mkdir test-reports + + # Updating ownership... + chown -R artemis_user:artemis_user . + + REQ_FILE=requirements.txt + if [ -f "$REQ_FILE" ]; then + python3 -m venv /venv + /venv/bin/pip3 install -r "$REQ_FILE" + else + echo "$REQ_FILE does not exist" + fi + runAlways: false + - name: build_and_run_all_tests + script: |- + #!/usr/bin/env bash + + # ------------------------------ + # Task Description: + # Build and run all tests + # ------------------------------ + + if [ -d /venv ]; then + . /venv/bin/activate + fi + + # Run tests as unprivileged user + runuser -u artemis_user python3 Tests.py + runAlways: false + results: + - name: junit_test-reports/tests-results.xml + path: 'test-reports/*.xml' + type: junit diff --git a/src/main/resources/templates/aeolus/java/plain_maven_blackbox.sh b/src/main/resources/templates/aeolus/java/plain_maven_blackbox.sh index 46186cf50311..190bc88c4831 100644 --- a/src/main/resources/templates/aeolus/java/plain_maven_blackbox.sh +++ b/src/main/resources/templates/aeolus/java/plain_maven_blackbox.sh @@ -9,9 +9,9 @@ build () { checkers () { echo '⚙️ executing checkers' # all java files in the assignment folder should have maximal line length 80 - pipeline-helper line-length -l 80 -s assignment/ -e java + pipeline-helper line-length -l 80 -s ${studentParentWorkingDirectoryName}/ -e java # checks that the file exists and is not empty for non gui programs - pipeline-helper file-exists assignment/Tests.txt + pipeline-helper file-exists ${studentParentWorkingDirectoryName}/Tests.txt main_checker_output=$(pipeline-helper main-method -s target/classes) diff --git a/src/main/resources/templates/aeolus/java/plain_maven_blackbox.yaml b/src/main/resources/templates/aeolus/java/plain_maven_blackbox.yaml index 2b4428a585d1..02d0ff15b7ad 100644 --- a/src/main/resources/templates/aeolus/java/plain_maven_blackbox.yaml +++ b/src/main/resources/templates/aeolus/java/plain_maven_blackbox.yaml @@ -5,9 +5,9 @@ actions: - name: checkers script: |- # all java files in the assignment folder should have maximal line length 80 - pipeline-helper line-length -l 80 -s assignment/ -e java + pipeline-helper line-length -l 80 -s ${studentParentWorkingDirectoryName}/ -e java # checks that the file exists and is not empty for non gui programs - pipeline-helper file-exists assignment/Tests.txt + pipeline-helper file-exists ${studentParentWorkingDirectoryName}/Tests.txt main_checker_output=$(pipeline-helper main-method -s target/classes) diff --git a/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.sh b/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.sh index 9a6be87561a0..3a1af9d0d213 100644 --- a/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.sh +++ b/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.sh @@ -9,9 +9,9 @@ build () { checkers () { echo '⚙️ executing checkers' # all java files in the assignment folder should have maximal line length 80 - pipeline-helper line-length -l 80 -s assignment/ -e java + pipeline-helper line-length -l 80 -s ${studentParentWorkingDirectoryName}/ -e java # checks that the file exists and is not empty for non gui programs - pipeline-helper file-exists assignment/Tests.txt + pipeline-helper file-exists ${studentParentWorkingDirectoryName}/Tests.txt main_checker_output=$(pipeline-helper main-method -s target/classes) diff --git a/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.yaml b/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.yaml index 68c71c046d3d..7cd6a6c07773 100644 --- a/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.yaml +++ b/src/main/resources/templates/aeolus/java/plain_maven_blackbox_static.yaml @@ -5,9 +5,9 @@ actions: - name: checkers script: |- # all java files in the assignment folder should have maximal line length 80 - pipeline-helper line-length -l 80 -s assignment/ -e java + pipeline-helper line-length -l 80 -s ${studentParentWorkingDirectoryName}/ -e java # checks that the file exists and is not empty for non gui programs - pipeline-helper file-exists assignment/Tests.txt + pipeline-helper file-exists ${studentParentWorkingDirectoryName}/Tests.txt main_checker_output=$(pipeline-helper main-method -s target/classes) diff --git a/src/main/resources/templates/aeolus/ocaml/default.sh b/src/main/resources/templates/aeolus/ocaml/default.sh index ac1cf8b65b8d..f5b5f595b38d 100644 --- a/src/main/resources/templates/aeolus/ocaml/default.sh +++ b/src/main/resources/templates/aeolus/ocaml/default.sh @@ -3,7 +3,7 @@ set -e export AEOLUS_INITIAL_DIRECTORY=${PWD} build_and_test_the_code () { echo '⚙️ executing build_and_test_the_code' - cd "tests" + cd "${testWorkingDirectory}" # the build process is specified in `run.sh` in the test repository chmod +x run.sh ./run.sh -s diff --git a/src/main/resources/templates/aeolus/ocaml/default.yaml b/src/main/resources/templates/aeolus/ocaml/default.yaml index df5aef046d3a..70cdfcad1b9f 100644 --- a/src/main/resources/templates/aeolus/ocaml/default.yaml +++ b/src/main/resources/templates/aeolus/ocaml/default.yaml @@ -5,7 +5,7 @@ actions: # the build process is specified in `run.sh` in the test repository chmod +x run.sh ./run.sh -s - workdir: tests + workdir: ${testWorkingDirectory} runAlways: false - name: junit script: '#empty script action, just for the results' diff --git a/src/main/resources/templates/aeolus/r/default.sh b/src/main/resources/templates/aeolus/r/default.sh new file mode 100644 index 000000000000..1d0b32e87105 --- /dev/null +++ b/src/main/resources/templates/aeolus/r/default.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e +export AEOLUS_INITIAL_DIRECTORY=${PWD} +install () { + echo '⚙️ executing install' + R CMD INSTALL assignment +} + +run_all_tests () { + echo '⚙️ executing run_all_tests' + Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' +} + +main () { + if [[ "${1}" == "aeolus_sourcing" ]]; then + return 0 # just source to use the methods in the subshell, no execution + fi + local _script_name + _script_name=${BASH_SOURCE[0]:-$0} + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; install" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; run_all_tests" +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/r/default.yaml b/src/main/resources/templates/aeolus/r/default.yaml new file mode 100644 index 000000000000..a41d23c6f012 --- /dev/null +++ b/src/main/resources/templates/aeolus/r/default.yaml @@ -0,0 +1,14 @@ +api: v0.0.1 +metadata: + name: R + id: r + description: Test package using testthat +actions: + - name: install + script: R CMD INSTALL assignment + - name: run_all_tests + script: Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' + results: + - name: junit + path: tests/testthat/junit.xml + type: junit diff --git a/src/main/resources/templates/aeolus/swift/plain.sh b/src/main/resources/templates/aeolus/swift/plain.sh index 2673e861d564..71387b392783 100644 --- a/src/main/resources/templates/aeolus/swift/plain.sh +++ b/src/main/resources/templates/aeolus/swift/plain.sh @@ -4,12 +4,12 @@ set -e build_and_test_the_code () { echo '⚙️ executing build_and_test_the_code' # copy test files - cp -R Tests assignment - cp Package.swift assignment + cp -R Tests ${studentParentWorkingDirectoryName} + cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build - cd assignment + cd ${studentParentWorkingDirectoryName} swift build || error=true if [ ! $error ] diff --git a/src/main/resources/templates/aeolus/swift/plain.yaml b/src/main/resources/templates/aeolus/swift/plain.yaml index c90994ea4c4a..a2be5d469e65 100644 --- a/src/main/resources/templates/aeolus/swift/plain.yaml +++ b/src/main/resources/templates/aeolus/swift/plain.yaml @@ -3,12 +3,12 @@ actions: - name: build_and_test_the_code script: |- # copy test files - cp -R Tests assignment - cp Package.swift assignment + cp -R Tests ${studentParentWorkingDirectoryName} + cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build - cd assignment + cd ${studentParentWorkingDirectoryName} swift build || error=true if [ ! $error ] @@ -24,6 +24,6 @@ actions: runAlways: false results: - name: junit_tests.xml - path: assignment/tests.xml + path: ${studentParentWorkingDirectoryName}/tests.xml type: junit before: true diff --git a/src/main/resources/templates/aeolus/swift/plain_static.sh b/src/main/resources/templates/aeolus/swift/plain_static.sh index 835494ff5454..3bbaa9fe0662 100644 --- a/src/main/resources/templates/aeolus/swift/plain_static.sh +++ b/src/main/resources/templates/aeolus/swift/plain_static.sh @@ -3,14 +3,14 @@ set -e export AEOLUS_INITIAL_DIRECTORY=${PWD} build_and_test_the_code () { echo '⚙️ executing build_and_test_the_code' - cp -R Sources assignment + cp -R Sources ${studentParentWorkingDirectoryName} # copy test files - cp -R Tests assignment - cp Package.swift assignment + cp -R Tests ${studentParentWorkingDirectoryName} + cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build - cd assignment + cd ${studentParentWorkingDirectoryName} swift build || error=true if [ ! $error ] @@ -28,10 +28,10 @@ build_and_test_the_code () { run_static_code_analysis () { echo '⚙️ executing run_static_code_analysis' # Copy SwiftLint rules - cp .swiftlint.yml assignment || true + cp .swiftlint.yml ${studentParentWorkingDirectoryName} || true # create target directory for SCA Parser mkdir target - cd assignment + cd ${studentParentWorkingDirectoryName} # Execute static code analysis swiftlint > ../target/swiftlint-result.xml } diff --git a/src/main/resources/templates/aeolus/swift/plain_static.yaml b/src/main/resources/templates/aeolus/swift/plain_static.yaml index c900edc82d44..83c76e1a2b61 100644 --- a/src/main/resources/templates/aeolus/swift/plain_static.yaml +++ b/src/main/resources/templates/aeolus/swift/plain_static.yaml @@ -2,14 +2,14 @@ api: v0.0.1 actions: - name: build_and_test_the_code script: |- - cp -R Sources assignment + cp -R Sources ${studentParentWorkingDirectoryName} # copy test files - cp -R Tests assignment - cp Package.swift assignment + cp -R Tests ${studentParentWorkingDirectoryName} + cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build - cd assignment + cd ${studentParentWorkingDirectoryName} swift build || error=true if [ ! $error ] @@ -26,10 +26,10 @@ actions: - name: run_static_code_analysis script: |- # Copy SwiftLint rules - cp .swiftlint.yml assignment || true + cp .swiftlint.yml ${studentParentWorkingDirectoryName} || true # create target directory for SCA Parser mkdir target - cd assignment + cd ${studentParentWorkingDirectoryName} # Execute static code analysis swiftlint > ../target/swiftlint-result.xml runAlways: true @@ -39,6 +39,6 @@ actions: before: false type: static-code-analysis - name: junit_tests.xml - path: assignment/tests.xml + path: ${studentParentWorkingDirectoryName}/tests.xml type: junit before: true diff --git a/src/main/resources/templates/aeolus/typescript/default.sh b/src/main/resources/templates/aeolus/typescript/default.sh new file mode 100644 index 000000000000..6b6dceabd179 --- /dev/null +++ b/src/main/resources/templates/aeolus/typescript/default.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -e +export AEOLUS_INITIAL_DIRECTORY=${PWD} +install_dependencies () { + echo '⚙️ executing install_dependencies' + npm ci --prefer-offline --no-audit +} + +build () { + echo '⚙️ executing build' + npm run build +} + +test () { + echo '⚙️ executing test' + npm run test:ci +} + +main () { + if [[ "${1}" == "aeolus_sourcing" ]]; then + return 0 # just source to use the methods in the subshell, no execution + fi + local _script_name + _script_name=${BASH_SOURCE[0]:-$0} + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; install_dependencies" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; build" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; test" +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/typescript/default.yaml b/src/main/resources/templates/aeolus/typescript/default.yaml new file mode 100644 index 000000000000..de335d090617 --- /dev/null +++ b/src/main/resources/templates/aeolus/typescript/default.yaml @@ -0,0 +1,16 @@ +api: v0.0.1 +metadata: + name: TypeScript + description: Run tests using Jest +actions: + - name: install_dependencies + script: 'npm ci --prefer-offline --no-audit' + - name: build + script: 'npm run build' + - name: test + script: 'npm run test:ci' + runAlways: false + results: + - name: junit + path: 'junit.xml' + type: junit diff --git a/src/main/resources/templates/aeolus/vhdl/default.sh b/src/main/resources/templates/aeolus/vhdl/default.sh index f4f5a3f3f609..747c1b20c58f 100644 --- a/src/main/resources/templates/aeolus/vhdl/default.sh +++ b/src/main/resources/templates/aeolus/vhdl/default.sh @@ -9,16 +9,16 @@ provide_environment_information () { pip3 --version echo "--------------------Contents of tests repository--------------------" - ls -la tests + ls -la ${testWorkingDirectory} echo "---------------------------------------------" echo "--------------------Contents of assignment repository--------------------" - ls -la assignment + ls -la ${studentParentWorkingDirectoryName} echo "---------------------------------------------" #Fallback in case Docker does not work as intended - REQ_FILE=tests/requirements.txt + REQ_FILE=${testWorkingDirectory}/requirements.txt if [ -f "$REQ_FILE" ]; then - pip3 install --user -r tests/requirements.txt || true + pip3 install --user -r ${testWorkingDirectory}/requirements.txt || true else echo "$REQ_FILE does not exist" fi @@ -26,16 +26,16 @@ provide_environment_information () { prepare_makefile () { echo '⚙️ executing prepare_makefile' - rm -f assignment/{GNUmakefile, Makefile, makefile} - cp -f tests/Makefile assignment/Makefile || exit 2 + rm -f ${studentParentWorkingDirectoryName}/{GNUmakefile, Makefile, makefile} + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 } run_and_compile () { echo '⚙️ executing run_and_compile' - cd "tests" - python3 compileTest.py ../assignment/ + cd "${testWorkingDirectory}" + python3 compileTest.py ../${studentParentWorkingDirectoryName}/ rm compileTest.py - cp result.xml ../assignment/result.xml + cp result.xml ../${studentParentWorkingDirectoryName}/result.xml } junit () { diff --git a/src/main/resources/templates/aeolus/vhdl/default.yaml b/src/main/resources/templates/aeolus/vhdl/default.yaml index 872f94622916..64bcb4365fc5 100644 --- a/src/main/resources/templates/aeolus/vhdl/default.yaml +++ b/src/main/resources/templates/aeolus/vhdl/default.yaml @@ -8,37 +8,37 @@ actions: pip3 --version echo "--------------------Contents of tests repository--------------------" - ls -la tests + ls -la ${testWorkingDirectory} echo "---------------------------------------------" echo "--------------------Contents of assignment repository--------------------" - ls -la assignment + ls -la ${studentParentWorkingDirectoryName} echo "---------------------------------------------" #Fallback in case Docker does not work as intended - REQ_FILE=tests/requirements.txt + REQ_FILE=${testWorkingDirectory}/requirements.txt if [ -f "$REQ_FILE" ]; then - pip3 install --user -r tests/requirements.txt || true + pip3 install --user -r ${testWorkingDirectory}/requirements.txt || true else echo "$REQ_FILE does not exist" fi runAlways: false - name: prepare_makefile script: |- - rm -f assignment/{GNUmakefile, Makefile, makefile} - cp -f tests/Makefile assignment/Makefile || exit 2 + rm -f ${studentParentWorkingDirectoryName}/{GNUmakefile, Makefile, makefile} + cp -f ${testWorkingDirectory}/Makefile ${studentParentWorkingDirectoryName}/Makefile || exit 2 runAlways: false - name: run_and_compile script: |- - python3 compileTest.py ../assignment/ + python3 compileTest.py ../${studentParentWorkingDirectoryName}/ rm compileTest.py - cp result.xml ../assignment/result.xml - workdir: tests + cp result.xml ../${studentParentWorkingDirectoryName}/result.xml + workdir: ${testWorkingDirectory} runAlways: false - name: junit script: '#empty script action, just for the results' runAlways: true results: - name: assignment_junit_results - path: assignment/result.xml + path: ${studentParentWorkingDirectoryName}/result.xml type: junit before: true diff --git a/src/main/resources/templates/c_plus_plus/exercise/.clang-format b/src/main/resources/templates/c_plus_plus/exercise/.clang-format new file mode 100644 index 000000000000..541e6dca6c50 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/.clang-format @@ -0,0 +1,4 @@ +--- +Language: Cpp +BasedOnStyle: Google +IncludeBlocks: Preserve diff --git a/src/main/resources/templates/c_plus_plus/exercise/.gitattributes b/src/main/resources/templates/c_plus_plus/exercise/.gitattributes new file mode 100644 index 000000000000..0e4a9089ed6e --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/.gitattributes @@ -0,0 +1,39 @@ +# Source: https://github.com/gitattributes/gitattributes/blob/master/C%2B%2B.gitattributes (01.09.2024) + +# Sources +*.c text diff=cpp +*.cc text diff=cpp +*.cxx text diff=cpp +*.cpp text diff=cpp +*.cpi text diff=cpp +*.c++ text diff=cpp +*.hpp text diff=cpp +*.h text diff=cpp +*.h++ text diff=cpp +*.hh text diff=cpp + +# Compiled Object files +*.slo binary +*.lo binary +*.o binary +*.obj binary + +# Precompiled Headers +*.gch binary +*.pch binary + +# Compiled Dynamic libraries +*.so binary +*.dylib binary +*.dll binary + +# Compiled Static libraries +*.lai binary +*.la binary +*.a binary +*.lib binary + +# Executables +*.exe binary +*.out binary +*.app binary diff --git a/src/main/resources/templates/c_plus_plus/exercise/.gitignore b/src/main/resources/templates/c_plus_plus/exercise/.gitignore new file mode 100644 index 000000000000..62f60adbb914 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/.gitignore @@ -0,0 +1,4 @@ +cmake-build-*/ + +.vscode/ +.idea/ diff --git a/src/main/resources/templates/c_plus_plus/exercise/CMakeLists.txt b/src/main/resources/templates/c_plus_plus/exercise/CMakeLists.txt new file mode 100644 index 000000000000..1a01a44252aa --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.13) +project(ArtemisExercise) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(assignment src/sort.cpp) +target_include_directories(assignment PUBLIC include) + +add_executable(assignment_main src/main.cpp) +target_link_libraries(assignment_main assignment) diff --git a/src/main/resources/templates/c_plus_plus/exercise/include/sort.hpp b/src/main/resources/templates/c_plus_plus/exercise/include/sort.hpp new file mode 100644 index 000000000000..886fc4aeba92 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/include/sort.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +void selection_sort(std::vector::iterator begin, + std::vector::iterator end); + +void insertion_sort(std::vector::iterator begin, + std::vector::iterator end); + +void quicksort(std::vector::iterator begin, + std::vector::iterator end); + +void mergesort(std::vector::iterator begin, + std::vector::iterator end); + +void mergesort_inplace(std::vector::iterator begin, + std::vector::iterator end); + +void heapsort(std::vector::iterator begin, std::vector::iterator end); + +void heapsort_explicit(std::vector::iterator begin, + std::vector::iterator end); + +void bogosort(std::vector::iterator begin, std::vector::iterator end); diff --git a/src/main/resources/templates/c_plus_plus/exercise/src/main.cpp b/src/main/resources/templates/c_plus_plus/exercise/src/main.cpp new file mode 100644 index 000000000000..16b651caade9 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/src/main.cpp @@ -0,0 +1,5 @@ +#include "sort.hpp" + +int main() { + // Test your implementation here +} diff --git a/src/main/resources/templates/c_plus_plus/exercise/src/sort.cpp b/src/main/resources/templates/c_plus_plus/exercise/src/sort.cpp new file mode 100644 index 000000000000..5966af03e21f --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/exercise/src/sort.cpp @@ -0,0 +1,43 @@ +#include "sort.hpp" + +#include + +void selection_sort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void insertion_sort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void quicksort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void mergesort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void mergesort_inplace(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void heapsort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void heapsort_explicit(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} + +void bogosort(std::vector::iterator begin, + std::vector::iterator end) { + throw std::logic_error("not implemented"); +} diff --git a/src/main/resources/templates/c_plus_plus/readme b/src/main/resources/templates/c_plus_plus/readme new file mode 100644 index 000000000000..5aefda9d7606 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/readme @@ -0,0 +1,91 @@ +# Sorting using `` building blocks + +An array `v` is considered _sorted_ if each pair of neighboring elements fulfills `v[i] <= v[i + 1]`. +Sorting an array means rearranging the elements such that it is sorted. This also means that +sorting may not add or remove elements. This is usually achieved by swapping elements. + +1. [task][CMake runs correctly](TestConfigure) +2. [task][Your code compiles](CompileSort) + + +# Sorting Algorithms +[task][All algorithms sort correctly](TestCatch2(sort-test)) +1. [task][Selection Sort](sorting_algorithms/selection_sort,sorting_algorithms/all_elements_equal/selection_sort,sorting_algorithms/reverse-sorted_values/selection_sort,sorting_algorithms/single_values/selection_sort,sorting_algorithms/empty_input/selection_sort,sorting_algorithms/large_input/selection_sort) + Find the correct value for the next position, one position at a time. + + Implement the following function using suitable C++ standard library algorithms. + ```c++ + void selection_sort(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + +2. [task][Insertion Sort](sorting_algorithms/insertion_sort,sorting_algorithms/all_elements_equal/insertion_sort,sorting_algorithms/reverse-sorted_values/insertion_sort,sorting_algorithms/single_values/insertion_sort,sorting_algorithms/empty_input/insertion_sort,sorting_algorithms/large_input/insertion_sort) + Find the correct position in the sorted sequence for the next value, one value at a time. + + Implement the following function using suitable C++ standard library algorithms: + ```c++ + void insertion_sort(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + +3. [task][Quicksort](sorting_algorithms/quicksort,sorting_algorithms/all_elements_equal/quicksort,sorting_algorithms/reverse-sorted_values/quicksort,sorting_algorithms/single_values/quicksort,sorting_algorithms/empty_input/quicksort,sorting_algorithms/large_input/quicksort) + Quicksort chooses a single element (called _pivot_ `p`) from the input and partitions the + remaining elements into $$ \le p $$ and $$ \gt p $$, with the pivot placed between them. + + ```c++ + void quicksort(std::vector::iterator begin, + std::vector::iterator end) { /* .. */ } + ``` + + Don't forget to catch the base case (sorting 0 or 1 elements)! + +4. [task][Mergesort](sorting_algorithms/mergesort,sorting_algorithms/all_elements_equal/mergesort,sorting_algorithms/reverse-sorted_values/mergesort,sorting_algorithms/single_values/mergesort,sorting_algorithms/empty_input/mergesort,sorting_algorithms/large_input/mergesort) + Split the input into 2 equal-sized halves, call `mergesort` on them and then merge/interleave + the two sorted halves using an appropriate algorithm with linear time complexity. + + ```c++ + void mergesort(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + + Don't forget to catch the base case (sorting 0 or 1 elements)! + +5. [task][Mergesort Inplace](sorting_algorithms/mergesort_inplace,sorting_algorithms/all_elements_equal/mergesort_inplace,sorting_algorithms/reverse-sorted_values/mergesort_inplace,sorting_algorithms/single_values/mergesort_inplace,sorting_algorithms/empty_input/mergesort_inplace,sorting_algorithms/large_input/mergesort_inplace) + Split the input into 2 equal-sized halves, call `mergesort_inplace` on them and then merge/interleave + the two sorted halves using an appropriate algorithm without allocating additional memory. + + ```c++ + void mergesort_inplace(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + + Don't forget to catch the base case (sorting 0 or 1 elements)! + +6. [task][Heapsort](sorting_algorithms/heapsort,sorting_algorithms/all_elements_equal/heapsort,sorting_algorithms/reverse-sorted_values/heapsort,sorting_algorithms/single_values/heapsort,sorting_algorithms/empty_input/heapsort,sorting_algorithms/large_input/heapsort) + Construct a heap from the input. Then sort it with an appropriate algorithm. + + ```c++ + void heapsort(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + +7. [task][Explicit Heapsort](sorting_algorithms/heapsort_explicit,sorting_algorithms/all_elements_equal/heapsort_explicit,sorting_algorithms/reverse-sorted_values/heapsort_explicit,sorting_algorithms/single_values/heapsort_explicit,sorting_algorithms/empty_input/heapsort_explicit,sorting_algorithms/large_input/heapsort_explicit) + Implement Heapsort without using `sort_heap`. + You do not need to understand the details of the algorithm. You should only look at the + documentation for heap algorithms. For example, refer to + [cppreference.com: `sort_heap`](https://en.cppreference.com/w/cpp/algorithm/sort_heap) + and figure out which algorithms need to be called. + + ```c++ + void heapsort_explicit(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` + +8. [task][Bogosort](bogosort,bogosort/empty_input,bogosort/single_value) + As long as the vector is not sorted, randomly shuffle the entire vector. + Alternatively, you can deterministically try all permutations until the vector is sorted. + + ```c++ + void bogosort(std::vector::iterator begin, + std::vector::iterator end) { /* ... */ } + ``` diff --git a/src/main/resources/templates/c_plus_plus/solution/.clang-format b/src/main/resources/templates/c_plus_plus/solution/.clang-format new file mode 100644 index 000000000000..541e6dca6c50 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/.clang-format @@ -0,0 +1,4 @@ +--- +Language: Cpp +BasedOnStyle: Google +IncludeBlocks: Preserve diff --git a/src/main/resources/templates/c_plus_plus/solution/.gitattributes b/src/main/resources/templates/c_plus_plus/solution/.gitattributes new file mode 100644 index 000000000000..0e4a9089ed6e --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/.gitattributes @@ -0,0 +1,39 @@ +# Source: https://github.com/gitattributes/gitattributes/blob/master/C%2B%2B.gitattributes (01.09.2024) + +# Sources +*.c text diff=cpp +*.cc text diff=cpp +*.cxx text diff=cpp +*.cpp text diff=cpp +*.cpi text diff=cpp +*.c++ text diff=cpp +*.hpp text diff=cpp +*.h text diff=cpp +*.h++ text diff=cpp +*.hh text diff=cpp + +# Compiled Object files +*.slo binary +*.lo binary +*.o binary +*.obj binary + +# Precompiled Headers +*.gch binary +*.pch binary + +# Compiled Dynamic libraries +*.so binary +*.dylib binary +*.dll binary + +# Compiled Static libraries +*.lai binary +*.la binary +*.a binary +*.lib binary + +# Executables +*.exe binary +*.out binary +*.app binary diff --git a/src/main/resources/templates/c_plus_plus/solution/.gitignore b/src/main/resources/templates/c_plus_plus/solution/.gitignore new file mode 100644 index 000000000000..62f60adbb914 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/.gitignore @@ -0,0 +1,4 @@ +cmake-build-*/ + +.vscode/ +.idea/ diff --git a/src/main/resources/templates/c_plus_plus/solution/CMakeLists.txt b/src/main/resources/templates/c_plus_plus/solution/CMakeLists.txt new file mode 100644 index 000000000000..1a01a44252aa --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.13) +project(ArtemisExercise) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(assignment src/sort.cpp) +target_include_directories(assignment PUBLIC include) + +add_executable(assignment_main src/main.cpp) +target_link_libraries(assignment_main assignment) diff --git a/src/main/resources/templates/c_plus_plus/solution/include/sort.hpp b/src/main/resources/templates/c_plus_plus/solution/include/sort.hpp new file mode 100644 index 000000000000..886fc4aeba92 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/include/sort.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +void selection_sort(std::vector::iterator begin, + std::vector::iterator end); + +void insertion_sort(std::vector::iterator begin, + std::vector::iterator end); + +void quicksort(std::vector::iterator begin, + std::vector::iterator end); + +void mergesort(std::vector::iterator begin, + std::vector::iterator end); + +void mergesort_inplace(std::vector::iterator begin, + std::vector::iterator end); + +void heapsort(std::vector::iterator begin, std::vector::iterator end); + +void heapsort_explicit(std::vector::iterator begin, + std::vector::iterator end); + +void bogosort(std::vector::iterator begin, std::vector::iterator end); diff --git a/src/main/resources/templates/c_plus_plus/solution/src/main.cpp b/src/main/resources/templates/c_plus_plus/solution/src/main.cpp new file mode 100644 index 000000000000..16b651caade9 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/src/main.cpp @@ -0,0 +1,5 @@ +#include "sort.hpp" + +int main() { + // Test your implementation here +} diff --git a/src/main/resources/templates/c_plus_plus/solution/src/sort.cpp b/src/main/resources/templates/c_plus_plus/solution/src/sort.cpp new file mode 100644 index 000000000000..2e091fae5c4d --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/solution/src/sort.cpp @@ -0,0 +1,82 @@ +#include "sort.hpp" + +#include +#include +#include + +void selection_sort(std::vector::iterator begin, + std::vector::iterator end) { + for (auto it = begin; it != end; ++it) { + auto min = std::min_element(it, end); + // std::iter_swap(min, it); // unstable + std::rotate(it, min, min + 1); + } +} + +void insertion_sort(std::vector::iterator begin, + std::vector::iterator end) { + for (auto it = begin; it != end; ++it) { + auto insertion_pos = std::upper_bound(begin, it, *it); + std::rotate(insertion_pos, it, it + 1); + } +} + +void quicksort(std::vector::iterator begin, + std::vector::iterator end) { + if (end - begin <= 1) { + return; + } + auto pivot = *begin; + auto middle = + std::partition(begin + 1, end, [pivot](int i) { return i < pivot; }); + auto new_middle = std::rotate(begin, begin + 1, middle); + quicksort(begin, new_middle); + quicksort(new_middle + 1, end); +} + +void mergesort(std::vector::iterator begin, + std::vector::iterator end) { + auto length = end - begin; + if (length <= 1) { + return; + } + std::vector tmp(begin, end); + auto middle = tmp.begin() + length / 2; + mergesort(tmp.begin(), middle); + mergesort(middle, tmp.end()); + std::merge(tmp.begin(), middle, middle, tmp.end(), begin); +} + +void mergesort_inplace(std::vector::iterator begin, + std::vector::iterator end) { + auto length = end - begin; + if (length <= 1) { + return; + } + auto middle = begin + length / 2; + mergesort_inplace(begin, middle); + mergesort_inplace(middle, end); + std::inplace_merge(begin, middle, end); +} + +void heapsort(std::vector::iterator begin, + std::vector::iterator end) { + std::make_heap(begin, end); + std::sort_heap(begin, end); +} + +void heapsort_explicit(std::vector::iterator begin, + std::vector::iterator end) { + std::make_heap(begin, end); + while (end != begin) { + std::pop_heap(begin, end); + --end; + } +} + +void bogosort(std::vector::iterator begin, + std::vector::iterator end) { + while (!std::is_sorted(begin, end)) { + std::next_permutation(begin, end); + } +} diff --git a/src/main/resources/templates/c_plus_plus/test/.clang-format b/src/main/resources/templates/c_plus_plus/test/.clang-format new file mode 100644 index 000000000000..541e6dca6c50 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/.clang-format @@ -0,0 +1,4 @@ +--- +Language: Cpp +BasedOnStyle: Google +IncludeBlocks: Preserve diff --git a/src/main/resources/templates/c_plus_plus/test/.gitattributes b/src/main/resources/templates/c_plus_plus/test/.gitattributes new file mode 100644 index 000000000000..0e4a9089ed6e --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/.gitattributes @@ -0,0 +1,39 @@ +# Source: https://github.com/gitattributes/gitattributes/blob/master/C%2B%2B.gitattributes (01.09.2024) + +# Sources +*.c text diff=cpp +*.cc text diff=cpp +*.cxx text diff=cpp +*.cpp text diff=cpp +*.cpi text diff=cpp +*.c++ text diff=cpp +*.hpp text diff=cpp +*.h text diff=cpp +*.h++ text diff=cpp +*.hh text diff=cpp + +# Compiled Object files +*.slo binary +*.lo binary +*.o binary +*.obj binary + +# Precompiled Headers +*.gch binary +*.pch binary + +# Compiled Dynamic libraries +*.so binary +*.dylib binary +*.dll binary + +# Compiled Static libraries +*.lai binary +*.la binary +*.a binary +*.lib binary + +# Executables +*.exe binary +*.out binary +*.app binary diff --git a/src/main/resources/templates/c_plus_plus/test/.gitignore b/src/main/resources/templates/c_plus_plus/test/.gitignore new file mode 100644 index 000000000000..d779962e6971 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/.gitignore @@ -0,0 +1,10 @@ +/${studentParentWorkingDirectoryName}/ +/test-reports/ +/build/ + +cmake-build-*/ + +.vscode/ +.idea/ + +__pycache__/ diff --git a/src/main/resources/templates/c_plus_plus/test/CMakeLists.txt b/src/main/resources/templates/c_plus_plus/test/CMakeLists.txt new file mode 100644 index 000000000000..fe378768d813 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.13) +project(ArtemisTest) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(CTest) + +find_package(Catch2 3.0 REQUIRED) + +add_subdirectory("${studentParentWorkingDirectoryName}") + +add_executable(sort-test src/sort-test.cpp) +target_link_libraries(sort-test assignment Catch2::Catch2WithMain) +add_test(NAME sort-test COMMAND sort-test) diff --git a/src/main/resources/templates/c_plus_plus/test/Tests.py b/src/main/resources/templates/c_plus_plus/test/Tests.py new file mode 100755 index 000000000000..1c062fef90fe --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/Tests.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +from tests.TestCompile import TestCompile +from tests.TestConfigure import TestConfigure +from tests.TestCatch2 import TestCatch2 +from testUtils.Tester import Tester + + +def main() -> None: + # Create a new instance of the tester: + tester: Tester = Tester() + + buildDir = "./build" + + # Register all test cases: + # Configure: + testConfigure: TestConfigure = TestConfigure(".", buildDir, + ["-DCMAKE_BUILD_TYPE=Debug", + "-DCMAKE_CXX_FLAGS=-fsanitize=address", + "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address"]) + tester.addTest(testConfigure) + tester.addTest(TestCompile(buildDir, "sort-test", requirements=[testConfigure.name], name="CompileSort")) + tester.addTest(TestCatch2(buildDir, "sort-test", ["CompileSort"])) + + # Run the actual tests: + tester.run() + # Export the results into the JUnit XML format: + tester.exportResult("./test-reports/tests-results.xml") + + +if __name__ == "__main__": + main() diff --git a/src/main/resources/templates/c_plus_plus/test/src/sort-test.cpp b/src/main/resources/templates/c_plus_plus/test/src/sort-test.cpp new file mode 100644 index 000000000000..6216ce9aa745 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/src/sort-test.cpp @@ -0,0 +1,122 @@ +#include "sort.hpp" + +#include +#include +#include +#include + +#include + +void run_all_algorithms(std::vector& values, + const std::vector& expected) { + SECTION("selection_sort") { + selection_sort(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("insertion_sort") { + insertion_sort(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("quicksort") { + quicksort(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("mergesort") { + mergesort(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("mergesort_inplace") { + mergesort_inplace(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("heapsort") { + heapsort(values.begin(), values.end()); + REQUIRE(values == expected); + } + SECTION("heapsort_explicit") { + heapsort_explicit(values.begin(), values.end()); + REQUIRE(values == expected); + } +} + +TEST_CASE("sorting_algorithms") { + std::vector values{6, 2, 4, 2, 1, 7, 0, 2, 3, 4, 8}; + std::vector expected{0, 1, 2, 2, 2, 3, 4, 4, 6, 7, 8}; + + run_all_algorithms(values, expected); +} + +TEST_CASE("sorting_algorithms/all_elements_equal") { + std::vector values(20, 1); + auto expected = values; + + // just to make sure your code doesn't crash on repeated values + run_all_algorithms(values, expected); +} + +TEST_CASE("sorting_algorithms/reverse-sorted_values") { + std::vector values(20, 1); + std::iota(values.begin(), values.end(), 0); + auto expected = values; + std::reverse(values.begin(), values.end()); + + run_all_algorithms(values, expected); +} + +TEST_CASE("sorting_algorithms/single_values") { + std::vector values{4}; + std::vector expected{4}; + + // just to make sure your code doesn't crash on single values + run_all_algorithms(values, expected); +} + +TEST_CASE("sorting_algorithms/empty_input") { + std::vector values; + std::vector expected; + + // just to make sure your code doesn't crash on empty inputs + run_all_algorithms(values, expected); +} + +TEST_CASE("sorting_algorithms/large_input") { + std::vector values; + std::uniform_int_distribution dist{0, 50}; + std::default_random_engine rng; // default seed + for (int i = 0; i < 100; ++i) { + values.push_back(dist(rng)); + } + auto expected = values; + std::sort(expected.begin(), expected.end()); + + run_all_algorithms(values, expected); +} + +TEST_CASE("bogosort") { + // bogosort only works for very small inputs, + // large inputs take forever + std::vector values{6, 2, 4, 2}; + std::vector expected{2, 2, 4, 6}; + + bogosort(values.begin(), values.end()); + + REQUIRE(values == expected); +} + +TEST_CASE("bogosort/empty_input") { + std::vector values{}; + std::vector expected{}; + + bogosort(values.begin(), values.end()); + + REQUIRE(values == expected); +} + +TEST_CASE("bogosort/single_value") { + std::vector values{3}; + std::vector expected{3}; + + bogosort(values.begin(), values.end()); + + REQUIRE(values == expected); +} diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractProgramTest.py b/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractProgramTest.py new file mode 100644 index 000000000000..5c080913c17c --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractProgramTest.py @@ -0,0 +1,42 @@ +from typing import List, Optional + +from testUtils.AbstractTest import AbstractTest +from testUtils.Utils import PWrap + + +class AbstractProgramTest(AbstractTest): + """ + A abstract test that every test executing an external program has to inherit from. + How to: + 1. Inherit from AbstractProgramTest + 2. Override the "_run()" method. + 3. Done + """ + + # Our process wrapper instance: + pWrap: Optional[PWrap] + # The location of the executable: + executionDirectory: str + # The name of the executable that should get executed: + executable: str + + def __init__(self, name: str, executionDirectory: str, executable: str, requirements: List[str] = None, timeoutSec: int = -1): + super(AbstractProgramTest, self).__init__(name, requirements, timeoutSec) + self.executionDirectory: str = executionDirectory + self.executable: str = executable + self.pWrap: Optional[PWrap] = None + + def _onTimeout(self): + self._terminateProgramm() + + def _onFailed(self): + self._terminateProgramm() + + def _terminateProgramm(self): + if self.pWrap: + if not self.pWrap.hasTerminated(): + self.pWrap.kill() + self.pWrap.cleanup() + + def _progTerminatedUnexpectedly(self): + self._failWith("Program terminated unexpectedly.") diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractTest.py b/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractTest.py new file mode 100644 index 000000000000..18040680de98 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/AbstractTest.py @@ -0,0 +1,269 @@ +from abc import ABC, abstractmethod +from contextlib import contextmanager, suppress +from datetime import datetime, timedelta +from os import makedirs, path +from signal import alarm, SIG_IGN, SIGALRM, signal +from traceback import print_exc +from typing import Dict, List, Optional, NoReturn +from xml.etree import ElementTree as Et + +from testUtils.junit.TestCase import Result, TestCase +from testUtils.junit.TestSuite import TestSuite +from testUtils.TestFailedError import TestFailedError +from testUtils.Utils import printTester, PWrap + + +# Timeout handler based on: https://www.jujens.eu/posts/en/2018/Jun/02/python-timeout-function/ +class AbstractTest(ABC): + """ + An abstract test that every test has to inherit from. + How to: + 1. Inherit from AbstractTest + 2. Override the "_run()" method. + 3. Override the "_onTimeout()" method. + 4. Override the "_onFailed()" method. + 5. Done + """ + + name: str + requirements: List[str] + timeoutSec: int + case: Optional[TestCase] + suite: Optional[TestSuite] + additionalSuites: List[Et.Element] + + def __init__(self, name: str, requirements: Optional[List[str]] = None, timeoutSec: int = -1) -> None: + """ + name: str + An unique test case name. + + requirements: List[str] + A list of test cases names that have to finish successfully for this test to run. + Usually an execution test should have the compile test as its requirement. + + timeoutSec: int + The test case timeout in seconds, + """ + + self.name = name + self.timeoutSec = timeoutSec + self.requirements = [] if requirements is None else requirements + + self.case: Optional[TestCase] = None + self.suite: Optional[TestSuite] = None + + def start(self, testResults: Dict[str, Result], suite: TestSuite, additionalSuites: List[TestSuite]) -> None: + """ + Starts the test run. + + --- + + testResults: Dict[str, Result] + All test results up to this point. + + suite: TestSuite + The test suite where this test should get added to. + """ + + self.suite = suite + self.additionalSuites = additionalSuites + self.case = TestCase(self.name) + + # Check if all test requirements (other tests) are fulfilled: + if not self.__checkTestRequirements(testResults): + printTester(f"Skipping test case '{self.name}' not all requirements ({self.requirements!s}) are fulfilled") + self.case.message = f"Test requires other test cases to succeed first ({self.requirements!s})" + self.case.result = Result.SKIPPED + self.case.stdout = "" + self.case.stderr = "" + self.case.time = timedelta() + self.suite.addCase(self.case) + return + + startTime: datetime = datetime.now() + + self._initOutputDirectory() + + if self.timeoutSec > 0: + # Run with timeout: + with self.__timeout(self.timeoutSec): + try: + self._run() + except TestFailedError: + printTester(f"'{self.name}' failed.") + except TimeoutError: + self._timeout() + except Exception as e: + self.__markAsFailed(f"'{self.name}' had an internal error. {e}.\nPlease report this to an instructor!") + print_exc() + self._onFailed() + else: + # Run without timeout: + try: + self._run() + except TestFailedError: + printTester(f"'{self.name}' failed.") + except Exception as e: + self.__markAsFailed(f"'{self.name}' had an internal error. {e}.\nPlease report this to an instructor!") + print_exc() + self._onFailed() + + self.case.time = datetime.now() - startTime + self.suite.addCase(self.case) + + def __checkTestRequirements(self, testResults: Dict[str, Result]) -> bool: + """ + Checks if all requirements (i.e. other test cases were successful) are fulfilled. + """ + + return all(testResults.get(req) == Result.SUCCESS for req in self.requirements) + + @contextmanager + def __timeout(self, timeoutSec: int): + # Register a function to raise a TimeoutError on the signal. + signal(SIGALRM, self.__raiseTimeout) + # Schedule the signal to be sent after ``time``. + alarm(timeoutSec) + + with suppress(TimeoutError): + yield + # Unregister the signal so it won't be triggered + # if the timeout is not reached. + signal(SIGALRM, SIG_IGN) + + def __raiseTimeout(self, _sigNum: int, _frame) -> NoReturn: + self._onTimeout() + raise TimeoutError + + def _failWith(self, msg: str) -> NoReturn: + """ + Marks the current test as failed with the given message. + Stores the complete stderr and stdout output from the run. + """ + + self.__markAsFailed(msg) + self._onFailed() + raise TestFailedError(f"{self.name} failed.") + + def __markAsFailed(self, msg: str) -> None: + """ + Marks the current test case as failed and loads all stdout and stderr. + """ + + self.case.message = msg + self.case.result = Result.FAILURE + self.case.stdout = self._loadFullStdout() + self.case.stderr = self._loadFullStderr() + printTester(f"Test {self.name} failed with: {msg}") + + def _timeout(self, msg: str = "") -> None: + """ + Marks the current test as failed with the given optional message. + Stores the complete stderr and stdout output from the run. + Should be called once a test timeout occurred. + """ + + if msg: + self.__markAsFailed(f"timeout ({msg})") + else: + self.__markAsFailed("timeout") + + def __loadFileContent(self, filePath: str) -> str: + """ + Returns the content of a file specified by filePath as string. + """ + if path.exists(filePath) and path.isfile(filePath): + with open(filePath, "r") as file: + content: str = file.read() + return content + return "" + + def _loadFullStdout(self) -> str: + """ + Returns the stdout output of the executable. + """ + filePath: str = self._getStdoutFilePath() + return self.__loadFileContent(filePath) + + def _loadFullStderr(self) -> str: + """ + Returns the stderr output of the executable. + """ + + filePath: str = self._getStderrFilePath() + return self.__loadFileContent(filePath) + + def _initOutputDirectory(self) -> None: + """ + Prepares the output directory for the stderr and stdout files. + """ + outDir: str = self._getOutputPath() + if path.exists(outDir) and path.isdir(outDir): + return + makedirs(outDir) + + def _getOutputPath(self) -> str: + """ + Returns the output path for temporary stuff like the stderr and stdout files. + """ + + return path.join("/tmp", self.suite.name, self.name) + + def _getStdoutFilePath(self) -> str: + """ + Returns the path of the stdout cache file. + """ + + return path.join(self._getOutputPath(), "stdout.txt") + + def _getStderrFilePath(self) -> str: + """ + Returns the path of the stderr cache file. + """ + + return path.join(self._getOutputPath(), "stderr.txt") + + def _createPWrap(self, cmd: List[str], cwd: Optional[str] = None) -> PWrap: + """ + Creates a new PWrap instance from the given command. + """ + + return PWrap(cmd, self._getStdoutFilePath(), self._getStderrFilePath(), cwd=cwd) + + def _startPWrap(self, pWrap: PWrap) -> None: + """ + Starts the PWrap execution. + Handles FileNotFoundError if, for example, the executable was not found or does not exist. + """ + + try: + pWrap.start() + except FileNotFoundError as fe: + printTester(str(fe)) + self._failWith("File not found for execution. Did compiling fail?") + except NotADirectoryError as de: + printTester(str(de)) + self._failWith(f"Directory '{pWrap.cwd}' does not exist.") + except PermissionError as pe: + printTester(str(pe)) + self._failWith("Missing file execution permission. Make sure it has execute rights (chmod +x ).") + + @abstractmethod + def _run(self): + """ + Implement your test run here. + """ + + @abstractmethod + def _onTimeout(self): + """ + Called once a timeout occurs. + Should cancel all outstanding actions and free all resources. + """ + + @abstractmethod + def _onFailed(self): + """ + Called once the test failed via "_failWith(msg: str)". + Should cancel all outstanding actions and free all allocated resources. + """ diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/TestFailedError.py b/src/main/resources/templates/c_plus_plus/test/testUtils/TestFailedError.py new file mode 100644 index 000000000000..00ac65a80ce8 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/TestFailedError.py @@ -0,0 +1,6 @@ +class TestFailedError(Exception): + """ + Raised when a test failed. + """ + + pass diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/Tester.py b/src/main/resources/templates/c_plus_plus/test/testUtils/Tester.py new file mode 100644 index 000000000000..eae56143c44e --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/Tester.py @@ -0,0 +1,81 @@ +from typing import Dict, List + +from testUtils.AbstractTest import AbstractTest +from testUtils.junit.Junit import Junit +from testUtils.junit.TestCase import Result +from testUtils.junit.TestSuite import TestSuite +from xml.etree import ElementTree as Et +from testUtils.Utils import clearTesterOutputCache, getTesterOutput, printTester, resetStdoutLimit, setStdoutLimitEnabled + + +class Tester: + name: str + suite: TestSuite + additionalSuites: List[Et.Element] + tests: Dict[str, AbstractTest] + + def __init__(self, name: str = "GBS-Tester-1.36") -> None: + self.name = name + self.suite = TestSuite(name) + self.additionalSuites = [] + self.tests = {} + + def run(self) -> None: + """ + Starts the tester and runs all tests added via "addTest(test: AbstractTest)". + """ + + setStdoutLimitEnabled(False) + printTester(f"Running: {self.name}") + + # A dictionary of test results: + # Test name -> result + testResults: Dict[str, Result] = {} + + for name, test in self.tests.items(): + if test.timeoutSec >= 0: + printTester(f"Running test case '{name}' with a {test.timeoutSec} second timeout...") + else: + printTester(f"Running test case '{name}' with no timeout...") + + # Reset the tester output cache: + resetStdoutLimit() + setStdoutLimitEnabled(True) + clearTesterOutputCache() + + test.start(testResults, self.suite, self.additionalSuites) + + setStdoutLimitEnabled(False) + printTester(f"Finished test case '{name}' in {test.case.time.total_seconds()} seconds.") + + # Store the tester output in the test case: + test.case.testerOutput = self.name + "\n" + getTesterOutput() + # Update test results: + testResults[name] = test.case.result + self.__printResult() + + def addTest(self, test: AbstractTest) -> None: + """ + Adds a new test that will be run once "run()" is invoked. + """ + + if test.name in self.tests: + raise ValueError(f"Test '{test.name}' already registered. Test names should be unique!") + self.tests[test.name] = test + + def __printResult(self) -> None: + print("Result".center(50, "=")) + print(f"{self.name} finished {len(self.tests)} test cases in {self.suite.time.total_seconds()} seconds.") + print(f"SUCCESS: {self.suite.successful}") + print(f"FAILED: {self.suite.failures}") + print(f"ERROR: {self.suite.errors}") + print(f"SKIPPED: {self.suite.skipped}") + print("".center(50, "=")) + + def exportResult(self, outputPath: str) -> None: + """ + Exports the test results into a JUnit format and stores it at the given outputPath. + """ + + junit: Junit = Junit(self.suite, self.additionalSuites) + junit.toXml(outputPath) diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/Utils.py b/src/main/resources/templates/c_plus_plus/test/testUtils/Utils.py new file mode 100644 index 000000000000..25c28bb4553e --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/Utils.py @@ -0,0 +1,532 @@ +import os +import select +import signal +from datetime import datetime +from io import TextIOWrapper +from pty import openpty +from pwd import getpwnam, struct_passwd +from subprocess import Popen +from termios import ONLCR, tcgetattr, TCSANOW, tcsetattr +from threading import Thread +from time import sleep +from typing import Any, Dict, List, Optional + + +def studSaveStrComp(ref: str, other: str, strip: bool = True, ignoreCase: bool = True, ignoreNonAlNum=True): + """ + Student save compare between strings. + Converts both to lower, strips them and removes all non alphanumeric chars + before comparison. + """ + # Strip: + if strip: + ref = ref.strip() + other = other.strip() + + # Convert to lower + if ignoreCase: + ref = ref.lower() + other = other.lower() + + # Remove all non alphanumeric chars: + if ignoreNonAlNum: + ref = "".join(c for c in ref if c.isalnum()) + other = "".join(c for c in other if c.isalnum()) + + # print("Ref: {}\nOther:{}".format(ref, other)) + return ref == other + + +def recursive_chmod(path: str, mode: int): + """ + Recursively changes file permissions. + """ + os.chmod(path, mode) + # print("CHMOD: {}".format(path)) + f: str + for f in os.listdir(path): + f = os.path.join(path, f) + if os.path.isdir(f): + recursive_chmod(f, mode) + else: + os.chmod(f, mode) + # print("CHMOD: {}".format(f)) + + +# Limit for stdout in chars. +# Should prevent to much output on artemis if for example there is a loop in a tree. +# By default the stdout limit is disabled: +__stdoutLimitEnabled: bool = False + + +def resetStdoutLimit(limit: int = 15000): + """ + Resets the stout limit to the given limit (default = 15.000 chars). + """ + global stdoutCharsLeft # Required since we want to modify stdoutCharsLeft + stdoutCharsLeft = limit + + +def setStdoutLimitEnabled(enabled: bool): + """ + Enables or disables the stdout limit. + Does not restet the chars left! + """ + global __stdoutLimitEnabled + __stdoutLimitEnabled = enabled + + +def __printStdout(text: str): + """ + Prints the given text to stdout. + Only if there are still enough chars in stdoutCharsLeft left. + Else will not print anything. + """ + global stdoutCharsLeft # Required since we want to modify stdoutCharsLeft + + if not __stdoutLimitEnabled: + print(text) + elif stdoutCharsLeft > 0: + if stdoutCharsLeft >= len(text): + print(text) + else: + print(text[:stdoutCharsLeft] + "...") + stdoutCharsLeft -= len(text) + if stdoutCharsLeft <= 0: + print("[STDOUT LIMIT REACHED]".center(50, "=")) + + +# A cache of all that the tester has been writing to stdout: +testerOutputCache: List[str] = list() + + +def clearTesterOutputCache(): + """ + Clears the testerOutputCache. + """ + testerOutputCache.clear() + + +def getTesterOutput(): + """ + Returns the complete tester output as a single string. + """ + return "\n".join(testerOutputCache) + + +startTime: datetime = datetime.now() + + +def __getCurSeconds(): + """ + Returns the total seconds passed, since the tester started as a string with a precision of two digits. + """ + seconds: float = (datetime.now() - startTime).total_seconds() + return str(round(seconds, 2)) + + +def __getCurDateTimeStr(): + """ + Returns the current date and time string (e.g. 11.10.2019_17:02:33) + """ + return datetime.now().strftime("%d.%m.%Y_%H:%M:%S") + + +def printTester(text: str, addToCache: bool = True): + """ + Prints the given string with the '[T]: ' tag in front. + Should be used instead of print() to make it easier for students + to determine what came from the tester and what from their program. + """ + msg: str = f"[{__getCurSeconds()}][T]: {text}" + __printStdout(msg) + if addToCache: + testerOutputCache.append(msg) + + +def printProg(text: str, addToCache: bool = True): + """ + Prints the given string with the '[P]: ' tag in front. + Should be used instead of print() to make it easier for students + to determine what came from the tester and what from their program. + """ + msg: str = f"[{__getCurSeconds()}][P]: {text.rstrip()}" + __printStdout(msg) + if addToCache: + testerOutputCache.append(msg) + + +def shortenText(text: str, maxNumChars: int): + """ + Shortens the given text to a maximum number of chars. + If there are more chars than specified in maxNumChars, + it will append: "\n[And {} chars more...]". + """ + + if len(text) > maxNumChars: + s: str = f"\n[And {len(text) - maxNumChars} chars more...]" + l: int = maxNumChars - len(s) + if l > 0: + return f"{text[:l]}{s}" + else: + printTester(f"Unable to limit output to {maxNumChars} chars! Not enough space.", False) + return "" + return text + + +class ReadCache(Thread): + """ + Helper class that makes sure we only get one line (separated by '\n') + if we read multiple lines at once. + """ + + __cacheList: List[str] + __cacheFile: TextIOWrapper + + __outFd: int + __outSlaveFd: int + + def __init__(self, filePath: str): + Thread.__init__(self) + self.__cacheList = [] + self.__cacheFile = open(filePath, "w") + + # Emulate a terminal: + self.__outFd, self.__outSlaveFd = openpty() + + self.start() + + def fileno(self): + return self.__outFd + + def join(self, timeout: float = None): + try: + os.close(self.__outFd) + except OSError as e: + printTester(f"Closing stdout FD failed with: {e}") + try: + os.close(self.__outSlaveFd) + except OSError as e: + printTester(f"Closing stdout slave FD failed with: {e}") + Thread.join(self, timeout) + + @staticmethod + def __isFdValid(fd: int): + try: + os.stat(fd) + except OSError: + return False + return True + + @staticmethod + def __decode(data: bytes): + """ + Tries to decode the given string as UTF8. + In case this fails, it will fall back to ASCII encoding. + Returns the decoded result. + + --- + + data: bytes + The data that should be decoded. + """ + try: + return data.decode("utf8", "replace") + except UnicodeDecodeError as e: + printTester(f"Failed to decode line as utf8. Using ascii ecoding - {e}") + return data.decode("ascii", "replace") + + def run(self): + pollObj = select.poll() + pollObj.register(self.__outSlaveFd, select.POLLIN) + while self.__isFdValid(self.__outSlaveFd): + try: + for fd, mask in pollObj.poll(100): + if fd != self.__outSlaveFd: + continue + if mask & (select.POLLHUP | select.POLLERR | select.POLLNVAL): + return + if mask & select.POLLIN: + data: bytes = os.read(self.__outSlaveFd, 4096) + dataStr: str = self.__decode(data) + try: + self.__cacheFile.write(dataStr) + except UnicodeEncodeError: + printTester("Invalid ASCII character read. Skipping line...") + continue + self.__cacheFile.flush() + self.__cache(dataStr) + printProg(dataStr) + except OSError: + break + + def canReadLine(self): + return len(self.__cacheList) > 0 + + def __cache(self, data: str): + self.__cacheList.extend(data.splitlines(True)) + + def readLine(self): + if self.canReadLine(): + return self.__cacheList.pop(0) + return "" + + +class PWrap: + """ + A wrapper for "Popen". + """ + + cmd: List[str] + prog: Optional[Popen] + cwd: str + + __stdinFd: int + __stdinMasterFd: int + + __stdOutLineCache: ReadCache + __stdErrLineCache: ReadCache + + __terminatedTime: Optional[datetime] + + def __init__(self, cmd: List[str], stdoutFilePath: str = "/tmp/stdout.txt", stderrFilePath: str = "/tmp/stderr.txt", cwd: Optional[str] = None): + self.cmd = cmd + self.prog = None + self.cwd: str = os.getcwd() if cwd is None else cwd + self.stdout = open(stdoutFilePath, "wb") + self.stderr = open(stderrFilePath, "wb") + + self.__stdOutLineCache = ReadCache(stdoutFilePath) + self.__stdErrLineCache = ReadCache(stderrFilePath) + + self.__terminatedTime = None + + def __del__(self): + try: + os.close(self.__stdinFd) + except OSError as e: + printTester(f"Closing stdin FD failed with: {e}") + except AttributeError: + pass + try: + os.close(self.__stdinMasterFd) + except OSError as e: + printTester(f"Closing stdin master FD failed with: {e}") + except AttributeError: + pass + + def start(self, userName: Optional[str] = None): + """ + Starts the process and sets all file descriptors to nonblocking. + + --- + + userName: Optional[str] = None + In case the userName is not None, the process will be executed as the given userName. + This requires root privileges and you have to ensure the user has the required rights to access all resources (files). + """ + # Emulate a terminal for stdin: + self.__stdinMasterFd, self.__stdinFd = openpty() + + # Transform "\r\n" to '\n' for data send to stdin: + tsettings: List[Any] = tcgetattr(self.__stdinFd) + tsettings[1] &= ~ONLCR + tcsetattr(self.__stdinFd, TCSANOW, tsettings) + + if userName is not None: + # Check for root privileges: + self.__checkForRootPrivileges() + + # Prepare environment: + pwRecord: struct_passwd = getpwnam(userName) + env: Dict[str, str] = os.environ.copy() + env["HOME"] = pwRecord.pw_dir + env["LOGNAME"] = pwRecord.pw_name + env["USER"] = pwRecord.pw_name + env["PWD"] = self.cwd + printTester(f"Starting process as: {pwRecord.pw_name}") + + # Start the actual process: + self.prog = Popen( + self.cmd, + stdout=self.__stdOutLineCache.fileno(), + stdin=self.__stdinMasterFd, + stderr=self.__stdErrLineCache.fileno(), + universal_newlines=True, + cwd=self.cwd, + env=env, + preexec_fn=self.__demote(pwRecord.pw_uid, pwRecord.pw_gid, pwRecord.pw_name), + ) + else: + # Start the actual process: + self.prog = Popen( + self.cmd, + stdout=self.__stdOutLineCache.fileno(), + stdin=self.__stdinMasterFd, + stderr=self.__stdErrLineCache.fileno(), + universal_newlines=True, + cwd=self.cwd, + preexec_fn=os.setsid, + ) # Make sure we store the process group id + + def __demote(self, userUid: int, userGid: int, userName: str): + """ + Returns a call, demoting the calling process to the given user, UID and GID. + """ + + def result(): + # self.__printIds("Starting demotion...") # Will print inside the new process and reports via the __stdOutLineCache + os.initgroups(userName, userGid) + os.setuid(userUid) + # self.__printIds("Finished demotion.") # Will print inside the new process and reports via the __stdOutLineCache + + return result + + @staticmethod + def __checkForRootPrivileges(): + """ + Checks if the current process has root permissions. + Fails if not. + """ + if os.geteuid() != 0: + raise PermissionError("The tester has to be executed as root to be able to switch users!") + + def __printIds(self, msg: str): + printTester(f"uid, gid = {os.getuid()}, {os.getgid()}; {msg}") + + def __readLine(self, lineCache: ReadCache, blocking: bool): + """ + Reads a single line from the given ReadCache and returns it. + + --- + + blocking: + When set to True will only return if the process terminated or we read a non empty string. + """ + while blocking: + if not lineCache.canReadLine(): + if not self.hasTerminated(): + sleep(0.1) + else: + break + else: + line: str = lineCache.readLine() + return line + return "" + + def readLineStdout(self, blocking: bool = True): + """ + Reads a single line from the processes stdout and returns it. + + --- + + blocking: + When set to True will only return if the process terminated or we read a non empty string. + """ + return self.__readLine(self.__stdOutLineCache, blocking) + + def canReadLineStdout(self): + """ + Returns whether there is a line from the processes stdout that can be read. + """ + return self.__stdOutLineCache.canReadLine() + + def readLineStderr(self, blocking: bool = True): + """ + Reads a single line from the processes stderr and returns it. + + --- + + blocking: + When set to True will only return if the process terminated or we read a non empty string. + """ + return self.__readLine(self.__stdErrLineCache, blocking) + + def canReadLineStderr(self): + """ + Returns whether there is a line from the processes stderr that can be read. + """ + return self.__stdErrLineCache.canReadLine() + + def writeStdin(self, data: str): + """ + Writes the given data string to the processes stdin. + """ + os.write(self.__stdinFd, data.encode()) + printTester(f"Wrote: {data}") + + def hasTerminated(self): + """ + Returns whether the process has terminated. + """ + if self.prog is None: + return True + + # Make sure we wait 1.0 seconds after the process has terminated to + # make sure all the output arrived: + elif self.prog.poll() is not None: + if self.__terminatedTime: + if (datetime.now() - self.__terminatedTime).total_seconds() > 1.0: + return True + else: + self.__terminatedTime = datetime.now() + return False + + def getReturnCode(self): + """ + Returns the returncode of the terminated process else None. + """ + return self.prog.returncode + + def waitUntilTerminationReading(self, secs: float = -1): + """ + Waits until termination of the process and tries to read until either + the process terminated or the timeout occurred. + + Returns True if the process terminated before the timeout occurred, + else False. + + --- + + secs: + The timeout in seconds. Values < 0 result in infinity. + """ + start: datetime = datetime.now() + while True: + if self.hasTerminated(): + return True + elif 0 <= secs <= (datetime.now() - start).total_seconds(): + return False + self.readLineStdout(False) + sleep(0.1) + + def kill(self, signal: int = signal.SIGKILL): + """ + Sends the given signal to the complete process group started by the process. + + Returns True if the process existed and had to be killed. Else False. + + --- + + signal: + The signal that should be sent to the process group started by the process. + """ + # Send a signal to the complete process group: + try: + os.killpg(os.getpgid(self.prog.pid), signal) + return True + except ProcessLookupError: + printTester("No need to kill process. Process does not exist any more.") + return False + + def cleanup(self): + """ + Should be called once the execution has terminated. + Will join the stdout and stderr reader threads. + """ + + self.__stdOutLineCache.join() + self.__stdErrLineCache.join() + + def getPID(self): + return self.prog.pid diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/junit/Junit.py b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/Junit.py new file mode 100644 index 000000000000..12aaa288889b --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/Junit.py @@ -0,0 +1,33 @@ +from os import chmod, makedirs, path +from typing import Tuple, List +from xml.etree import ElementTree as Et + +from testUtils.junit.TestSuite import TestSuite + + +# JUnit format: https://github.com/junit-team/junit5/blob/master/platform-tests/src/test/resources/jenkins-junit.xsd +class Junit: + suite: TestSuite + additionalSuites: List[Et.Element] + + def __init__(self, suite: TestSuite, additionalSuites: List[Et.Element]) -> None: + self.suite = suite + self.additionalSuites = additionalSuites + + def toXml(self, outputPath: str) -> None: + suiteXml: Et.Element = self.suite.toXml() + root: Et.Element = Et.Element("testsuites") + root.append(suiteXml) + root.extend(self.additionalSuites) + tree: Et.ElementTree = Et.ElementTree(root) + self.createOutputPath(outputPath) + tree.write(outputPath, xml_declaration=True) + # Ensure nobody can edit our results: + chmod(outputPath, 0o644) + + @staticmethod + def createOutputPath(outputPath: str) -> None: + paths: Tuple[str, str] = path.split(outputPath) + if paths[0] and not path.exists(paths[0]): + # Prevent others from writing in this folder: + makedirs(paths[0], mode=0o755) diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestCase.py b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestCase.py new file mode 100644 index 000000000000..8bb7e1392bc7 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestCase.py @@ -0,0 +1,75 @@ +from datetime import timedelta +from enum import Enum +from xml.etree import ElementTree as Et + +from testUtils.Utils import shortenText + + +class Result(Enum): + SKIPPED = "skipped" + ERROR = "error" + FAILURE = "failure" + SUCCESS = "success" + + +class TestCase: + stdout: str + stderr: str + testerOutput: str + + name: str + time: timedelta + result: Result + message: str + + def __init__(self, name: str) -> None: + self.name = name + + self.stdout: str = "" + self.stderr: str = "" + self.testerOutput: str = "" + self.time: timedelta = timedelta() + self.result: Result = Result.SUCCESS + self.message: str = "" + + def toXml(self, suite: Et.Element, maxCharsPerOutput: int = 2500) -> None: + case: Et.Element = Et.SubElement(suite, "testcase") + case.set("name", self.name) + case.set("time", str(self.time.total_seconds())) + + if self.result != Result.SUCCESS: + result: Et.Element = Et.SubElement(case, self.result.value) + result.set("message", self.message) + result.text = self.genErrFailureMessage() + + if self.stdout: + stdout: Et.Element = Et.SubElement(case, "system-out") + stdout.text = shortenText(self.stdout, maxCharsPerOutput) + "\n" + if self.stderr: + stderr: Et.Element = Et.SubElement(case, "system-err") + stderr.text = shortenText(self.stderr, maxCharsPerOutput) + "\n" + + def genErrFailureMessage(self, maxChars: int = 5000) -> str: + oneThird: int = maxChars // 3 + + # Limit the stderr output to one third of the available chars: + stderrMsg: str = "\n" + "stderr".center(50, "=") + "\n" + if self.stderr: + stderrMsg += shortenText(self.stderr, oneThird) + "\n" + else: + stderrMsg += "No output on stderr found!\n" + + # Limit the stdout output to one third + the unused chars from the stderr output: + stdoutMsg: str = "\n" + "stdout".center(50, "=") + "\n" + if self.stdout: + stdoutMsg += shortenText(self.stdout, oneThird + (oneThird - len(stderrMsg))) + "\n" + else: + stdoutMsg += "No output on stdout found!\n" + + # Limit the tester output to one third + the left overs from stderr and stdout: + testerMsg: str = "\n" + "Tester".center(50, "=") + "\n" + if self.testerOutput: + testerMsg += shortenText(self.testerOutput, maxChars - len(testerMsg) - len(stderrMsg) - len(stdoutMsg)) + "\n" + else: + testerMsg += "No tester output found!\n" + return self.message + stdoutMsg + stderrMsg + testerMsg diff --git a/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestSuite.py b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestSuite.py new file mode 100644 index 000000000000..0acc513c744b --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/testUtils/junit/TestSuite.py @@ -0,0 +1,55 @@ +from datetime import timedelta +from typing import Dict +from xml.etree import ElementTree as Et + +from testUtils.junit.TestCase import Result, TestCase + + +class TestSuite: + __cases: Dict[str, TestCase] + + name: str + tests: int + failures: int + errors: int + skipped: int + successful: int + time: timedelta + + def __init__(self, name: str): + self.name = name + + self.__cases: Dict[str, TestCase] = dict() + self.tests: int = 0 + self.failures: int = 0 + self.errors: int = 0 + self.skipped: int = 0 + self.successful: int = 0 + self.time: timedelta = timedelta() + + def addCase(self, case: TestCase): + self.__cases[case.name] = case + self.tests += 1 + self.time += case.time + + if case.result == Result.ERROR: + self.errors += 1 + elif case.result == Result.FAILURE: + self.failures += 1 + elif case.result == Result.SKIPPED: + self.skipped += 1 + else: + self.successful += 1 + + def toXml(self): + suite: Et.Element = Et.Element("testsuite") + suite.set("name", self.name) + suite.set("tests", str(self.tests)) + suite.set("failures", str(self.failures)) + suite.set("errors", str(self.errors)) + suite.set("skipped", str(self.skipped)) + suite.set("time", str(self.time.total_seconds())) + + for _name, case in self.__cases.items(): + case.toXml(suite) + return suite diff --git a/src/main/resources/templates/c_plus_plus/test/tests/TestCatch2.py b/src/main/resources/templates/c_plus_plus/test/tests/TestCatch2.py new file mode 100644 index 000000000000..4b2de70d2564 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/tests/TestCatch2.py @@ -0,0 +1,36 @@ +from os.path import join +from typing import List +from xml.etree import ElementTree as Et + +from testUtils.AbstractProgramTest import AbstractProgramTest +from testUtils.Utils import printTester + + +class TestCatch2(AbstractProgramTest): + def __init__(self, location: str, executable: str, requirements: List[str] | None = None, name: str | None = None) -> None: + super().__init__(name or f"TestCatch2({executable})", location, executable, requirements, timeoutSec=10) + + def _run(self) -> None: + # Start the program: + outputFilename = f"result-{self.executable}.xml" + self.pWrap = self._createPWrap([join(".", self.executable), "--success", "--reporter", f"JUnit::out={outputFilename}", "--reporter", "console::out=-::colour-mode=none"], self.executionDirectory) + self._startPWrap(self.pWrap) + self.pWrap.waitUntilTerminationReading() + + retCode: int = self.pWrap.getReturnCode() + # parse XML output and append it to the results + try: + catchXmlRoot: Et = Et.parse(join(self.executionDirectory, outputFilename)) + catchXmlSuite: Et.Element = catchXmlRoot.find("testsuite") + self.additionalSuites.append(catchXmlSuite) + printTester(f"Appended {catchXmlSuite}") + except Exception as e: + printTester(f"Exception {e}") + + if retCode != 0: + self._failWith( + f"Test for {self.executable} failed." + ) + + # Always cleanup to make sure all threads get joined: + self.pWrap.cleanup() diff --git a/src/main/resources/templates/c_plus_plus/test/tests/TestCompile.py b/src/main/resources/templates/c_plus_plus/test/tests/TestCompile.py new file mode 100644 index 000000000000..612380af0872 --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/tests/TestCompile.py @@ -0,0 +1,40 @@ +from typing import List + +from testUtils.AbstractProgramTest import AbstractProgramTest + + +class TestCompile(AbstractProgramTest): + """ + Test case that tries to compile the given program with any compiler optimization disabled. + Most compiler warnings are enabled but aren't treated as errors. + """ + + target: str + + def __init__( + self, + buildDir: str, + target: str = "all", + requirements: List[str] | None = None, + name: str = "TestCompile", + ) -> None: + super().__init__( + name, buildDir, "cmake", requirements, timeoutSec=10 + ) + self.target = target + + def _run(self) -> None: + # Build all targets: + self.pWrap = self._createPWrap([self.executable, "--build", self.executionDirectory, "--target", self.target]) + self._startPWrap(self.pWrap) + + self.pWrap.waitUntilTerminationReading() + + retCode: int = self.pWrap.getReturnCode() + if retCode != 0: + self._failWith( + f"Build for directory {self.executionDirectory} failed. Returncode is {retCode}." + ) + + # Always cleanup to make sure all threads get joined: + self.pWrap.cleanup() diff --git a/src/main/resources/templates/c_plus_plus/test/tests/TestConfigure.py b/src/main/resources/templates/c_plus_plus/test/tests/TestConfigure.py new file mode 100644 index 000000000000..6a74ed566b0b --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/tests/TestConfigure.py @@ -0,0 +1,49 @@ +from typing import List +import shutil +import os.path + +from testUtils.AbstractProgramTest import AbstractProgramTest + + +class TestConfigure(AbstractProgramTest): + """ + Test case that runs CMake to configure the build + """ + + buildDir: str + extraFlags: List[str] + + def __init__( + self, + location: str, + buildDir: str, + extraFlags: List[str] | None = None, + requirements: List[str] | None = None, + name: str = "TestConfigure", + ) -> None: + super().__init__( + name, location, "cmake", requirements, timeoutSec=10 + ) + self.buildDir = buildDir + self.extraFlags = extraFlags or [] + + def _run(self) -> None: + if os.path.exists(self.buildDir): + shutil.rmtree(self.buildDir) + # Call CMake to configure the project: + self.pWrap = self._createPWrap( + [self.executable, "-S", self.executionDirectory, "-B", self.buildDir, + *self.extraFlags] + ) + self._startPWrap(self.pWrap) + + self.pWrap.waitUntilTerminationReading() + + retCode: int = self.pWrap.getReturnCode() + if retCode != 0: + self._failWith( + f"CMake for directory {self.executionDirectory} failed. Returncode is {retCode}." + ) + + # Always cleanup to make sure all threads get joined: + self.pWrap.cleanup() diff --git a/src/main/resources/templates/c_plus_plus/test/tests/TestOutput.py b/src/main/resources/templates/c_plus_plus/test/tests/TestOutput.py new file mode 100644 index 000000000000..dfbc60fe28fa --- /dev/null +++ b/src/main/resources/templates/c_plus_plus/test/tests/TestOutput.py @@ -0,0 +1,39 @@ +from os.path import join +from typing import List + +from testUtils.AbstractProgramTest import AbstractProgramTest +from testUtils.Utils import printTester, studSaveStrComp + + +class TestOutput(AbstractProgramTest): + def __init__(self, makefileLocation: str, requirements: List[str] = None, name: str = "TestOutput", executable: str = "helloWorld.out"): + super(TestOutput, self).__init__(name, makefileLocation, executable, requirements, timeoutSec=10) + + def _run(self): + # Start the program: + self.pWrap = self._createPWrap([join(".", self.executionDirectory, self.executable)]) + self._startPWrap(self.pWrap) + + # Wait for child being ready: + printTester("Waiting for: 'Hello world!'") + expected: str = "Hello world!" + while True: + if self.pWrap.hasTerminated() and not self.pWrap.canReadLineStdout(): + self._progTerminatedUnexpectedly() + # Read a single line form the program output: + line: str = self.pWrap.readLineStdout() + # Perform a "student save" compare: + if studSaveStrComp(expected, line): + break + else: + printTester(f"Expected '{expected}' but received read '{line}'") + + # Wait reading until the program terminates: + printTester("Waiting for the program to terminate...") + if not self.pWrap.waitUntilTerminationReading(3): + printTester("Program did not terminate - killing it!") + self.pWrap.kill() + self._failWith("Program did not terminate at the end.") + + # Always cleanup to make sure all threads get joined: + self.pWrap.cleanup() 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/resources/templates/haskell/test/.gitignore b/src/main/resources/templates/haskell/test/.gitignore index 38ce398dd22a..39312aeed502 100755 --- a/src/main/resources/templates/haskell/test/.gitignore +++ b/src/main/resources/templates/haskell/test/.gitignore @@ -3,10 +3,10 @@ test-reports/ # Subdirectories containing other repositories template/ -solution/ +${solutionWorkingDirectory}/ # Subdirectories with test submission -assignment/ +${studentParentWorkingDirectoryName}/ # Taken from https://github.com/github/gitignore diff --git a/src/main/resources/templates/haskell/test/readme.md b/src/main/resources/templates/haskell/test/readme.md index 7ab5dcefbf25..45ac2724c923 100644 --- a/src/main/resources/templates/haskell/test/readme.md +++ b/src/main/resources/templates/haskell/test/readme.md @@ -6,8 +6,8 @@ Tests are run using [stack](https://docs.haskellstack.org/en/stable/README/) in ## Setup -The executables specified in `test.cabal` expect the solution repository checked out in the `solution` subdirectory and -the submission checked out in the `assignment` subdirectory. +The executables specified in `test.cabal` expect the solution repository checked out in the `${solutionWorkingDirectory}` subdirectory and +the submission checked out in the `${studentParentWorkingDirectoryName}` subdirectory. Moreover, `test.cabal` provides an executable to test the template repository locally. For this, it expects the template repository in the `template` subdirectory. diff --git a/src/main/resources/templates/haskell/test/run.sh b/src/main/resources/templates/haskell/test/run.sh index 06169da575a2..562c1139cf0f 100755 --- a/src/main/resources/templates/haskell/test/run.sh +++ b/src/main/resources/templates/haskell/test/run.sh @@ -13,20 +13,20 @@ done shift $((OPTIND-1)) # check for symlinks as they might be abused to link to the sample solution -$safe && find assignment/ -type l | grep -q . && echo "Cannot build with symlinks in submission." && exit 1 +$safe && find ${studentParentWorkingDirectoryName}/ -type l | grep -q . && echo "Cannot build with symlinks in submission." && exit 1 # check for unsafe OPTIONS and OPTIONS_GHC pragma as they allow to overwrite command line arguments $safe && \ while IFS= read file; do cat $file | tr -d '\n' | grep -qim 1 "{-#[[:space:]]*options" && \ echo "Cannot build with \"{-# OPTIONS..\" pragma in source." && exit 1 -done < <(find assignment/src -type f) +done < <(find ${studentParentWorkingDirectoryName}/src -type f) # build the libraries - do not forget to set the right compilation flag (Prod) stack build --allow-different-user --flag test:Prod && \ # delete the solution and tests (so that students cannot access it) when in safe mode ($safe && \ - (rm -rf solution && rm -rf test) \ + (rm -rf ${solutionWorkingDirectory} && rm -rf test) \ ) \ # run the test executable and return 0 # Note: as a convention, a failed haskell tasty test suite returns 1, but this stops the JUnit Parser from running. diff --git a/src/main/resources/templates/haskell/test/test.cabal b/src/main/resources/templates/haskell/test/test.cabal index 7cf429c106e3..16977cf154b8 100644 --- a/src/main/resources/templates/haskell/test/test.cabal +++ b/src/main/resources/templates/haskell/test/test.cabal @@ -56,7 +56,7 @@ library submission -- by setting it to a non-existent program called `nonExistentCPP`. See -- https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/phases.html ghc-options: -fpackage-trust -trust base -pgmP nonExistentCPP - hs-source-dirs: assignment/src + hs-source-dirs: ${studentParentWorkingDirectoryName}/src exposed-modules: Exercise -- build the local template @@ -74,7 +74,7 @@ library template -- build the solution library solution import: common-all - hs-source-dirs: solution/src + hs-source-dirs: ${solutionWorkingDirectory}/src exposed-modules: Exercise -- run tests for a submission diff --git a/src/main/resources/templates/java/test/blackbox/projectTemplate/.gitignore b/src/main/resources/templates/java/test/blackbox/projectTemplate/.gitignore index 44c742ee253c..2bb4fca67c4f 100644 --- a/src/main/resources/templates/java/test/blackbox/projectTemplate/.gitignore +++ b/src/main/resources/templates/java/test/blackbox/projectTemplate/.gitignore @@ -1,4 +1,4 @@ -assignment/ +${studentParentWorkingDirectoryName}/ # Taken from https://github.com/github/gitignore diff --git a/src/main/resources/templates/java/test/gradle/projectTemplate/.gitignore b/src/main/resources/templates/java/test/gradle/projectTemplate/.gitignore index bde9056a0f14..964ec2319a13 100644 --- a/src/main/resources/templates/java/test/gradle/projectTemplate/.gitignore +++ b/src/main/resources/templates/java/test/gradle/projectTemplate/.gitignore @@ -1,5 +1,5 @@ .gradle -assignment/ +${studentParentWorkingDirectoryName}/ **/build/ !src/**/build/ target/ diff --git a/src/main/resources/templates/java/test/gradle/projectTemplate/build.gradle b/src/main/resources/templates/java/test/gradle/projectTemplate/build.gradle index c6d2e5f6689a..97be1ab67bfd 100644 --- a/src/main/resources/templates/java/test/gradle/projectTemplate/build.gradle +++ b/src/main/resources/templates/java/test/gradle/projectTemplate/build.gradle @@ -28,7 +28,7 @@ dependencies { // testImplementation(':${exerciseNamePomXml}-Solution') } -def assignmentSrcDir = "assignment/src" +def assignmentSrcDir = "${studentWorkingDirectoryNoSlash}" def studentOutputDir = sourceSets.main.java.destinationDirectory.get() // %static-code-analysis-start% def scaConfigDirectory = "$projectDir/staticCodeAnalysisConfig" diff --git a/src/main/resources/templates/java/test/maven/projectTemplate/.gitignore b/src/main/resources/templates/java/test/maven/projectTemplate/.gitignore index dd177405a0d3..dbf843d6c857 100644 --- a/src/main/resources/templates/java/test/maven/projectTemplate/.gitignore +++ b/src/main/resources/templates/java/test/maven/projectTemplate/.gitignore @@ -1,4 +1,4 @@ -assignment/ +${studentParentWorkingDirectoryName}/ target/ # Taken from https://github.com/github/gitignore diff --git a/src/main/resources/templates/java/test/stagePom.xml b/src/main/resources/templates/java/test/stagePom.xml index fea2492362ef..18cfa54fc39f 100644 --- a/src/main/resources/templates/java/test/stagePom.xml +++ b/src/main/resources/templates/java/test/stagePom.xml @@ -8,7 +8,7 @@ 4.0.0 ${exerciseNamePomXml}-Tests - ${project.basedir}/../assignment/src + ${project.basedir}/..${studentWorkingDirectory} org.apache.maven.plugins diff --git a/src/main/resources/templates/javascript/test/.gitignore b/src/main/resources/templates/javascript/test/.gitignore index d81d793eaed4..0d3cf4874427 100644 --- a/src/main/resources/templates/javascript/test/.gitignore +++ b/src/main/resources/templates/javascript/test/.gitignore @@ -1,4 +1,4 @@ node_modules/ -/assignment +/${studentParentWorkingDirectoryName} /junit.xml diff --git a/src/main/resources/templates/javascript/test/package-lock.json b/src/main/resources/templates/javascript/test/package-lock.json index b18c57c3b694..79830909997b 100644 --- a/src/main/resources/templates/javascript/test/package-lock.json +++ b/src/main/resources/templates/javascript/test/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "artemis-test", "workspaces": [ - "assignment" + "${studentParentWorkingDirectoryName}" ], "devDependencies": { "@babel/core": "^7.24.7", @@ -17,7 +17,7 @@ "jest-junit": "^16.0.0" } }, - "assignment": { + "${studentParentWorkingDirectoryName}": { "name": "artemis-exercise" }, "node_modules/@ampproject/remapping": { @@ -2499,7 +2499,7 @@ } }, "node_modules/artemis-exercise": { - "resolved": "assignment", + "resolved": "${studentParentWorkingDirectoryName}", "link": true }, "node_modules/babel-jest": { diff --git a/src/main/resources/templates/javascript/test/package.json b/src/main/resources/templates/javascript/test/package.json index 3971d2b0f3c6..782e6c431492 100644 --- a/src/main/resources/templates/javascript/test/package.json +++ b/src/main/resources/templates/javascript/test/package.json @@ -6,7 +6,7 @@ "test:ci": "jest --ci --reporters=default --reporters=jest-junit" }, "workspaces": [ - "assignment" + "${studentParentWorkingDirectoryName}" ], "devDependencies": { "@babel/core": "^7.24.7", diff --git a/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..d9bc3bbf4a27 --- /dev/null +++ b/src/main/resources/templates/jenkins/c_plus_plus/regularRuns/pipeline.groovy @@ -0,0 +1,81 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Setup') { + sh ''' + mkdir test-reports + + # Updating ownership... + chown -R artemis_user:artemis_user . + + REQ_FILE=requirements.txt + if [ -f "$REQ_FILE" ]; then + python3 -m venv /venv + /venv/bin/pip3 install -r "$REQ_FILE" + else + echo "$REQ_FILE does not exist" + fi + ''' + } + + stage('Compile and Test') { + sh ''' + if [ -d /venv ]; then + . /venv/bin/activate + fi + + # Run tests as unprivileged user + runuser -u artemis_user python3 Tests.py + ''' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + if [ -e test-reports/tests-results.xml ] + then + sed -i 's/[^[:print:]\t]/�/g' test-reports/tests-results.xml + sed -i 's//<\\/error>/g' test-reports/tests-results.xml + sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' test-reports/tests-results.xml + fi + rm -rf results + mv test-reports results + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..9a2ec97b5843 --- /dev/null +++ b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy @@ -0,0 +1,59 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Test') { + sh ''' + R CMD INSTALL assignment + Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' + ''' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + rm -rf results + mkdir results + if [ -e tests/testthat/junit.xml ] + then + sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' tests/testthat/junit.xml + fi + cp tests/testthat/junit.xml $WORKSPACE/results/ || true + sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..1ba259ab3553 --- /dev/null +++ b/src/main/resources/templates/jenkins/typescript/regularRuns/pipeline.groovy @@ -0,0 +1,62 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Install Dependencies') { + sh 'npm ci --prefer-offline --no-audit' + } + stage('Build') { + sh 'npm run build' + } + stage('Test') { + sh 'npm run test:ci' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + rm -rf results + mkdir results + if [ -e junit.xml ] + then + sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' junit.xml + fi + cp junit.xml $WORKSPACE/results/ || true + sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/kotlin/test/maven/projectTemplate/.gitignore b/src/main/resources/templates/kotlin/test/maven/projectTemplate/.gitignore index bff99bd68dc5..5c7928c0bc37 100644 --- a/src/main/resources/templates/kotlin/test/maven/projectTemplate/.gitignore +++ b/src/main/resources/templates/kotlin/test/maven/projectTemplate/.gitignore @@ -1,4 +1,4 @@ -assignment/ +${studentParentWorkingDirectoryName}/ target/ # Taken from https://github.com/github/gitignore diff --git a/src/main/resources/templates/kotlin/test/stagePom.xml b/src/main/resources/templates/kotlin/test/stagePom.xml index 89bcbda7d484..d525536c92f6 100644 --- a/src/main/resources/templates/kotlin/test/stagePom.xml +++ b/src/main/resources/templates/kotlin/test/stagePom.xml @@ -8,7 +8,7 @@ 4.0.0 ${exerciseNamePomXml}-Tests - ${project.basedir}/../assignment/src + ${project.basedir}/..${studentWorkingDirectory} ${project.basedir}/test diff --git a/src/main/resources/templates/ocaml/test/.gitignore b/src/main/resources/templates/ocaml/test/.gitignore index e477e6e6a9b6..fdad97ced9bf 100644 --- a/src/main/resources/templates/ocaml/test/.gitignore +++ b/src/main/resources/templates/ocaml/test/.gitignore @@ -1,6 +1,6 @@ # Things generated by the test framework -/solution/*.ml -/assignment/*.ml +/${solutionWorkingDirectory}/*.ml +/${studentParentWorkingDirectoryName}/*.ml /test/runHidden.ml /checker/checker.exe diff --git a/src/main/resources/templates/ocaml/test/checker/checker.ml b/src/main/resources/templates/ocaml/test/checker/checker.ml index b13edbad4032..daa6d48b56fa 100644 --- a/src/main/resources/templates/ocaml/test/checker/checker.ml +++ b/src/main/resources/templates/ocaml/test/checker/checker.ml @@ -96,7 +96,7 @@ let checkFile fn = violation := true; Location.report_exception Format.err_formatter exn -let studentDir = "assignment" +let studentDir = "${studentParentWorkingDirectoryName}" (** check all student files for violations *) let _ = diff --git a/src/main/resources/templates/ocaml/test/run.sh b/src/main/resources/templates/ocaml/test/run.sh index fe73a2830c98..3dc4af3ff7cd 100755 --- a/src/main/resources/templates/ocaml/test/run.sh +++ b/src/main/resources/templates/ocaml/test/run.sh @@ -3,6 +3,7 @@ # copy code from the assignment or solution to the appropriate test folder cp_code() { + mv "$2" "$1" cd "$1" || exit rm ./*.ml >/dev/null 2>&1 # shellcheck disable=SC2086 @@ -44,13 +45,13 @@ else fi # check for symlink is the submission -find ../assignment/ -type l | grep -q . && echo "Cannot build with symlinks in submission." && exit 0 +find ../${studentParentWorkingDirectoryName}/ -type l | grep -q . && echo "Cannot build with symlinks in submission." && exit 0 # include solution and assignment in the tests # this will only pick up *.ml files in the /src folders if other files are required for the tests this needs to be adjusted -cp_code solution -echo 'include Assignment' > solution/solution.ml -cp_code assignment +cp_code ${solutionWorkingDirectory} solution +echo 'include Assignment' > ${solutionWorkingDirectory}/solution.ml +cp_code ${studentParentWorkingDirectoryName} assignment # select if tests are run by generated source code as student toplevel code may run before the tests and be able to spoof a runtime signal echo "let runHidden = $RUN_HIDDEN" > test/runHidden.ml @@ -71,7 +72,7 @@ if ! timeout -s SIGTERM $BUILD_TIMEOUT checker/checker.exe; then fi # build the student submission # don't reference the tests or solution, so that we can show the build output to the student and not leak test / solution code -if ! timeout -s SIGTERM $BUILD_TIMEOUT dune build --force assignment; then +if ! timeout -s SIGTERM $BUILD_TIMEOUT dune build --force ${solutionWorkingDirectory}; then echo "Unable to build submission, please ensure that your code builds and matches the provided interface" >&2 exit 0 fi @@ -85,13 +86,13 @@ fi cd "$BUILD_ROOT" || exit # copy the test executable into the project root -mv -f tests/test/test.exe ./ +mv -f ${testWorkingDirectory}/test/test.exe ./ # to then delete all source code, to prevent access to it while running the code if $SAFE; then - rm -rf assignment - rm -rf solution - rm -rf tests + rm -rf ${studentParentWorkingDirectoryName} + rm -rf ${solutionWorkingDirectory} + rm -rf ${testWorkingDirectory} fi; # running the test executable without arguments to cause them to exit without actually running any tests diff --git a/src/main/resources/templates/python/test/behavior/behavior_test.py b/src/main/resources/templates/python/test/behavior/behavior_test.py index 15ad384df904..31c0491b0a9c 100644 --- a/src/main/resources/templates/python/test/behavior/behavior_test.py +++ b/src/main/resources/templates/python/test/behavior/behavior_test.py @@ -1,7 +1,7 @@ import unittest -from assignment.sorting_algorithms import * -from assignment.context import Context -from assignment.policy import Policy +from ${studentParentWorkingDirectoryName}.sorting_algorithms import * +from ${studentParentWorkingDirectoryName}.context import Context +from ${studentParentWorkingDirectoryName}.policy import Policy class TestSortingBehavior(unittest.TestCase): diff --git a/src/main/resources/templates/python/test/structural/structural_test.py b/src/main/resources/templates/python/test/structural/structural_test.py index 24ed0f758476..96c30edd118e 100644 --- a/src/main/resources/templates/python/test/structural/structural_test.py +++ b/src/main/resources/templates/python/test/structural/structural_test.py @@ -1,8 +1,8 @@ import unittest -from assignment import sorting_algorithms -from assignment import sort_strategy -from assignment import context -from assignment import policy +from ${studentParentWorkingDirectoryName} import sorting_algorithms +from ${studentParentWorkingDirectoryName} import sort_strategy +from ${studentParentWorkingDirectoryName} import context +from ${studentParentWorkingDirectoryName} import policy from structural import structural_helpers diff --git a/src/main/resources/templates/r/exercise/DESCRIPTION b/src/main/resources/templates/r/exercise/DESCRIPTION new file mode 100644 index 000000000000..2933cb767621 --- /dev/null +++ b/src/main/resources/templates/r/exercise/DESCRIPTION @@ -0,0 +1,7 @@ +Package: assignment +Title: Artemis R Student Assignment +Version: 0.0.0.9000 +Author: Artemis +Description: This is an assignment to be solved by students. +License: MIT +Encoding: UTF-8 diff --git a/src/main/resources/templates/r/exercise/NAMESPACE b/src/main/resources/templates/r/exercise/NAMESPACE new file mode 100644 index 000000000000..9c9f9ac2d917 --- /dev/null +++ b/src/main/resources/templates/r/exercise/NAMESPACE @@ -0,0 +1 @@ +exportPattern("^[^\\.]") diff --git a/src/main/resources/templates/r/exercise/R/convert.R b/src/main/resources/templates/r/exercise/R/convert.R new file mode 100644 index 000000000000..28e787cf2967 --- /dev/null +++ b/src/main/resources/templates/r/exercise/R/convert.R @@ -0,0 +1,3 @@ +matrix_to_column_list <- function(mat) { + # TODO: implement +} diff --git a/src/main/resources/templates/r/readme b/src/main/resources/templates/r/readme new file mode 100644 index 000000000000..73377139d293 --- /dev/null +++ b/src/main/resources/templates/r/readme @@ -0,0 +1,6 @@ +# Matrix Columns + +Write a function `matrix_to_column_list` in R that takes a matrix of any shape and converts it into a list of +column-vectors. Each element of the list should represent a column of the matrix. + +1. [task][Convert to column-vectors](converts_3x3_matrix_to_vectors,converts_4x2_matrix_to_vectors,converts_1x5_matrix_to_scalars,converts_5x1_matrix_to_vector) diff --git a/src/main/resources/templates/r/solution/DESCRIPTION b/src/main/resources/templates/r/solution/DESCRIPTION new file mode 100644 index 000000000000..2933cb767621 --- /dev/null +++ b/src/main/resources/templates/r/solution/DESCRIPTION @@ -0,0 +1,7 @@ +Package: assignment +Title: Artemis R Student Assignment +Version: 0.0.0.9000 +Author: Artemis +Description: This is an assignment to be solved by students. +License: MIT +Encoding: UTF-8 diff --git a/src/main/resources/templates/r/solution/NAMESPACE b/src/main/resources/templates/r/solution/NAMESPACE new file mode 100644 index 000000000000..9c9f9ac2d917 --- /dev/null +++ b/src/main/resources/templates/r/solution/NAMESPACE @@ -0,0 +1 @@ +exportPattern("^[^\\.]") diff --git a/src/main/resources/templates/r/solution/R/convert.R b/src/main/resources/templates/r/solution/R/convert.R new file mode 100644 index 000000000000..7d701772ab7b --- /dev/null +++ b/src/main/resources/templates/r/solution/R/convert.R @@ -0,0 +1,17 @@ +matrix_to_column_list <- function(mat) { + if (!is.matrix(mat)) { + stop("Input must be a matrix") + } + + n_cols <- ncol(mat) + + # Initialize an empty list to store column-vectors + column_list <- vector("list", length = n_cols) + + # Loop through each column and store it in the list + for (i in 1:n_cols) { + column_list[[i]] <- mat[, i] + } + + return(column_list) +} diff --git a/src/main/resources/templates/r/test/DESCRIPTION b/src/main/resources/templates/r/test/DESCRIPTION new file mode 100644 index 000000000000..e19a2b735419 --- /dev/null +++ b/src/main/resources/templates/r/test/DESCRIPTION @@ -0,0 +1,14 @@ +Package: test +Title: Artemis R Tests +Version: 0.0.0.9000 +Author: Artemis +Description: This package tests the student assignment. +License: MIT +Encoding: UTF-8 +Imports: + assignment +Remotes: + local::./assignment +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 diff --git a/src/main/resources/templates/r/test/tests/testthat.R b/src/main/resources/templates/r/test/tests/testthat.R new file mode 100644 index 000000000000..388438828173 --- /dev/null +++ b/src/main/resources/templates/r/test/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(tests) + +test_check("tests") diff --git a/src/main/resources/templates/r/test/tests/testthat/test-convert.R b/src/main/resources/templates/r/test/tests/testthat/test-convert.R new file mode 100644 index 000000000000..a84a0e879711 --- /dev/null +++ b/src/main/resources/templates/r/test/tests/testthat/test-convert.R @@ -0,0 +1,47 @@ +test_that("converts_3x3_matrix_to_vectors", { + mat <- matrix(c(5, 8, 11, 6, 9, 12, 7, 10, 13), nrow = 3, ncol = 3) + + result <- assignment::matrix_to_column_list(mat) + + # Make sure to only use exactly one "expect_" function per test + expect_equal(result, list( + c(5, 8, 11), + c(6, 9, 12), + c(7, 10, 13) + )) +}) + +test_that("converts_4x2_matrix_to_vectors", { + mat <- matrix(c(13, 13, 5, 18, 11, 4, 7, 10), nrow = 4, ncol = 2) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + c(13, 13, 5, 18), + c(11, 4, 7, 10) + )) +}) + +test_that("converts_1x5_matrix_to_scalars", { + mat <- matrix(c(16, 10, 15, 8, 7), nrow = 1, ncol = 5) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + 16, + 10, + 15, + 8, + 7 + )) +}) + +test_that("converts_5x1_matrix_to_vector", { + mat <- matrix(c(14, 9, 1, 3, 4), nrow = 5, ncol = 1) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + c(14, 9, 1, 3, 4) + )) +}) diff --git a/src/main/resources/templates/rust/test/.gitignore b/src/main/resources/templates/rust/test/.gitignore index a37c5236a444..aecf1e1c91fa 100644 --- a/src/main/resources/templates/rust/test/.gitignore +++ b/src/main/resources/templates/rust/test/.gitignore @@ -1,2 +1,2 @@ /target -/assignment +/${studentParentWorkingDirectoryName} diff --git a/src/main/resources/templates/rust/test/Cargo.toml b/src/main/resources/templates/rust/test/Cargo.toml index 07f82b3f09f0..f7f39b905be0 100644 --- a/src/main/resources/templates/rust/test/Cargo.toml +++ b/src/main/resources/templates/rust/test/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] chrono = { version = "0.4.38", default-features = false } -rust-template-exercise = { path = "assignment" } +rust-template-exercise = { path = "${studentParentWorkingDirectoryName}" } syn = { version = "2.0.72", features = ["full"] } rust_template_test_macros = { path = "./rust_template_test_macros" } diff --git a/src/main/resources/templates/rust/test/build.rs b/src/main/resources/templates/rust/test/build.rs index 850fcb846cce..947f4783dbd7 100644 --- a/src/main/resources/templates/rust/test/build.rs +++ b/src/main/resources/templates/rust/test/build.rs @@ -6,7 +6,7 @@ use std::{fs, io}; use syn::{parse_file, FnArg, ImplItem, Item, TraitItem, Type, TypeParamBound}; -const SRC_DIR: &str = "assignment/src"; +const SRC_DIR: &str = "${studentWorkingDirectoryNoSlash}"; fn main() { println!("cargo::rerun-if-changed={SRC_DIR}"); diff --git a/src/main/resources/templates/rust/test/tests/structural.rs b/src/main/resources/templates/rust/test/tests/structural.rs index 70b0d19c9131..cf0bef375605 100644 --- a/src/main/resources/templates/rust/test/tests/structural.rs +++ b/src/main/resources/templates/rust/test/tests/structural.rs @@ -4,14 +4,14 @@ use structural_helpers::*; #[test] fn test_sort_strategy_trait() { - let ast = parse_file("./assignment/src/sort_strategy.rs"); + let ast = parse_file(".${studentWorkingDirectory}/sort_strategy.rs"); check_trait_names(&ast.items, ["SortStrategy"]) .unwrap_or_else(|name| panic!("A trait named \"{name}\" should be defined")); } #[test] fn test_sort_strategy_supertrait() { - let ast = parse_file("./assignment/src/sort_strategy.rs"); + let ast = parse_file(".${studentWorkingDirectory}/sort_strategy.rs"); let sort_strategy = find_trait(&ast.items, "SortStrategy") .expect("A trait named \"SortStrategy\" should be defined"); check_trait_supertrait(sort_strategy, "Any") @@ -20,7 +20,7 @@ fn test_sort_strategy_supertrait() { #[test] fn test_sort_strategy_methods() { - let ast = parse_file("./assignment/src/sort_strategy.rs"); + let ast = parse_file(".${studentWorkingDirectory}/sort_strategy.rs"); let sort_strategy = find_trait(&ast.items, "SortStrategy") .expect("A trait named \"SortStrategy\" should be defined"); check_trait_function_names(&sort_strategy.items, ["perform_sort"]) @@ -29,7 +29,7 @@ fn test_sort_strategy_methods() { #[test] fn test_context_fields() { - let ast = parse_file("./assignment/src/context.rs"); + let ast = parse_file(".${studentWorkingDirectory}/context.rs"); let context = find_struct(&ast.items, "Context").expect("A struct named \"Context\" should be defined"); check_struct_field_names(&context.fields, ["sort_algorithm"]) @@ -38,7 +38,7 @@ fn test_context_fields() { #[test] fn test_context_methods() { - let ast = parse_file("./assignment/src/context.rs"); + let ast = parse_file(".${studentWorkingDirectory}/context.rs"); let context_impl = find_impl(&ast.items, "Context").expect("SortStrategy should implement functions"); check_impl_function_names(&context_impl.items, ["new", "sort", "sort_algorithm"]) @@ -47,7 +47,7 @@ fn test_context_methods() { #[test] fn test_policy_fields() { - let ast = parse_file("./assignment/src/policy.rs"); + let ast = parse_file(".${studentWorkingDirectory}/policy.rs"); let policy = find_struct(&ast.items, "Policy").expect("A struct named \"Policy\" should be defined"); check_struct_field_names(&policy.fields, ["context"]) @@ -56,7 +56,7 @@ fn test_policy_fields() { #[test] fn test_policy_methods() { - let ast = parse_file("./assignment/src/policy.rs"); + let ast = parse_file(".${studentWorkingDirectory}/policy.rs"); let policy_impl = find_impl(&ast.items, "Policy").expect("Policy should implement functions"); check_impl_function_names(&policy_impl.items, ["new", "configure"]) .unwrap_or_else(|name| panic!("Policy should implement the function \"{name}\"")); @@ -64,7 +64,7 @@ fn test_policy_methods() { #[test] fn test_bubble_sort_struct() { - let ast = parse_file("./assignment/src/bubble_sort.rs"); + let ast = parse_file(".${studentWorkingDirectory}/bubble_sort.rs"); find_struct(&ast.items, "BubbleSort").expect("A struct named \"BubbleSort\" should be defined"); find_impl_for(&ast.items, "BubbleSort", "SortStrategy") .expect("BubbleSort should implement the trait \"SortStrategy\""); @@ -72,7 +72,7 @@ fn test_bubble_sort_struct() { #[test] fn test_merge_sort_struct() { - let ast = parse_file("./assignment/src/merge_sort.rs"); + let ast = parse_file("./${studentWorkingDirectory}/merge_sort.rs"); find_struct(&ast.items, "MergeSort").expect("A struct named \"MergeSort\" should be defined"); find_impl_for(&ast.items, "MergeSort", "SortStrategy") .expect("MergeSort should implement the trait \"SortStrategy\""); diff --git a/src/main/resources/templates/swift/Swift-Server-Setup.md b/src/main/resources/templates/swift/Swift-Server-Setup.md index 425f7244566c..58cc83d1a18b 100644 --- a/src/main/resources/templates/swift/Swift-Server-Setup.md +++ b/src/main/resources/templates/swift/Swift-Server-Setup.md @@ -55,7 +55,7 @@ Append following to ~/.bashrc: # Bamboo Build Plan ## Create Tasks Go to Plan Configuration > Default Job > Tasks -- Create default task to checkout repos "tests and assignment" +- Create default task to checkout repos "tests and ${studentParentWorkingDirectoryName}" - Create a task to build the swift project - Name the task `Build swift`. - Interpreter: `Shell` diff --git a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}Test.xcscheme b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}Test.xcscheme index fa4508ce15ce..1d83b9c00883 100644 --- a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}Test.xcscheme +++ b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}Test.xcscheme @@ -41,7 +41,7 @@ BlueprintIdentifier = "4F4474F426BD8F87004E6064" BuildableName = "${appName}.app" BlueprintName = "${appName}" - ReferencedContainer = "container:assignment/${appName}.xcodeproj"> + ReferencedContainer = "container:${studentParentWorkingDirectoryName}/${appName}.xcodeproj"> @@ -57,7 +57,7 @@ BlueprintIdentifier = "4F4474F426BD8F87004E6064" BuildableName = "${appName}.app" BlueprintName = "${appName}" - ReferencedContainer = "container:assignment/${appName}.xcodeproj"> + ReferencedContainer = "container:${studentParentWorkingDirectoryName}/${appName}.xcodeproj"> diff --git a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}UITest.xcscheme b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}UITest.xcscheme index 11353953efa7..86d626978f97 100644 --- a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}UITest.xcscheme +++ b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcodeproj/xcshareddata/xcschemes/${appName}UITest.xcscheme @@ -41,7 +41,7 @@ ${appName} BlueprintIdentifier = "4F4474F426BD8F87004E6064" BuildableName = "${appName}.app" BlueprintName = "${appName}" - ReferencedContainer = "container:assignment/${appName}.xcodeproj"> + ReferencedContainer = "container:${studentParentWorkingDirectoryName}/${appName}.xcodeproj"> @@ -57,7 +57,7 @@ ${appName} BlueprintIdentifier = "4F4474F426BD8F87004E6064" BuildableName = "${appName}.app" BlueprintName = "${appName}" - ReferencedContainer = "container:assignment/${appName}.xcodeproj"> + ReferencedContainer = "container:${studentParentWorkingDirectoryName}/${appName}.xcodeproj"> diff --git a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcworkspace/contents.xcworkspacedata b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcworkspace/contents.xcworkspacedata index 70fc7ea09f74..bcc06bae07c6 100644 --- a/src/main/resources/templates/swift/xcode/test/${appName}Test.xcworkspace/contents.xcworkspacedata +++ b/src/main/resources/templates/swift/xcode/test/${appName}Test.xcworkspace/contents.xcworkspacedata @@ -5,6 +5,6 @@ location = "group:${appName}Test.xcodeproj"> + location = "group:${studentParentWorkingDirectoryName}/${appName}.xcodeproj"> - \ No newline at end of file + diff --git a/src/main/resources/templates/swift/xcode/test/.swiftlint.yml b/src/main/resources/templates/swift/xcode/test/.swiftlint.yml index 5c35904fd2e7..39604f106d66 100644 --- a/src/main/resources/templates/swift/xcode/test/.swiftlint.yml +++ b/src/main/resources/templates/swift/xcode/test/.swiftlint.yml @@ -292,7 +292,7 @@ only_rules: # An XCTFail call should include a description of the assertion. included: # paths to include during linting. `--path` is ignored if present. - - assignment + - ${studentParentWorkingDirectoryName} excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage diff --git a/src/main/resources/templates/swift/xcode/test/README.md b/src/main/resources/templates/swift/xcode/test/README.md index 61991fee8ef8..d0093037df8c 100644 --- a/src/main/resources/templates/swift/xcode/test/README.md +++ b/src/main/resources/templates/swift/xcode/test/README.md @@ -1,7 +1,7 @@ This is the combined repo that will be produced on the build agent by cloning two repos -1) exercise --> everything in the assignment folder -2) tests --> everything except the assignment folder +1) exercise --> everything in the ${studentParentWorkingDirectoryName} folder +2) tests --> everything except the ${studentParentWorkingDirectoryName} folder The tests can be executed as follows diff --git a/src/main/resources/templates/typescript/exercise/.gitignore b/src/main/resources/templates/typescript/exercise/.gitignore new file mode 100644 index 000000000000..c6ce4cc9ff34 --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/.gitignore @@ -0,0 +1,132 @@ +# NodeJS .gitignore from https://github.com/github/gitignore/blob/main/Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/src/main/resources/templates/typescript/exercise/package-lock.json b/src/main/resources/templates/typescript/exercise/package-lock.json new file mode 100644 index 000000000000..4c093b19263f --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "artemis-exercise", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "artemis-exercise", + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/node": "^20.15.0", + "typescript": "^5.6.2" + } + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/main/resources/templates/typescript/exercise/package.json b/src/main/resources/templates/typescript/exercise/package.json new file mode 100644 index 000000000000..6d49a1f95773 --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/package.json @@ -0,0 +1,19 @@ +{ + "name": "artemis-exercise", + "private": true, + "scripts": { + "build": "tsc", + "start": "node ./dist/client.js" + }, + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/node": "^20.15.0", + "typescript": "^5.6.2" + } +} diff --git a/src/main/resources/templates/typescript/exercise/src/bubblesort.ts b/src/main/resources/templates/typescript/exercise/src/bubblesort.ts new file mode 100644 index 000000000000..36f8463102ef --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/bubblesort.ts @@ -0,0 +1,3 @@ +export default class BubbleSort { + // TODO: implement in performSort(Array) +} diff --git a/src/main/resources/templates/typescript/exercise/src/client.ts b/src/main/resources/templates/typescript/exercise/src/client.ts new file mode 100644 index 000000000000..ad9475b976ad --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/client.ts @@ -0,0 +1,66 @@ +const ITERATIONS = 10; +const DATES_LENGTH_MIN = 5; +const DATES_LENGTH_MAX = 15; + +/** + * Main function. + * Add code to demonstrate your implementation here. + */ +function main() { + // TODO: Init Context and Policy + + // Run multiple times to simulate different sorting strategies + for (let i = 0; i < ITERATIONS; i++) { + const dates = createRandomDates(); + + // TODO: Configure context + + console.log('Unsorted Array of dates:'); + console.log(dates); + + // TODO: Sort dates + + console.log('Sorted Array of dates:'); + console.log(dates); + } +} + +/** + * Generates an Array of random Date objects with random Array length between + * {@link DATES_LENGTH_MIN} and {@link DATES_LENGTH_MAX}. + * + * @return an Array of random Date objects + */ +function createRandomDates(): Array { + const length = randomIntegerWithin(DATES_LENGTH_MIN, DATES_LENGTH_MAX); + + const lowestDate = new Date('2024-09-15'); + const highestDate = new Date('2025-01-15'); + + return Array.from(Array(length), () => randomDateWithin(lowestDate, highestDate)); +} + +/** + * Creates a random Date within the given range. + * + * @param low {Date} the lower bound + * @param high {Date} the upper bound + * @return {Date} random Date within the given range + */ +function randomDateWithin(low: Date, high: Date): Date { + const randomTimestamp = randomIntegerWithin(low.valueOf(), high.valueOf()); + return new Date(randomTimestamp); +} + +/** + * Creates a random int within the given range. + * + * @param low {number} the lower bound + * @param high {number} the upper bound + * @returns {number} random int within the given range + */ +function randomIntegerWithin(low: number, high: number): number { + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +main(); diff --git a/src/main/resources/templates/typescript/exercise/src/context.ts b/src/main/resources/templates/typescript/exercise/src/context.ts new file mode 100644 index 000000000000..a667a10bb29e --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/context.ts @@ -0,0 +1,3 @@ +export default class Context { + // TODO: Create and implement a Context class according to the UML class diagram +} diff --git a/src/main/resources/templates/typescript/exercise/src/mergesort.ts b/src/main/resources/templates/typescript/exercise/src/mergesort.ts new file mode 100644 index 000000000000..4b07a80b4c31 --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/mergesort.ts @@ -0,0 +1,3 @@ +export default class MergeSort { + // TODO: implement in performSort(Array) +} diff --git a/src/main/resources/templates/typescript/exercise/src/policy.ts b/src/main/resources/templates/typescript/exercise/src/policy.ts new file mode 100644 index 000000000000..7c8723feb1a9 --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/policy.ts @@ -0,0 +1,3 @@ +export default class Policy { + // TODO: Create and implement a Policy class as described in the problem statement +} diff --git a/src/main/resources/templates/typescript/exercise/src/sortstrategy.ts b/src/main/resources/templates/typescript/exercise/src/sortstrategy.ts new file mode 100644 index 000000000000..40723e61965c --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/src/sortstrategy.ts @@ -0,0 +1,3 @@ +export default interface SortStrategy { + // TODO: Create a SortStrategy interface according to the UML class diagram +} diff --git a/src/main/resources/templates/typescript/exercise/tsconfig.json b/src/main/resources/templates/typescript/exercise/tsconfig.json new file mode 100644 index 000000000000..b26f243b6e4d --- /dev/null +++ b/src/main/resources/templates/typescript/exercise/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + } +} diff --git a/src/main/resources/templates/typescript/readme b/src/main/resources/templates/typescript/readme new file mode 100644 index 000000000000..a536c0d80cc9 --- /dev/null +++ b/src/main/resources/templates/typescript/readme @@ -0,0 +1,86 @@ +# Sorting with the Strategy Pattern + +In this exercise, we want to implement sorting algorithms and choose them based on runtime specific variables. + +### Part 1: Sorting + +First, we need to implement two sorting algorithms, in this case `MergeSort` and `BubbleSort`. + +**You have the following tasks:** + +1. [task][Implement Bubble Sort](structural_BubbleSort_has_method,behavior_BubbleSort_should_sort_correctly) +Implement the method `performSort(Array)` in the class `BubbleSort`. Make sure to follow the Bubble Sort algorithm exactly. + +2. [task][Implement Merge Sort](structural_MergeSort_has_method,behavior_MergeSort_should_sort_correctly) +Implement the method `performSort(Array)` in the class `MergeSort`. Make sure to follow the Merge Sort algorithm exactly. + +### Part 2: Strategy Pattern + +We want the application to apply different algorithms for sorting an Array of `Date` objects. +Use the strategy pattern to select the right sorting algorithm at runtime. + +**You have the following tasks:** + +1. SortStrategy Interface +Create a `SortStrategy` interface and adjust the sorting algorithms so that they implement this interface. + +2. [task][Context Class](structural_Context_has_properties,structural_Context_has_methods) +Create and implement a `Context` class following the below class diagram. +Add `get` and `set` accessors for the attribute. + +3. [task][Context Policy](structural_Policy_has_properties,structural_Policy_has_methods) +Create and implement a `Policy` class following the below class diagram. +Add `get` and `set` accessors for the attribute. +`Policy` should implement a simple configuration mechanism: + + 1. [task][Select MergeSort](behavior_Policy_uses_MergeSort_for_big_list) + Select `MergeSort` when the List has more than 10 dates. + + 2. [task][Select BubbleSort](behavior_Policy_uses_BubbleSort_for_small_list) + Select `BubbleSort` when the List has less or equal 10 dates. + +4. Complete the `main()` function which demonstrates switching between two strategies at runtime. + +@startuml + +class Policy { + +Policy(Context) <> + +configure() +} + +class Context { + -dates: Array + +sort() +} + +interface SortStrategy { + +performSort(Array) +} + +class BubbleSort { + +performSort(Array) +} + +class MergeSort { + +performSort(Array) +} + +MergeSort -up-|> SortStrategy #testsColor(structural_MergeSort_has_method) +BubbleSort -up-|> SortStrategy #testsColor(structural_BubbleSort_has_method) +Policy -right-> Context #testsColor(structural_Policy_has_properties): context +Context -right-> SortStrategy #testsColor(structural_Context_has_properties): sortAlgorithm + +hide empty fields +hide empty methods + +@enduml + + +### Part 3: Optional Challenges + +(These are not tested) + +1. Create a new class `QuickSort` that implements `SortStrategy` and implement the Quick Sort algorithm. +2. Make the method `performSort(List)` generic, so that other objects can also be sorted by the same method. + **Hint:** Create a `Comparable` interface. +3. Think about a useful decision in `Policy` when to use the new `QuickSort` algorithm. diff --git a/src/main/resources/templates/typescript/solution/.gitignore b/src/main/resources/templates/typescript/solution/.gitignore new file mode 100644 index 000000000000..c6ce4cc9ff34 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/.gitignore @@ -0,0 +1,132 @@ +# NodeJS .gitignore from https://github.com/github/gitignore/blob/main/Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/src/main/resources/templates/typescript/solution/package-lock.json b/src/main/resources/templates/typescript/solution/package-lock.json new file mode 100644 index 000000000000..4c093b19263f --- /dev/null +++ b/src/main/resources/templates/typescript/solution/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "artemis-exercise", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "artemis-exercise", + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/node": "^20.15.0", + "typescript": "^5.6.2" + } + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/main/resources/templates/typescript/solution/package.json b/src/main/resources/templates/typescript/solution/package.json new file mode 100644 index 000000000000..6d49a1f95773 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/package.json @@ -0,0 +1,19 @@ +{ + "name": "artemis-exercise", + "private": true, + "scripts": { + "build": "tsc", + "start": "node ./dist/client.js" + }, + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/node": "^20.15.0", + "typescript": "^5.6.2" + } +} diff --git a/src/main/resources/templates/typescript/solution/src/bubblesort.ts b/src/main/resources/templates/typescript/solution/src/bubblesort.ts new file mode 100644 index 000000000000..f894bfc3fdd9 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/bubblesort.ts @@ -0,0 +1,21 @@ +import SortStrategy from './sortstrategy'; +import Comparable from './comparable'; + +export default class BubbleSort implements SortStrategy { + /** + * Sorts objects with BubbleSort. + * + * @param input {Array} the array of objects to be sorted + */ + performSort(input: Array) { + for (let i = input.length - 1; i >= 0; i--) { + for (let j = 0; j < i; j++) { + if (input[j].valueOf() > input[j + 1].valueOf()) { + const temp = input[j]; + input[j] = input[j + 1]; + input[j + 1] = temp; + } + } + } + } +} diff --git a/src/main/resources/templates/typescript/solution/src/client.ts b/src/main/resources/templates/typescript/solution/src/client.ts new file mode 100644 index 000000000000..7c27a4411760 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/client.ts @@ -0,0 +1,72 @@ +import Context from './context'; +import Policy from './policy'; + +const ITERATIONS = 10; +const DATES_LENGTH_MIN = 5; +const DATES_LENGTH_MAX = 15; + +/** + * Main function. + * Add code to demonstrate your implementation here. + */ +function main() { + // Init Context and Policy + const context = new Context(); + const policy = new Policy(context); + + // Run multiple times to simulate different sorting strategies + for (let i = 0; i < ITERATIONS; i++) { + const dates = createRandomDates(); + + context.dates = dates; + policy.configure(); + + console.log('Unsorted Array of dates:'); + console.log(dates); + + context.sort(); + + console.log('Sorted Array of dates:'); + console.log(dates); + } +} + +/** + * Generates an Array of random Date objects with random Array length between + * {@link DATES_LENGTH_MIN} and {@link DATES_LENGTH_MAX}. + * + * @return an Array of random Date objects + */ +function createRandomDates(): Array { + const length = randomIntegerWithin(DATES_LENGTH_MIN, DATES_LENGTH_MAX); + + const lowestDate = new Date('2024-09-15'); + const highestDate = new Date('2025-01-15'); + + return Array.from(Array(length), () => randomDateWithin(lowestDate, highestDate)); +} + +/** + * Creates a random Date within the given range. + * + * @param low {Date} the lower bound + * @param high {Date} the upper bound + * @return {Date} random Date within the given range + */ +function randomDateWithin(low: Date, high: Date): Date { + const randomTimestamp = randomIntegerWithin(low.valueOf(), high.valueOf()); + return new Date(randomTimestamp); +} + +/** + * Creates a random int within the given range. + * + * @param low {number} the lower bound + * @param high {number} the upper bound + * @returns {number} random int within the given range + */ +function randomIntegerWithin(low: number, high: number): number { + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +main(); diff --git a/src/main/resources/templates/typescript/solution/src/comparable.ts b/src/main/resources/templates/typescript/solution/src/comparable.ts new file mode 100644 index 000000000000..eade48028180 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/comparable.ts @@ -0,0 +1,3 @@ +export default interface Comparable { + valueOf(): number; +} diff --git a/src/main/resources/templates/typescript/solution/src/context.ts b/src/main/resources/templates/typescript/solution/src/context.ts new file mode 100644 index 000000000000..731a630aa235 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/context.ts @@ -0,0 +1,30 @@ +import type SortStrategy from './sortstrategy'; + +export default class Context { + private _sortAlgorithm: SortStrategy | null = null; + + private _dates: Array = []; + + /** + * Runs the configured sort algorithm. + */ + sort() { + this._sortAlgorithm?.performSort(this._dates); + } + + get sortAlgorithm(): SortStrategy | null { + return this._sortAlgorithm; + } + + set sortAlgorithm(sortAlgorithm: SortStrategy) { + this._sortAlgorithm = sortAlgorithm; + } + + get dates(): Array { + return this._dates; + } + + set dates(dates: Array) { + this._dates = dates; + } +} diff --git a/src/main/resources/templates/typescript/solution/src/mergesort.ts b/src/main/resources/templates/typescript/solution/src/mergesort.ts new file mode 100644 index 000000000000..383d84a8826c --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/mergesort.ts @@ -0,0 +1,69 @@ +import SortStrategy from './sortstrategy'; +import Comparable from './comparable'; + +export default class MergeSort implements SortStrategy { + /** + * Wrapper method for the real MergeSort algorithm. + * + * @template T + * @param input {Array} the array of objects to be sorted + */ + performSort(input: Array) { + mergesort(input, 0, input.length - 1); + } +} + +/** + * Recursive merge sort function + * + * @template T + * @param input {Array} + * @param low {number} + * @param high {number} + */ +function mergesort(input: Array, low: number, high: number) { + if (low >= high) { + return; + } + const mid = Math.floor((low + high) / 2); + mergesort(input, low, mid); + mergesort(input, mid + 1, high); + merge(input, low, mid, high); +} + +/** + * Merge function + * + * @template T + * @param input {Array} + * @param low {number} + * @param middle {number} + * @param high {number} + */ +function merge(input: Array, low: number, middle: number, high: number) { + const temp = new Array(high - low + 1); + + let leftIndex = low; + let rightIndex = middle + 1; + let wholeIndex = 0; + + while (leftIndex <= middle && rightIndex <= high) { + if (input[leftIndex].valueOf() <= input[rightIndex].valueOf()) { + temp[wholeIndex] = input[leftIndex++]; + } else { + temp[wholeIndex] = input[rightIndex++]; + } + wholeIndex++; + } + + while (leftIndex <= middle) { + temp[wholeIndex++] = input[leftIndex++]; + } + while (rightIndex <= high) { + temp[wholeIndex++] = input[rightIndex++]; + } + + for (wholeIndex = 0; wholeIndex < temp.length; wholeIndex++) { + input[wholeIndex + low] = temp[wholeIndex]; + } +} diff --git a/src/main/resources/templates/typescript/solution/src/policy.ts b/src/main/resources/templates/typescript/solution/src/policy.ts new file mode 100644 index 000000000000..19bf88b07911 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/policy.ts @@ -0,0 +1,28 @@ +import BubbleSort from './bubblesort'; +import MergeSort from './mergesort'; +import Context from './context'; + +const DATES_LENGTH_THRESHOLD = 10; + +export default class Policy { + constructor(private _context: Context) {} + + /** + * Chooses a strategy depending on the number of date objects. + */ + configure() { + if (this._context.dates.length > DATES_LENGTH_THRESHOLD) { + this._context.sortAlgorithm = new MergeSort(); + } else { + this._context.sortAlgorithm = new BubbleSort(); + } + } + + get context(): Context { + return this._context; + } + + set context(context: Context) { + this._context = context; + } +} diff --git a/src/main/resources/templates/typescript/solution/src/sortstrategy.ts b/src/main/resources/templates/typescript/solution/src/sortstrategy.ts new file mode 100644 index 000000000000..f658b5b53705 --- /dev/null +++ b/src/main/resources/templates/typescript/solution/src/sortstrategy.ts @@ -0,0 +1,5 @@ +import Comparable from './comparable'; + +export default interface SortStrategy { + performSort(dates: Array): void; +} diff --git a/src/main/resources/templates/typescript/solution/tsconfig.json b/src/main/resources/templates/typescript/solution/tsconfig.json new file mode 100644 index 000000000000..b26f243b6e4d --- /dev/null +++ b/src/main/resources/templates/typescript/solution/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + } +} diff --git a/src/main/resources/templates/typescript/test/.gitignore b/src/main/resources/templates/typescript/test/.gitignore new file mode 100644 index 000000000000..9de920749a26 --- /dev/null +++ b/src/main/resources/templates/typescript/test/.gitignore @@ -0,0 +1,135 @@ +/${studentParentWorkingDirectoryName} +/junit.xml + +# NodeJS .gitignore from https://github.com/github/gitignore/blob/main/Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/src/main/resources/templates/typescript/test/jest.config.js b/src/main/resources/templates/typescript/test/jest.config.js new file mode 100644 index 000000000000..f5d30d13b959 --- /dev/null +++ b/src/main/resources/templates/typescript/test/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest",{}], + }, +}; \ No newline at end of file diff --git a/src/main/resources/templates/typescript/test/package-lock.json b/src/main/resources/templates/typescript/test/package-lock.json new file mode 100644 index 000000000000..1db85e84a58f --- /dev/null +++ b/src/main/resources/templates/typescript/test/package-lock.json @@ -0,0 +1,4039 @@ +{ + "name": "artemis-test", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "artemis-test", + "workspaces": [ + "${studentParentWorkingDirectoryName}" + ], + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "ts-jest": "^29.2.5", + "typescript": "^5.6.2" + } + }, + "${studentParentWorkingDirectoryName}": { + "name": "artemis-exercise", + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/node": "^20.15.0", + "typescript": "^5.6.2" + } + }, + "${studentParentWorkingDirectoryName}/node_modules/@types/node": { + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.25.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", + "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helpers": "^7.25.7", + "@babel/parser": "^7.25.8", + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.8", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "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==", + "dev": true, + "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" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", + "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.7.tgz", + "integrity": "sha512-AqVo+dguCgmpi/3mYBdu9lkngOBlQ2w2vnNpa6gfiCxQZLzV4ZbhsXitJ2Yblkoe1VQwtHSaNmIaGll/26YWRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "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==", + "dev": true, + "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", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", + "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/artemis-exercise": { + "resolved": "${studentParentWorkingDirectoryName}", + "link": true + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-junit": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz", + "integrity": "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mkdirp": "^1.0.4", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2", + "xml": "^1.0.1" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "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==", + "dev": true, + "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", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-jest": { + "version": "29.2.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", + "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.6.3", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/main/resources/templates/typescript/test/package.json b/src/main/resources/templates/typescript/test/package.json new file mode 100644 index 000000000000..d7f50e3d33d3 --- /dev/null +++ b/src/main/resources/templates/typescript/test/package.json @@ -0,0 +1,25 @@ +{ + "name": "artemis-test", + "private": true, + "scripts": { + "build": "tsc -b", + "test": "jest", + "test:ci": "jest --ci --reporters=default --reporters=jest-junit" + }, + "workspaces": [ + "${studentParentWorkingDirectoryName}" + ], + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "ts-jest": "^29.2.5", + "typescript": "^5.6.2" + }, + "jest-junit": { + "classNameTemplate": "{classname}_{title}", + "titleTemplate": "{classname}_{title}", + "ancestorSeparator": "_" + } +} diff --git a/src/main/resources/templates/typescript/test/src/behavior.test.ts b/src/main/resources/templates/typescript/test/src/behavior.test.ts new file mode 100644 index 000000000000..d18092bff9aa --- /dev/null +++ b/src/main/resources/templates/typescript/test/src/behavior.test.ts @@ -0,0 +1,77 @@ +import MergeSort from 'artemis-exercise/mergesort'; +import BubbleSort from 'artemis-exercise/bubblesort'; +import Context from 'artemis-exercise/context'; +import Policy from 'artemis-exercise/policy'; + +// incorrect type structure should fail with runtime errors +const _MergeSort: any = MergeSort; +const _BubbleSort: any = BubbleSort; +const _Context: any = Context; +const _Policy: any = Policy; + +// prettier-ignore +const datesWithCorrectOrder = [ + new Date('2016-02-15'), + new Date('2017-04-15'), + new Date('2017-09-15'), + new Date('2018-11-08'), +]; + +describe('behavior', () => { + let dates: Array; + beforeEach(() => { + // prettier-ignore + dates = [ + new Date('2018-11-08'), + new Date('2017-04-15'), + new Date('2016-02-15'), + new Date('2017-09-15'), + ]; + }); + + describe('BubbleSort', () => { + it('should_sort_correctly', () => { + const bubbleSort = new _BubbleSort(); + bubbleSort.performSort(dates); + expect(dates).toEqual(datesWithCorrectOrder); + }); + }); + + describe('MergeSort', () => { + it('should_sort_correctly', () => { + const mergeSort = new _MergeSort(); + mergeSort.performSort(dates); + expect(dates).toEqual(datesWithCorrectOrder); + }); + }); + + describe('Policy', () => { + it('uses_MergeSort_for_big_list', () => { + const bigList: Array = []; + for (let i = 0; i < 11; i++) { + bigList.push(new Date()); + } + + const context = new _Context(); + context.dates = bigList; + const policy = new _Policy(context); + policy.configure(); + const chosenSortStrategy = context.sortAlgorithm; + expect(chosenSortStrategy).toBeInstanceOf(_MergeSort); + }); + + it('uses_BubbleSort_for_small_list', () => { + const smallList: Array = []; + for (let i = 0; i < 3; i++) { + smallList.push(new Date()); + } + + const context = new _Context(); + context.dates = smallList; + const policy = new _Policy(context); + policy.configure(); + const chosenSortStrategy = context.sortAlgorithm; + expect(chosenSortStrategy).toBeInstanceOf(_BubbleSort); + }); + }); +}); diff --git a/src/main/resources/templates/typescript/test/src/structural.test.ts b/src/main/resources/templates/typescript/test/src/structural.test.ts new file mode 100644 index 000000000000..e6048d0e3f40 --- /dev/null +++ b/src/main/resources/templates/typescript/test/src/structural.test.ts @@ -0,0 +1,50 @@ +import MergeSort from 'artemis-exercise/mergesort'; +import BubbleSort from 'artemis-exercise/bubblesort'; +import Context from 'artemis-exercise/context'; +import Policy from 'artemis-exercise/policy'; + +// incorrect type structure should fail with runtime errors +const _MergeSort: any = MergeSort; +const _BubbleSort: any = BubbleSort; +const _Context: any = Context; +const _Policy: any = Policy; + +describe('structural', () => { + describe('Context', () => { + const context = new _Context(); + + it('has_properties', () => { + expect(context).toHaveProperty('dates'); + expect(context).toHaveProperty('sortAlgorithm'); + }); + + it('has_methods', () => { + expect(context).toHaveProperty('sort', expect.any(Function)); + }); + }); + + describe('Policy', () => { + const context = new _Context(); + const policy = new _Policy(context); + + it('has_properties', () => { + expect(policy).toHaveProperty('context'); + }); + + it('has_methods', () => { + expect(policy).toHaveProperty('configure', expect.any(Function)); + }); + }); + + describe('BubbleSort', () => { + it('has_method', () => { + expect(_BubbleSort.prototype).toHaveProperty('performSort', expect.any(Function)); + }); + }); + + describe('MergeSort', () => { + it('has_method', () => { + expect(_MergeSort.prototype).toHaveProperty('performSort', expect.any(Function)); + }); + }); +}); diff --git a/src/main/resources/templates/typescript/test/tsconfig.json b/src/main/resources/templates/typescript/test/tsconfig.json new file mode 100644 index 000000000000..d7b28c1a1dbf --- /dev/null +++ b/src/main/resources/templates/typescript/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "references": [ + { + "path": "${studentParentWorkingDirectoryName}" + } + ] +} 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/metrics/metrics.model.ts b/src/main/webapp/app/admin/metrics/metrics.model.ts index dbed33af6fc7..cc476415b8ad 100644 --- a/src/main/webapp/app/admin/metrics/metrics.model.ts +++ b/src/main/webapp/app/admin/metrics/metrics.model.ts @@ -83,6 +83,7 @@ export interface Services { export enum HttpMethod { Post = 'POST', Get = 'GET', + Put = 'PUT', Delete = 'DELETE', Patch = 'PATCH', } 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 3ed378a83525..f6e060a63ab1 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); @@ -224,6 +225,10 @@ export class AccountService implements IAccountService { return this.hasAnyAuthorityDirect([Authority.ADMIN]); } + isAtLeastTutor(): boolean { + return this.hasAnyAuthorityDirect([Authority.ADMIN, Authority.EDITOR, Authority.INSTRUCTOR, Authority.TA]); + } + isAuthenticated(): boolean { return this.authenticated; } diff --git a/src/main/webapp/app/core/config/monaco.config.ts b/src/main/webapp/app/core/config/monaco.config.ts index aa40e47c177c..f37dfe5a4069 100644 --- a/src/main/webapp/app/core/config/monaco.config.ts +++ b/src/main/webapp/app/core/config/monaco.config.ts @@ -1,19 +1,23 @@ /** * Sets up the MonacoEnvironment for the monaco editor's service worker. + * See https://github.com/microsoft/monaco-editor/blob/main/samples/browser-esm-esbuild/index.js */ export function MonacoConfig() { self.MonacoEnvironment = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getWorkerUrl: function (workerId: string, label: string) { - /* - * This is the AMD-based service worker, which comes bundled with a few special workers for selected languages. - * (e.g.: javascript, typescript, html, css) - * - * It is also possible to use an ESM-based approach, which requires a little more setup and case distinctions in this method. - * At the moment, it seems that the ESM-based approaches are incompatible with the Artemis client, as they would require custom builders. - * Support for custom builders was removed in #6546. - */ - return 'vs/base/worker/workerMain.js'; + getWorkerUrl: (_moduleId: string, label: string): string => { + if (label === 'json') { + return './vs/language/json/json.worker.js'; + } + if (label === 'css' || label === 'scss' || label === 'less') { + return './vs/language/css/css.worker.js'; + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return './vs/language/html/html.worker.js'; + } + if (label === 'typescript' || label === 'javascript') { + return './vs/language/typescript/ts.worker.js'; + } + return './vs/editor/editor.worker.js'; }, }; } 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/core/user/admin-user.service.ts b/src/main/webapp/app/core/user/admin-user.service.ts index 0614e9229d3b..73e14d154597 100644 --- a/src/main/webapp/app/core/user/admin-user.service.ts +++ b/src/main/webapp/app/core/user/admin-user.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { createRequestOption } from 'app/shared/util/request.util'; @@ -94,11 +94,7 @@ export class AdminUserService { * @return Observable> */ deleteUsers(logins: string[]): Observable> { - let params = new HttpParams(); - for (const login of logins) { - params = params.append('login', login); - } - return this.http.delete(`${this.resourceUrl}`, { params, observe: 'response' }); + return this.http.delete(`${this.resourceUrl}`, { body: logins, observe: 'response' }); } /** diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index 816cf4fc9a9c..f793581b21a3 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -16,6 +16,7 @@ export class User extends Account { public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; public sshPublicKey?: string; + public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( @@ -66,6 +67,7 @@ export class UserPublicInfoDTO { public firstName?: string; public lastName?: string; public email?: string; + public imageUrl?: string; public isInstructor?: boolean; public isEditor?: boolean; public isTeachingAssistant?: boolean; diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts index e60ec966042a..0ee37dd08169 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts @@ -1,15 +1,7 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, inject } from '@angular/core'; import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; -import { - CompetencyRelation, - CompetencyRelationDTO, - CompetencyWithTailRelationDTO, - CourseCompetency, - CourseCompetencyType, - dtoToCompetencyRelation, - getIcon, -} from 'app/entities/competency.model'; +import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { filter, map } from 'rxjs/operators'; import { onError } from 'app/shared/util/global.utils'; @@ -30,7 +22,6 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; export class CompetencyManagementTableComponent implements OnInit, OnDestroy { @Input() courseId: number; @Input() courseCompetencies: CourseCompetency[]; - @Input() relations: CompetencyRelation[]; @Input() competencyType: CourseCompetencyType; @Input() standardizedCompetenciesEnabled: boolean; @@ -103,14 +94,7 @@ export class CompetencyManagementTableComponent implements OnInit, OnDestroy { */ updateDataAfterImportAll(res: Array) { const importedCompetencies = res.map((dto) => dto.competency).filter((element): element is CourseCompetency => !!element); - - const importedRelations = res - .map((dto) => dto.tailRelations) - .flat() - .filter((element): element is CompetencyRelationDTO => !!element) - .map((dto) => dtoToCompetencyRelation(dto)); this.courseCompetencies.push(...importedCompetencies); - this.relations.push(...importedRelations); } /** diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html index 889576aa7830..a70934974f15 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html @@ -1,7 +1,12 @@
    -

    - +
    +

    + +
    @if (irisCompetencyGenerationEnabled) { @@ -9,6 +14,10 @@

    } +
    } - { @@ -75,6 +72,11 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { await this.loadData(); this.loadIrisEnabled(); }); + const lastVisit = sessionStorage.getItem('lastTimeVisitedCourseCompetencyExplanation'); + if (!lastVisit) { + this.openCourseCompetencyExplanation(); + } + sessionStorage.setItem('lastTimeVisitedCourseCompetencyExplanation', Date.now().toString()); this.standardizedCompetencySubscription = this.featureToggleService.getFeatureToggleActive(FeatureToggle.StandardizedCompetencies).subscribe((isActive) => { this.standardizedCompetenciesEnabled = isActive; }); @@ -104,12 +106,11 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { } /** - * Loads all data for the competency management: Prerequisites, competencies (with average course progress) and competency relations + * Loads all data for the competency management: Prerequisites and competencies (with average course progress) */ async loadData() { try { this.isLoading = true; - this.relations = (await this.courseCompetencyApiService.getCourseCompetencyRelations(this.courseId)).map(dtoToCompetencyRelation); this.courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesByCourseId(this.courseId); this.competencies = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.COMPETENCY); this.prerequisites = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.PREREQUISITE); @@ -120,6 +121,16 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { } } + protected openCourseCompetenciesRelationModal(): void { + const modalRef = this.modalService.open(CourseCompetenciesRelationModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'course-competencies-relation-graph-modal', + }); + modalRef.componentInstance.courseId = signal(this.courseId); + modalRef.componentInstance.courseCompetencies = signal(this.courseCompetencies); + } + /** * Opens a modal for selecting a course to import all competencies from. */ @@ -151,82 +162,30 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { } /** - * Updates the component and its relation chart with the new data from the importAll modal - * @param res Array of DTOs containing the new competencies and relations + * Updates the component with the new data from the importAll modal + * @param res Array of DTOs containing the new competencies * @private */ updateDataAfterImportAll(res: Array) { const importedCompetencies = res.map((dto) => dto.competency).filter((element): element is Competency => element?.type === CourseCompetencyType.COMPETENCY); const importedPrerequisites = res.map((dto) => dto.competency).filter((element): element is Prerequisite => element?.type === CourseCompetencyType.PREREQUISITE); - const importedRelations = res - .map((dto) => dto.tailRelations) - .flat() - .filter((element): element is CompetencyRelationDTO => !!element) - .map(dtoToCompetencyRelation); this.competencies = this.competencies.concat(importedCompetencies); this.prerequisites = this.prerequisites.concat(importedPrerequisites); this.courseCompetencies = this.competencies.concat(this.prerequisites); - this.relations = this.relations.concat(importedRelations); - } - - /** - * creates a given competency relation - * - * @param relation the given competency relation - */ - async createRelation(relation: CompetencyRelation) { - try { - const createdRelation = await this.courseCompetencyApiService.createCourseCompetencyRelation(this.courseId, { - headCompetencyId: relation.headCompetency?.id, - tailCompetencyId: relation.tailCompetency?.id, - relationType: relation.type, - }); - this.relations = this.relations.concat(dtoToCompetencyRelation(createdRelation)); - } catch (error) { - onError(this.alertService, error); - } - } - - /** - * Opens a confirmation dialog and if confirmed, deletes a competency relation with the given id - * - * @param relationId the given id - */ - onRemoveRelation(relationId: number) { - const relation = this.relations.find((relation) => relation.id === relationId); - const headId = relation?.headCompetency?.id; - const tailId = relation?.tailCompetency?.id; - const titleHead = this.courseCompetencies.find((competency) => competency.id === headId)?.title ?? ''; - const titleTail = this.courseCompetencies.find((competency) => competency.id === tailId)?.title ?? ''; - - const modalRef = this.modalService.open(ConfirmAutofocusModalComponent, { keyboard: true, size: 'md' }); - modalRef.componentInstance.title = 'artemisApp.competency.manage.deleteRelationModalTitle'; - modalRef.componentInstance.text = this.translateService.instant('artemisApp.competency.manage.deleteRelationModalText', { - titleTail: titleTail, - titleHead: titleHead, - }); - modalRef.result.then(() => this.removeRelation(relationId)); - } - - /** - * deletes a competency relation with the given id - * - * @param relationId the given id - */ - private async removeRelation(relationId: number) { - try { - await this.courseCompetencyApiService.deleteCourseCompetencyRelation(this.courseId, relationId); - this.relations = this.relations.filter((relation) => relation.id !== relationId); - } catch (error) { - onError(this.alertService, error); - } } onRemoveCompetency(competencyId: number) { this.competencies = this.competencies.filter((competency) => competency.id !== competencyId); this.prerequisites = this.prerequisites.filter((prerequisite) => prerequisite.id !== competencyId); - this.relations = this.relations.filter((relation) => relation.tailCompetency?.id !== competencyId && relation.headCompetency?.id !== competencyId); this.courseCompetencies = this.competencies.concat(this.prerequisites); } + + openCourseCompetencyExplanation(): void { + this.modalService.open(CourseCompetencyExplanationModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'course-competency-explanation-modal', + }); + } } diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html deleted file mode 100644 index c16d5c6bd2d9..000000000000 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html +++ /dev/null @@ -1,133 +0,0 @@ -
    -
    -
    -
    - -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - -
    -
    -
    - @if (relationError) { - - } - -
    - - - - - - - - - - - - {{ node.label }} - - - - - - - - - {{ ('artemisApp.competency.relation.type.' + link.label | artemisTranslate).toUpperCase() }} - - - - - -
    -
    -
    -
    -
    -
    diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss deleted file mode 100644 index e639f3e1bbfe..000000000000 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss +++ /dev/null @@ -1,28 +0,0 @@ -.accordion-body { - overflow: hidden; - max-height: 60vh; -} - -.node { - text { - fill: var(--body-color); - } - - rect { - fill: var(--primary); - } -} - -.edge { - stroke: var(--body-color) !important; - marker-end: url(#arrow); -} - -#arrow { - stroke: var(--body-color); - fill: var(--body-color); -} - -.text-path { - fill: var(--body-color); -} diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts deleted file mode 100644 index 2fc93e4a8e63..000000000000 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { Component, EventEmitter, Output, computed, input } from '@angular/core'; -import { faArrowsToEye } from '@fortawesome/free-solid-svg-icons'; -import { Edge, NgxGraphZoomOptions, Node } from '@swimlane/ngx-graph'; -import { CompetencyRelation, CompetencyRelationError, CompetencyRelationType, CourseCompetency } from 'app/entities/competency.model'; -import { Subject } from 'rxjs'; - -@Component({ - selector: 'jhi-competency-relation-graph', - templateUrl: './competency-relation-graph.component.html', - styleUrls: ['./competency-relation-graph.component.scss'], -}) -export class CompetencyRelationGraphComponent { - competencies = input([]); - relations = input([]); - - @Output() onRemoveRelation = new EventEmitter(); - @Output() onCreateRelation = new EventEmitter(); - - nodes = computed(() => { - this.update$.next(true); - return this.competencies().map((competency): Node => { - return { - id: `${competency.id}`, - label: competency.title, - }; - }); - }); - - edges = computed(() => { - this.update$.next(true); - return this.relations().map( - (relation): Edge => ({ - id: `edge${relation.id}`, - source: `${relation.tailCompetency?.id}`, - target: `${relation.headCompetency?.id}`, - label: relation.type, - data: { - id: relation.id, - }, - }), - ); - }); - - tailCompetencyId?: number; - headCompetencyId?: number; - relationType?: CompetencyRelationType; - relationError?: CompetencyRelationError = undefined; - update$: Subject = new Subject(); - center$: Subject = new Subject(); - zoomToFit$: Subject = new Subject(); - - // icons - protected readonly faArrowsToEye = faArrowsToEye; - - // constants - protected readonly competencyRelationType = CompetencyRelationType; - protected readonly errorMessage: Record = { - CIRCULAR: 'artemisApp.competency.relation.createsCircularRelation', - EXISTING: 'artemisApp.competency.relation.relationAlreadyExists', - SELF: 'artemisApp.competency.relation.selfRelation', - }; - - /** - * creates a relation with the currently entered data if it would not cause an error - */ - createRelation() { - this.validate(); - if (this.relationError) { - return; - } - const relation: CompetencyRelation = { - tailCompetency: { id: this.tailCompetencyId }, - headCompetency: { id: this.headCompetencyId }, - type: this.relationType, - }; - this.onCreateRelation.emit(relation); - } - - /** - * removes the relation - * @param edge the edge symbolizing the relation - */ - removeRelation(edge: Edge) { - this.onRemoveRelation.emit(edge.data.id); - } - - centerView() { - this.zoomToFit$.next({ autoCenter: true }); - this.center$.next(true); - } - - /** - * Validates if the currently entered data would cause an error and sets relationError accordingly - */ - validate(): void { - if (!this.tailCompetencyId || !this.headCompetencyId || !this.relationType) { - this.relationError = undefined; - return; - } - if (this.headCompetencyId === this.tailCompetencyId) { - this.relationError = CompetencyRelationError.SELF; - return; - } - if (this.doesRelationAlreadyExist()) { - this.relationError = CompetencyRelationError.EXISTING; - return; - } - if (this.containsCircularRelation()) { - this.relationError = CompetencyRelationError.CIRCULAR; - return; - } - this.relationError = undefined; - } - - /** - * checks if the currently entered data is equal to an existing relation - * @private - */ - private doesRelationAlreadyExist(): boolean { - return !!this.edges().find((edge) => edge.source === this.tailCompetencyId?.toString() && edge.target === this.headCompetencyId?.toString()); - } - - /** - * Checks if the currently entered data would create a circular relation - * - * @private - */ - private containsCircularRelation(): boolean { - if (!this.tailCompetencyId || !this.headCompetencyId || !this.relationType) { - return false; - } - return this.doesCreateCircularRelation(this.nodes(), this.edges(), { - source: this.tailCompetencyId! + '', - target: this.headCompetencyId! + '', - label: this.relationType!, - } as Edge); - } - - /** - * Checks if adding an edge would create a circular relation - * @param {Node[]} nodes an array of all existing nodes of a graph - * @param {Edge[]} edges an array of all existing edges of a graph - * @param {Edge} edgeToAdd the edge that you try to add to the graph - * - * @returns {boolean} whether or not adding the provided edge would result in a circle in the graph - */ - private doesCreateCircularRelation(nodes: Node[], edges: Edge[], edgeToAdd: Edge): boolean { - const edgesWithNewEdge = JSON.parse(JSON.stringify(edges)); - edgesWithNewEdge.push(edgeToAdd); - const graph = new Graph(); - for (const node of nodes) { - graph.addVertex(new Vertex(node.id)); - } - for (const edge of edgesWithNewEdge) { - const headVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.target); - const tailVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.source); - if (headVertex === undefined || tailVertex === undefined) { - throw new TypeError('Every edge needs a source or a target.'); - } - // only extends and assumes relations are considered when checking for circles because only they don't make sense - // MATCHES relations are considered in the next step by merging the edges and combining the adjacencyLists - switch (edge.label) { - case 'EXTENDS': - case 'ASSUMES': { - graph.addEdge(tailVertex, headVertex); - break; - } - } - } - // combine vertices that are connected through MATCHES - for (const edge of edgesWithNewEdge) { - if (edge.label === 'MATCHES') { - const headVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.target); - const tailVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.source); - if (headVertex === undefined || tailVertex === undefined) { - throw new TypeError('Every edge needs a source or a target.'); - } - if (headVertex.getAdjacencyList().includes(tailVertex) || tailVertex.getAdjacencyList().includes(headVertex)) { - return true; - } - // create a merged vertex - const mergedVertex = new Vertex(tailVertex.getLabel() + ', ' + headVertex.getLabel()); - // add all neighbours to merged vertex - mergedVertex.getAdjacencyList().push(...headVertex.getAdjacencyList()); - mergedVertex.getAdjacencyList().push(...tailVertex.getAdjacencyList()); - // update every vertex that initially had one of the two merged vertices as neighbours to now reference the merged vertex - for (const vertex of graph.vertices) { - for (const adjacentVertex of vertex.getAdjacencyList()) { - if (adjacentVertex.getLabel() === headVertex.getLabel() || adjacentVertex.getLabel() === tailVertex.getLabel()) { - const index = vertex.getAdjacencyList().indexOf(adjacentVertex, 0); - if (index > -1) { - vertex.getAdjacencyList().splice(index, 1); - } - vertex.getAdjacencyList().push(mergedVertex); - } - } - } - } - } - return graph.hasCycle(); - } - - /** - * Keeps order of elements as-is in the keyvalue pipe - */ - keepOrder = () => { - return 0; - }; -} - -/** - * A class that represents a vertex in a graph - * @class - * - * @constructor - * - * @property label a label to identify the vertex (we use the node id) - * @property beingVisited is the vertex the one that is currently being visited during the graph traversal - * @property visited has this vertex been visited before - * @property adjacencyList an array that contains all adjacent vertices - */ -class Vertex { - private readonly label: string; - private beingVisited: boolean; - private visited: boolean; - private readonly adjacencyList: Vertex[]; - - constructor(label: string) { - this.label = label; - this.adjacencyList = []; - } - - getLabel(): string { - return this.label; - } - - addNeighbor(adjacent: Vertex): void { - this.adjacencyList.push(adjacent); - } - - getAdjacencyList(): Vertex[] { - return this.adjacencyList; - } - - isBeingVisited(): boolean { - return this.beingVisited; - } - - setBeingVisited(beingVisited: boolean): void { - this.beingVisited = beingVisited; - } - - isVisited(): boolean { - return this.visited; - } - - setVisited(visited: boolean) { - this.visited = visited; - } -} - -/** - * A class that represents a graph - * @class - * - * @constructor - * - * @property vertices an array of all vertices in the graph (edges are represented by the adjacent vertices property of each vertex) - */ -class Graph { - vertices: Vertex[]; - - constructor() { - this.vertices = []; - } - - public addVertex(vertex: Vertex): void { - this.vertices.push(vertex); - } - - public addEdge(from: Vertex, to: Vertex): void { - from.addNeighbor(to); - } - - /** - * Checks if the graph contains a circle - * - * @returns {boolean} whether or not the graph contains a circle - */ - public hasCycle(): boolean { - // we have to check for every vertex if it is part of a cycle in case the graph is not connected - for (const vertex of this.vertices) { - if (!vertex.isVisited() && this.vertexHasCycle(vertex)) { - return true; - } - } - return false; - } - - /** - * Checks if a vertex is part of a circle - * - * @returns {boolean} whether or not the vertex is part of a circle - */ - private vertexHasCycle(sourceVertex: Vertex): boolean { - sourceVertex.setBeingVisited(true); - - for (const neighbor of sourceVertex.getAdjacencyList()) { - if (neighbor.isBeingVisited() || (!neighbor.isVisited() && this.vertexHasCycle(neighbor))) { - // backward edge exists - return true; - } - } - - sourceVertex.setBeingVisited(false); - sourceVertex.setVisited(true); - return false; - } -} diff --git a/src/main/webapp/app/course/competencies/competency.module.ts b/src/main/webapp/app/course/competencies/competency.module.ts index d7b4c6da30b5..6065b7e90ccb 100644 --- a/src/main/webapp/app/course/competencies/competency.module.ts +++ b/src/main/webapp/app/course/competencies/competency.module.ts @@ -3,7 +3,6 @@ import { RouterModule } from '@angular/router'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CompetencyManagementComponent } from './competency-management/competency-management.component'; import { CompetencyCardComponent } from 'app/course/competencies/competency-card/competency-card.component'; import { CompetenciesPopoverComponent } from './competencies-popover/competencies-popover.component'; import { NgxGraphModule } from '@swimlane/ngx-graph'; @@ -15,7 +14,6 @@ import { CourseDescriptionFormComponent } from 'app/course/competencies/generate import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { IrisModule } from 'app/iris/iris.module'; import { TaxonomySelectComponent } from 'app/course/competencies/taxonomy-select/taxonomy-select.component'; -import { CompetencyRelationGraphComponent } from 'app/course/competencies/competency-management/competency-relation-graph.component'; import { CompetencyAccordionComponent } from 'app/course/competencies/competency-accordion/competency-accordion.component'; import { ArtemisCourseExerciseRowModule } from 'app/overview/course-exercises/course-exercise-row.module'; import { RatingModule } from 'app/exercises/shared/rating/rating.module'; @@ -49,13 +47,11 @@ import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown CompetencySearchComponent, CompetencyRecommendationDetailComponent, CourseDescriptionFormComponent, - CompetencyManagementComponent, CompetencyCardComponent, CompetencyAccordionComponent, CompetenciesPopoverComponent, ImportCompetenciesTableComponent, TaxonomySelectComponent, - CompetencyRelationGraphComponent, ], exports: [ CompetencyCardComponent, diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html new file mode 100644 index 000000000000..d90c81fd3714 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html @@ -0,0 +1,36 @@ +
    + + + + + + + + + + + + + + + + + + + {{ ('artemisApp.courseCompetency.relations.relationTypes.' + link.label | artemisTranslate).toUpperCase() }} + + + + + +
    diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss new file mode 100644 index 000000000000..add2bfbd3928 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss @@ -0,0 +1,14 @@ +.course-competencies-graph-container { + #arrow { + stroke: var(--body-color); + fill: var(--body-color); + } + + .selected { + stroke: var(--bs-primary); + } + + .text-path { + fill: var(--body-color); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts new file mode 100644 index 000000000000..448d2e4b1d77 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts @@ -0,0 +1,80 @@ +import { Component, computed, effect, input, model, output, signal } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faFileImport } from '@fortawesome/free-solid-svg-icons'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyRelationDTO, CourseCompetency } from 'app/entities/competency.model'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { Edge, NgxGraphModule, Node } from '@swimlane/ngx-graph'; +import { Subject } from 'rxjs'; +import { SizeUpdate } from 'app/course/learning-paths/components/competency-node/competency-node.component'; +import { CourseCompetencyRelationNodeComponent } from 'app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component'; + +@Component({ + selector: 'jhi-course-competencies-relation-graph', + standalone: true, + imports: [FontAwesomeModule, NgbAccordionModule, NgxGraphModule, ArtemisSharedModule, CourseCompetencyRelationNodeComponent], + templateUrl: './course-competencies-relation-graph.component.html', + styleUrl: './course-competencies-relation-graph.component.scss', +}) +export class CourseCompetenciesRelationGraphComponent { + protected readonly faFileImport = faFileImport; + + readonly courseCompetencies = input.required(); + readonly relations = input.required(); + + readonly selectedRelationId = model.required(); + + readonly onCourseCompetencySelection = output(); + + readonly update$ = new Subject(); + readonly center$ = new Subject(); + + readonly nodes = signal([]); + + readonly edges = computed(() => { + return this.relations().map((relation) => ({ + id: `edge-${relation.id}`, + source: `${relation.headCompetencyId}`, + target: `${relation.tailCompetencyId}`, + label: relation.relationType, + data: { + id: relation.id, + }, + })); + }); + + constructor() { + effect( + () => { + return this.nodes.set( + this.courseCompetencies().map( + (courseCompetency): Node => ({ + id: courseCompetency.id!.toString(), + label: courseCompetency.title, + data: { + id: courseCompetency.id, + type: courseCompetency.type, + }, + }), + ), + ); + }, + { allowSignalWrites: true }, + ); + } + + protected selectRelation(relationId: number): void { + this.selectedRelationId.set(relationId); + } + + protected setNodeDimension(sizeUpdate: SizeUpdate): void { + this.nodes.update((nodes) => + nodes.map((node) => { + if (node.id === sizeUpdate.id) { + node.dimension = sizeUpdate.dimension; + } + return node; + }), + ); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html new file mode 100644 index 000000000000..5e14b7fb7680 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html @@ -0,0 +1,33 @@ +
    +
    +
    + + +
    +
    +
    +
    + @if (isLoading()) { +
    +
    + +
    +
    + } @else { +
    + +
    + + } +
    +
    diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss new file mode 100644 index 000000000000..bc1c8d310a04 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss @@ -0,0 +1,5 @@ +.course-competencies-graph-modal { + height: 90vh; + max-height: 700px; + overflow: hidden; +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts new file mode 100644 index 000000000000..df0547c57a44 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts @@ -0,0 +1,57 @@ +import { Component, effect, inject, input, signal, viewChild } from '@angular/core'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { CompetencyRelationDTO, CourseCompetency } from 'app/entities/competency.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { CourseCompetencyRelationFormComponent } from 'app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component'; +import { CourseCompetenciesRelationGraphComponent } from '../course-competencies-relation-graph/course-competencies-relation-graph.component'; + +@Component({ + selector: 'jhi-course-competencies-relation-modal', + standalone: true, + imports: [ArtemisSharedCommonModule, CompetencyGraphComponent, CourseCompetenciesRelationGraphComponent, CourseCompetencyRelationFormComponent], + templateUrl: './course-competencies-relation-modal.component.html', + styleUrl: './course-competencies-relation-modal.component.scss', +}) +export class CourseCompetenciesRelationModalComponent { + private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); + private readonly alertService = inject(AlertService); + private readonly activeModal = inject(NgbActiveModal); + + private readonly courseCompetencyRelationFormComponent = viewChild.required(CourseCompetencyRelationFormComponent); + + readonly courseId = input.required(); + readonly courseCompetencies = input.required(); + + readonly selectedRelationId = signal(undefined); + + readonly isLoading = signal(false); + readonly relations = signal([]); + + constructor() { + effect(() => this.loadRelations(this.courseId()), { allowSignalWrites: true }); + } + + private async loadRelations(courseId: number): Promise { + try { + this.isLoading.set(true); + const relations = await this.courseCompetencyApiService.getCourseCompetencyRelationsByCourseId(courseId); + this.relations.set(relations); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected selectCourseCompetency(courseCompetencyId: number) { + this.courseCompetencyRelationFormComponent().selectCourseCompetency(courseCompetencyId); + } + + protected closeModal(): void { + this.activeModal.close(); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.html b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.html new file mode 100644 index 000000000000..1215b5f095af --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.html @@ -0,0 +1,62 @@ +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +

    +

    + +
    +

    +

    +
    +
    +
    +
    +

    +

    +

    +

    +
    + How to create a course competency +
    +

    +
    +
    +
    +
    +

    + +

    +

    +

    +
    + How to create a course competency +
    +
    +
    +

    +

    +

    +
    +
    +
    +

    +

    +

    +
    +
    +
    +

    +

    +

    +
    +
    +
    +
    diff --git a/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.scss b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.scss new file mode 100644 index 000000000000..91fd4bf8bd19 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.scss @@ -0,0 +1,12 @@ +.explanation-model-gif { + display: block; + max-width: 800px; + margin: 0 auto; // Centers the container horizontally + + img { + width: 100%; // Makes both GIFs responsive + height: auto; // Maintains aspect ratio + display: block; // Centers the image horizontally + margin: 0 auto; + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.ts b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.ts new file mode 100644 index 000000000000..8cd333d439f3 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component.ts @@ -0,0 +1,25 @@ +import { Component, inject } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +@Component({ + selector: 'jhi-course-competency-explanation-modal', + standalone: true, + imports: [CompetencyGraphComponent, TranslateDirective, FontAwesomeModule], + templateUrl: './course-competency-explanation-modal.component.html', + styleUrl: './course-competency-explanation-modal.component.scss', +}) +export class CourseCompetencyExplanationModalComponent { + protected readonly closeIcon = faXmark; + + protected readonly DOCUMENTATION_LINK = 'https://docs.artemis.cit.tum.de/user/adaptive-learning/'; + + private readonly activeModal = inject(NgbActiveModal); + + protected closeModal(): void { + this.activeModal.close(); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html new file mode 100644 index 000000000000..5d8be2495c7c --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html @@ -0,0 +1,70 @@ +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + @if (exactRelationAlreadyExists()) { + + } @else if (relationAlreadyExists()) { + + } @else { + + } +
    + @if (showCircularDependencyError()) { + + } +
    diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss new file mode 100644 index 000000000000..84ed3ff63b6a --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss @@ -0,0 +1,4 @@ +.course-competency-relation-form-container { + background-color: var(--bs-body-bg); + border-radius: var(--bs-border-radius-lg); +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts new file mode 100644 index 000000000000..7f173f4968a3 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts @@ -0,0 +1,306 @@ +import { Component, computed, effect, inject, input, model, signal } from '@angular/core'; +import { CompetencyRelationDTO, CompetencyRelationType, CourseCompetency, UpdateCourseCompetencyRelationDTO } from 'app/entities/competency.model'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-course-competency-relation-form', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './course-competency-relation-form.component.html', + styleUrl: './course-competency-relation-form.component.scss', +}) +export class CourseCompetencyRelationFormComponent { + protected readonly faSpinner = faSpinner; + + protected readonly competencyRelationType = CompetencyRelationType; + + private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + readonly courseCompetencies = input.required(); + readonly relations = model.required(); + readonly selectedRelationId = model.required(); + + readonly headCompetencyId = signal(undefined); + readonly tailCompetencyId = signal(undefined); + readonly relationType = model(undefined); + + readonly isLoading = signal(false); + + readonly relationAlreadyExists = computed(() => this.getRelation(this.headCompetencyId(), this.tailCompetencyId()) !== undefined); + readonly exactRelationAlreadyExists = computed(() => this.getExactRelation(this.headCompetencyId(), this.tailCompetencyId(), this.relationType()) !== undefined); + + private readonly selectableTailCourseCompetencyIds = computed(() => { + if (this.headCompetencyId() && this.relationType()) { + return this.getSelectableTailCompetencyIds(this.headCompetencyId()!, this.relationType()!); + } + return this.courseCompetencies().map(({ id }) => id!); + }); + + readonly showCircularDependencyError = computed(() => this.tailCompetencyId() && !this.selectableTailCourseCompetencyIds().includes(this.tailCompetencyId()!)); + + constructor() { + effect(() => this.selectRelation(this.selectedRelationId()), { allowSignalWrites: true }); + } + + protected isCourseCompetencySelectable(courseCompetencyId: number): boolean { + return this.selectableTailCourseCompetencyIds().includes(courseCompetencyId); + } + + private selectRelation(relationId?: number): void { + const relation = this.relations().find(({ id }) => id === relationId); + if (relation) { + this.headCompetencyId.set(relation?.headCompetencyId); + this.tailCompetencyId.set(relation?.tailCompetencyId); + this.relationType.set(relation?.relationType); + } + } + + public selectCourseCompetency(courseCompetencyId: number): void { + if (!this.headCompetencyId()) { + this.selectHeadCourseCompetency(courseCompetencyId); + } else if (!this.tailCompetencyId()) { + this.selectTailCourseCompetency(courseCompetencyId); + } else { + this.selectHeadCourseCompetency(courseCompetencyId); + } + } + + protected selectHeadCourseCompetency(headId: number) { + this.headCompetencyId.set(headId); + this.tailCompetencyId.set(undefined); + this.selectedRelationId.set(undefined); + } + + protected selectTailCourseCompetency(tailId: number) { + this.tailCompetencyId.set(tailId); + const existingRelation = this.getRelation(this.headCompetencyId(), this.tailCompetencyId()); + if (existingRelation) { + this.selectedRelationId.set(existingRelation.id); + } else { + this.selectedRelationId.set(undefined); + } + } + + protected async createRelation(): Promise { + try { + this.isLoading.set(true); + const courseCompetencyRelation = await this.courseCompetencyApiService.createCourseCompetencyRelation(this.courseId(), { + headCompetencyId: this.headCompetencyId()!, + tailCompetencyId: Number(this.tailCompetencyId()!), + relationType: this.relationType()!, + }); + this.relations.update((relations) => [...relations, courseCompetencyRelation]); + this.selectedRelationId.set(courseCompetencyRelation.id!); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + protected getExactRelation(headCompetencyId?: number, tailCompetencyId?: number, relationType?: CompetencyRelationType): CompetencyRelationDTO | undefined { + return this.relations().find( + (relation) => relation.headCompetencyId === headCompetencyId && relation.tailCompetencyId === tailCompetencyId && relation.relationType === relationType, + ); + } + + protected getRelation(headCompetencyId?: number, tailCompetencyId?: number): CompetencyRelationDTO | undefined { + return this.relations().find((relation) => relation.headCompetencyId === headCompetencyId && relation.tailCompetencyId === tailCompetencyId); + } + + protected async updateRelation(): Promise { + try { + this.isLoading.set(true); + const newRelationType = this.relationType()!; + await this.courseCompetencyApiService.updateCourseCompetencyRelation(this.courseId(), this.selectedRelationId()!, { + newRelationType: newRelationType, + }); + this.relations.update((relations) => + relations.map((relation) => { + if (relation.id === this.selectedRelationId()) { + return { ...relation, relationType: newRelationType }; + } + return relation; + }), + ); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + protected async deleteRelation(): Promise { + try { + this.isLoading.set(true); + const deletedRelation = this.relations().find( + ({ headCompetencyId, tailCompetencyId, relationType }) => + headCompetencyId == this.headCompetencyId() && tailCompetencyId == this.tailCompetencyId() && relationType === this.relationType(), + ); + await this.courseCompetencyApiService.deleteCourseCompetencyRelation(this.courseId(), deletedRelation!.id!); + this.relations.update((relations) => relations.filter(({ id }) => id !== deletedRelation!.id)); + this.selectedRelationId.set(undefined); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + /** + * Function to get the selectable tail competency ids for the given head + * competency and relation type without creating a cyclic dependency + * + * @param headCompetencyId The selected head competency id + * @param relationType The selected relation type + * @private + * + * @returns The selectable tail competency ids + */ + private getSelectableTailCompetencyIds(headCompetencyId: number, relationType: CompetencyRelationType): number[] { + return this.courseCompetencies() + .map(({ id }) => id!) + .filter((id) => id !== headCompetencyId) // Exclude the head itself + .filter((id) => { + let relations = this.relations(); + const existingRelation = this.getRelation(headCompetencyId, id); + if (existingRelation) { + relations = relations.filter((relation) => relation.id !== existingRelation.id); + } + const potentialRelation: CompetencyRelationDTO = { + headCompetencyId: headCompetencyId, + tailCompetencyId: id, + relationType: relationType, + }; + return !this.detectCycleInRelations(relations.concat(potentialRelation), this.courseCompetencies().length); + }); + } + + /** + * Function to detect cycles in the competency relations + * @param relations The list of competency relations + * @param numOfCompetencies The total number of competencies + * @private + * + * @returns True if a cycle is detected, false otherwise + */ + private detectCycleInRelations(relations: CompetencyRelationDTO[], numOfCompetencies: number): boolean { + // Create a map to store the competency IDs and map them to incremental indices + const idToIndexMap = new Map(); + let currentIndex = 0; + + // map the competency IDs to incremental indices + relations.forEach((relation) => { + const tail = relation.tailCompetencyId!; + const head = relation.headCompetencyId!; + + if (!idToIndexMap.has(tail)) { + idToIndexMap.set(tail, currentIndex++); + } + if (!idToIndexMap.has(head)) { + idToIndexMap.set(head, currentIndex++); + } + }); + + const unionFind = new UnionFind(numOfCompetencies); + + // Apply Union-Find based on the MATCHES relations + relations.forEach((relation) => { + if (relation.relationType === CompetencyRelationType.MATCHES) { + const tailIndex = idToIndexMap.get(relation.tailCompetencyId!); + const headIndex = idToIndexMap.get(relation.headCompetencyId!); + + if (tailIndex !== undefined && headIndex !== undefined) { + // Perform union operation to group matching course competencies into sets + unionFind.union(tailIndex, headIndex); + } + } + }); + + // Build the reduced graph for EXTENDS and ASSUMES relations + const reducedGraph: number[][] = Array.from({ length: numOfCompetencies }, () => []); + + relations.forEach((relation) => { + const tail = unionFind.find(idToIndexMap.get(relation.tailCompetencyId!)!); + const head = unionFind.find(idToIndexMap.get(relation.headCompetencyId!)!); + + if (relation.relationType === CompetencyRelationType.EXTENDS || relation.relationType === CompetencyRelationType.ASSUMES) { + reducedGraph[tail].push(head); + } + }); + + return this.hasCycle(reducedGraph, numOfCompetencies); + } + + private hasCycle(graph: number[][], noOfCourseCompetencies: number): boolean { + const visited: boolean[] = Array(noOfCourseCompetencies).fill(false); + const recursionStack: boolean[] = Array(noOfCourseCompetencies).fill(false); + + // Depth-first search to detect cycles + const depthFirstSearch = (v: number): boolean => { + visited[v] = true; + recursionStack[v] = true; + + for (const neighbor of graph[v] || []) { + if (!visited[neighbor]) { + if (depthFirstSearch(neighbor)) return true; + } else if (recursionStack[neighbor]) { + return true; + } + } + + recursionStack[v] = false; + return false; + }; + + for (let node = 0; node < noOfCourseCompetencies; node++) { + if (!visited[node]) { + if (depthFirstSearch(node)) { + return true; + } + } + } + return false; + } +} + +// Union-Find (Disjoint Set) class (https://en.wikipedia.org/wiki/Disjoint-set_data_structure -> union by rank) +export class UnionFind { + parent: number[]; + rank: number[]; + + constructor(size: number) { + this.parent = Array.from({ length: size }, (_, index) => index); + this.rank = Array(size).fill(1); + } + + // Find the representative of the set that contains the `competencyId` + public find(competencyId: number): number { + if (this.parent[competencyId] !== competencyId) { + this.parent[competencyId] = this.find(this.parent[competencyId]); // Path compression + } + return this.parent[competencyId]; + } + + // Union the sets containing `tailCompetencyId` and `headCompetencyId` + public union(tailCompetencyId: number, headCompetencyId: number) { + const rootU = this.find(tailCompetencyId); + const rootV = this.find(headCompetencyId); + if (rootU !== rootV) { + // Union by rank + if (this.rank[rootU] > this.rank[rootV]) { + this.parent[rootV] = rootU; + } else if (this.rank[rootU] < this.rank[rootV]) { + this.parent[rootU] = rootV; + } else { + this.parent[rootV] = rootU; + this.rank[rootU] += 1; + } + } + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html new file mode 100644 index 000000000000..c7c8821f577f --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html @@ -0,0 +1,17 @@ +
    +
    + + + +
    + {{ courseCompetencyNode().label }} +
    diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss new file mode 100644 index 000000000000..baa3d06976c5 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss @@ -0,0 +1,20 @@ +.competency-node { + white-space: nowrap; + background-color: var(--bs-body-bg); + border-radius: calc(var(--bs-border-radius-lg) + 6px); + padding: 10px 12px; + + .progress-container { + color: var(--bs-white); + padding: 2px 8px; + border-radius: var(--bs-border-radius-lg); + } + + .competency-container { + background-color: var(--bs-green); + } + + .prerequisite-container { + background-color: var(--bs-yellow); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts new file mode 100644 index 000000000000..48708b6ed23c --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts @@ -0,0 +1,38 @@ +import { AfterViewInit, Component, ElementRef, computed, inject, input, output } from '@angular/core'; +import { SizeUpdate } from 'app/course/learning-paths/components/competency-node/competency-node.component'; +import { Node } from '@swimlane/ngx-graph'; +import { CourseCompetencyType } from 'app/entities/competency.model'; +import { NgClass } from '@angular/common'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +@Component({ + selector: 'jhi-course-competency-relation-node', + standalone: true, + imports: [NgClass, TranslateDirective, NgbTooltipModule, ArtemisSharedModule], + templateUrl: './course-competency-relation-node.component.html', + styleUrl: './course-competency-relation-node.component.scss', +}) +export class CourseCompetencyRelationNodeComponent implements AfterViewInit { + protected readonly CourseCompetencyType = CourseCompetencyType; + // height of node element in pixels + private readonly nodeHeight = 45.59; + + private readonly element = inject(ElementRef); + + readonly courseCompetencyNode = input.required(); + readonly courseCompetencyType = computed(() => this.courseCompetencyNode().data.type!); + + readonly onSizeSet = output(); + + ngAfterViewInit(): void { + this.setDimensions(this.element); + } + + setDimensions(element: ElementRef): void { + const width: number = element.nativeElement.offsetWidth; + const height = this.nodeHeight; + this.onSizeSet.emit({ id: `${this.courseCompetencyNode().id}`, dimension: { height, width } }); + } +} diff --git a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts index 9501484fe9b5..19d3d33b6124 100644 --- a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts +++ b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, OnInit, ViewChild } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; @@ -249,14 +249,4 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti get canDeactivateWarning(): string { return this.translateService.instant('pendingChanges'); } - - /** - * Only allow to refresh the page if no pending changes exist - */ - @HostListener('window:beforeunload', ['$event']) - unloadNotification(event: any) { - if (!this.canDeactivate()) { - event.returnValue = this.canDeactivateWarning; - } - } } diff --git a/src/main/webapp/app/course/competencies/import-standardized-competencies/course-import-standardized-course-competencies.component.ts b/src/main/webapp/app/course/competencies/import-standardized-competencies/course-import-standardized-course-competencies.component.ts index 1c369bea4657..63b74465efe7 100644 --- a/src/main/webapp/app/course/competencies/import-standardized-competencies/course-import-standardized-course-competencies.component.ts +++ b/src/main/webapp/app/course/competencies/import-standardized-competencies/course-import-standardized-course-competencies.component.ts @@ -10,7 +10,7 @@ import { } from 'app/entities/competency/standardized-competency.model'; import { faBan, faDownLeftAndUpRightToCenter, faFileImport, faSort, faTrash, faUpRightAndDownLeftFromCenter } from '@fortawesome/free-solid-svg-icons'; import { ActivatedRoute, Router } from '@angular/router'; -import { Component, HostListener, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { onError } from 'app/shared/util/global.utils'; import { forkJoin, map } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; @@ -168,16 +168,6 @@ export abstract class CourseImportStandardizedCourseCompetenciesComponent extend 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; - } - } - private convertToKnowledgeAreaForImport(knowledgeAreaDTO: KnowledgeAreaDTO, isVisible = true, level = 0, selected = false): KnowledgeAreaForImport { const children = knowledgeAreaDTO.children?.map((child) => this.convertToKnowledgeAreaForImport(child, isVisible, level + 1)); const competencies = knowledgeAreaDTO.competencies?.map((competency) => diff --git a/src/main/webapp/app/course/competencies/import/import-course-competencies.component.ts b/src/main/webapp/app/course/competencies/import/import-course-competencies.component.ts index 7cb8bbb30cf1..0190d71b8b5e 100644 --- a/src/main/webapp/app/course/competencies/import/import-course-competencies.component.ts +++ b/src/main/webapp/app/course/competencies/import/import-course-competencies.component.ts @@ -4,7 +4,7 @@ import { CourseCompetency, CourseCompetencyType } from 'app/entities/competency. import { AlertService } from 'app/core/util/alert.service'; import { SortService } from 'app/shared/service/sort.service'; import { onError } from 'app/shared/util/global.utils'; -import { Component, HostListener, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { faBan, faFileImport, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { ActivatedRoute, Router } from '@angular/router'; @@ -204,14 +204,4 @@ export abstract class ImportCourseCompetenciesComponent implements OnInit, Compo 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/course/competencies/services/course-competency-api.service.ts b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts index c7d69c3674c9..26dbb518ba68 100644 --- a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts +++ b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@angular/core'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; -import { CompetencyRelationDTO, CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyImportOptionsDTO } from 'app/entities/competency.model'; +import { + CompetencyRelationDTO, + CompetencyWithTailRelationDTO, + CourseCompetency, + CourseCompetencyImportOptionsDTO, + UpdateCourseCompetencyRelationDTO, +} from 'app/entities/competency.model'; @Injectable({ providedIn: 'root' }) export class CourseCompetencyApiService extends BaseApiHttpService { @@ -10,23 +16,31 @@ export class CourseCompetencyApiService extends BaseApiHttpService { return this.basePath.replace('$courseId', courseId.toString()); } - importAllByCourseId(courseId: number, courseCompetencyImportOptions: CourseCompetencyImportOptionsDTO): Promise { - return this.post(`${this.getPath(courseId)}/import-all`, courseCompetencyImportOptions); + async importAllByCourseId(courseId: number, courseCompetencyImportOptions: CourseCompetencyImportOptionsDTO): Promise { + return await this.post(`${this.getPath(courseId)}/import-all`, courseCompetencyImportOptions); } - createCourseCompetencyRelation(courseId: number, relation: CompetencyRelationDTO): Promise { - return this.post(`${this.getPath(courseId)}/relations`, relation); + async createCourseCompetencyRelation(courseId: number, relation: CompetencyRelationDTO): Promise { + return await this.post(`${this.getPath(courseId)}/relations`, relation); } - deleteCourseCompetencyRelation(courseId: number, relationId: number): Promise { - return this.delete(`${this.getPath(courseId)}/relations/${relationId}`); + async updateCourseCompetencyRelation(courseId: number, relationId: number, updateCourseCompetencyRelationDTO: UpdateCourseCompetencyRelationDTO): Promise { + return await this.patch(`${this.getPath(courseId)}/relations/${relationId}`, updateCourseCompetencyRelationDTO); } - getCourseCompetencyRelations(courseId: number): Promise { - return this.get(`${this.getPath(courseId)}/relations`); + async deleteCourseCompetencyRelation(courseId: number, relationId: number): Promise { + return await this.delete(`${this.getPath(courseId)}/relations/${relationId}`); } - getCourseCompetenciesByCourseId(courseId: number): Promise { - return this.get(`${this.getPath(courseId)}`); + async getCourseCompetencyRelationsByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}/relations`); + } + + async getCourseCompetencyRelations(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}/relations`); + } + + async getCourseCompetenciesByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}`); } } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts index 8dfd9971432e..ac7a27af9f09 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts @@ -1,7 +1,7 @@ import { Component, effect, inject, input, signal } from '@angular/core'; import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; @@ -46,4 +46,13 @@ export class CompetencyGraphModalComponent { closeModal(): void { this.activeModal.close(); } + + static openCompetencyGraphModal(modalService: NgbModal, learningPathId: number): void { + const modalRef = modalService.open(CompetencyGraphModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'competency-graph-modal', + }); + modalRef.componentInstance.learningPathId = signal(learningPathId); + } } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html index 77bb3e447289..3c546d22b01d 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html @@ -1,9 +1,11 @@
    - - @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS) { + @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS || valueType() === CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS) { {{ value() }} % + } @else { + {{ value() }} } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts index 2ff110c01954..5365bb22387b 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts @@ -46,6 +46,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isYellow(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() > 0 && this.value() < 100; default: return false; @@ -55,6 +56,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isGray(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() === 0; default: return false; diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts index a63830f98bc7..07722fb3d0e7 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts @@ -61,11 +61,6 @@ export class LearningPathNavOverviewComponent { } openCompetencyGraph(): void { - const modalRef = this.modalService.open(CompetencyGraphModalComponent, { - size: 'xl', - backdrop: 'static', - windowClass: 'competency-graph-modal', - }); - modalRef.componentInstance.learningPathId = this.learningPathId; + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, this.learningPathId()); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.html b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.html index e788ee5ebe01..ccf9b94629c5 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.html +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-student-nav/learning-path-student-nav.component.html @@ -4,7 +4,19 @@
    @if (predecessorLearningObject(); as predecessorLearningObject) {
    -
    @@ -13,18 +25,16 @@