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/README.md b/README.md index 08742f5d89b5..a982be2c6bba 100644 --- a/README.md +++ b/README.md @@ -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.1.war ``` ## Architecture diff --git a/build.gradle b/build.gradle index 163b2517748f..c6d5fe742ab3 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ plugins { } group = "de.tum.cit.aet.artemis" -version = "7.6.0" +version = "7.6.1" description = "Interactive Learning with Individual Feedback" java { @@ -243,11 +243,12 @@ 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:c:${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}" @@ -329,7 +330,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" @@ -345,7 +346,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 @@ -397,8 +398,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 @@ -409,8 +410,8 @@ 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 "io.projectreactor.netty:reactor-netty:1.1.23" + implementation "org.springframework:spring-messaging:6.1.14" implementation "org.springframework.retry:spring-retry:2.0.9" implementation "org.springframework.security:spring-security-config:${spring_security_version}" @@ -440,7 +441,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" @@ -448,8 +449,7 @@ 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 "commons-fileupload:commons-fileupload:1.5" diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index 660e2bd4bf02..e1ce98df80f1 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -39,6 +39,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | R | yes | yes | +----------------------+----------+---------+ + | C++ | 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. @@ -75,6 +77,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ | 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 | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ - *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/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/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/gradle.properties b/gradle.properties index 07ee79d07d25..02b6e1e550ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,15 +28,15 @@ slf4j_version=2.0.16 sentry_version=7.15.0 liquibase_version=4.29.2 docker_java_version=3.4.0 -logback_version=1.5.10 +logback_version=1.5.11 java_parser_version=3.26.2 -byte_buddy_version=1.15.4 +byte_buddy_version=1.15.5 # testing # make sure both versions are compatible junit_version=5.11.0 junit_platform_version=1.11.2 -mockito_version=5.14.1 +mockito_version=5.14.2 # gradle plugin version diff --git a/package-lock.json b/package-lock.json index 91ee03d72aaf..3d991d2d9079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,30 @@ { "name": "artemis", - "version": "7.6.0", + "version": "7.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis", - "version": "7.6.0", + "version": "7.6.1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "18.2.8", "@angular/compiler": "18.2.8", "@angular/core": "18.2.8", "@angular/forms": "18.2.8", "@angular/localize": "18.2.8", - "@angular/material": "18.2.8", + "@angular/material": "18.2.9", "@angular/platform-browser": "18.2.8", "@angular/platform-browser-dynamic": "18.2.8", "@angular/router": "18.2.8", "@angular/service-worker": "18.2.8", "@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", @@ -60,17 +60,17 @@ "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.167.0", + "posthog-js": "1.174.2", "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", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -78,13 +78,13 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@angular-devkit/build-angular": "18.2.9", "@angular-eslint/builder": "18.3.1", "@angular-eslint/eslint-plugin": "18.3.1", "@angular-eslint/eslint-plugin-template": "18.3.1", "@angular-eslint/schematics": "18.3.1", "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.8", + "@angular/cli": "18.2.9", "@angular/compiler-cli": "18.2.8", "@angular/language-service": "18.2.8", "@sentry/types": "8.34.0", @@ -93,14 +93,14 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.13", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.5", + "@types/node": "22.7.6", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.8.1", - "@typescript-eslint/parser": "8.8.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", "eslint": "9.12.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -121,7 +121,7 @@ "ngxtension": "4.0.0", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.79.5", + "sass": "1.80.2", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -212,13 +212,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.8.tgz", - "integrity": "sha512-/rtFQEKgS7LlB9oHr4NCBSdKnvP5kr8L5Hbd3Vl8hZOYK9QWjxKPEXnryA2d5+PCE98bBzZswCNXqELZCPTgIQ==", + "version": "0.1802.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.9.tgz", + "integrity": "sha512-fubJf4WC/t3ITy+tyjI4/CKKwUP4XJTmV+Y0nyPcrkcthVyUcIpZB74NlUOvg6WECiPQuIc+CtoAaA9X5+RQ5Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", + "@angular-devkit/core": "18.2.9", "rxjs": "7.8.1" }, "engines": { @@ -228,17 +228,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.8.tgz", - "integrity": "sha512-qK/iLk7A8vQp1CyiJV4DpwfLjPKoiOlTtFqoO5vD8Tyxmc+R06FQp6GJTsZ7JtrTLYSiH+QAWiY6NgF/Rj/hHg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.9.tgz", + "integrity": "sha512-d4W6t9vBozFUmOP2VvihMcSg/zgr3AvJY6/b7OPuATlK+W3P6tmsqxGIQ6eKc1TxXeu3lWhi14mV2pPykfrwfA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.8", - "@angular-devkit/build-webpack": "0.1802.8", - "@angular-devkit/core": "18.2.8", - "@angular/build": "18.2.8", + "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/build-webpack": "0.1802.9", + "@angular-devkit/core": "18.2.9", + "@angular/build": "18.2.9", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -249,7 +249,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.8", + "@ngtools/webpack": "18.2.9", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -382,13 +382,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.8.tgz", - "integrity": "sha512-uPpopkXkO66SSdjtVr7xCyQCPs/x6KUC76xkDc4j0b8EEHifTbi/fNpbkcZ6wBmoAfjKLWXfKvtkh0TqKK5Hkw==", + "version": "0.1802.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.9.tgz", + "integrity": "sha512-p7xNGo5ZTV/Z0Rk+q2/E68QQLw9VT33kauDh6s010jIeBLrOwMo74JpzXMSFttQo5O4bLKP8IORzIM+0q7Uzjg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.8", + "@angular-devkit/architect": "0.1802.9", "rxjs": "7.8.1" }, "engines": { @@ -402,9 +402,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.8.tgz", - "integrity": "sha512-4o2T6wsmXGE/v53+F8L7kGoN2+qzt03C9rtjLVQpOljzpJVttQ8bhvfWxyYLWwcl04RWqRa+82fpIZtBkOlZJw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.9.tgz", + "integrity": "sha512-bsVt//5E0ua7FZfO0dCF/qGGY6KQD34/bNGyRu5B6HedimpdU2/0PGDptksU5v3yKEc9gNw0xC6mT0UsY/R9pA==", "dev": true, "license": "MIT", "dependencies": { @@ -430,13 +430,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.8.tgz", - "integrity": "sha512-i/h2Oji5FhJMC7wDSnIl5XUe/qym+C1ZwScaATJwDyRLCUIynZkj5rLgdG/uK6l+H0PgvxigkF+akWpokkwW6w==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.9.tgz", + "integrity": "sha512-aIY5/IomDOINGCtFYi77uo0acDpdQNNCighfBBUGEBNMQ1eE3oGNGpLAH/qWeuxJndgmxrdKsvws9DdT46kLig==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", + "@angular-devkit/core": "18.2.9", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -564,14 +564,14 @@ } }, "node_modules/@angular/build": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.8.tgz", - "integrity": "sha512-ufuA4vHJSrL9SQW7bKV61DOoN1mm0t0ILTHaxSoCG3YF70cZJOX7+HNp3cK2uoldRMwbTOKSvCWBw54KKDRd5Q==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.9.tgz", + "integrity": "sha512-o1hOEM2e6ARy+ck2Pohl0d/RFgbbXTw6/hTLAj3CBKjtqAGStRaVF2UlJjhi+xOxlfsOPuJJc9IpzLBteku+Ag==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.8", + "@angular-devkit/architect": "0.1802.9", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -651,9 +651,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.8.tgz", - "integrity": "sha512-J8A2FkwTBzLleAEWz6EgW73dEoeq87GREBPjTv8+2JV09LX+V3hnbgNk6zWq5k4OXtQNg9WrWP9QyRbUyA597g==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.9.tgz", + "integrity": "sha512-hV2dXpvy2TLwCsRtI/ZXkb2EoaJiellRr+kbcnKwO15LFoz3mTAOhKtsvu7yOyURkaPiI605qiIZrPP4zLL1qw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -668,18 +668,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.8.tgz", - "integrity": "sha512-GKXG7F7z5rxwZ8/bnW/Bp8/zsfE/BpHmIP/icLfUIOwv2kaY5OD2tfQssWXPEuqZzYq2AYz+wjVSbWjxGoja8A==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.9.tgz", + "integrity": "sha512-ejTIqwvPABwK7MtVmI2qWbEaMhhbHNsq0NPzl1hwLtkrLbjdDrEVv0Wy+gN0xqrT9NyCPl4AmNLz/xuYTzgU5g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.8", - "@angular-devkit/core": "18.2.8", - "@angular-devkit/schematics": "18.2.8", + "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/core": "18.2.9", + "@angular-devkit/schematics": "18.2.9", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.8", + "@schematics/angular": "18.2.9", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -862,16 +862,16 @@ } }, "node_modules/@angular/material": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.8.tgz", - "integrity": "sha512-wQGMVsfQ9lQfih2VsWAvV4z3S3uBxrxc61owlE+K0T1BxH9u/jo3A/rnRitIdvR/L4NnYlfhCnmrW9K+Pl+WCg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.9.tgz", + "integrity": "sha512-M2oCgPPIMMd6BLgEJCD+FvdC7gRDeCjj9yktNn3ctHmkKUWRvpJ3xRBH/WjVXb+9fPCCW1iNwZI7+bN1fHE7cA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -3584,9 +3584,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" @@ -5376,9 +5376,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.8.tgz", - "integrity": "sha512-sq0kI8gEen4QlM6X8XqOYy7j4B8iLCYNo+iKxatV36ts4AXH0MuVkP56+oMaoH5oZNoSqd0RlfnotEHfvJAr8A==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.9.tgz", + "integrity": "sha512-/apDvs4qevjSWoYw3h3/c/mILFrf2EgCJfBy9f3E7PEgi2tjifOIszBRrLQkVpeHAaFgEH8zKS2ol0hAmOl8sw==", "dev": true, "license": "MIT", "engines": { @@ -6570,14 +6570,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.8.tgz", - "integrity": "sha512-62Sr7/j/dlhZorxH4GzQgpJy0s162BVts0Q7knZuEacP4VL+IWOUE1NS9OFkh/cbomoyXBdoewkZ5Zd1dVX78w==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.9.tgz", + "integrity": "sha512-LlMHZQ6f8zrqSK24OBXi4u2MTNHNu9ZN6JXpbElq0bz/9QkUR2zy+Kk2wLpPxCwXYTZby7/xgHiTzXvG+zTdhw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", - "@angular-devkit/schematics": "18.2.8", + "@angular-devkit/core": "18.2.9", + "@angular-devkit/schematics": "18.2.9", "jsonc-parser": "3.3.1" }, "engines": { @@ -7360,9 +7360,9 @@ } }, "node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "version": "22.7.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz", + "integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==", "dev": true, "license": "MIT", "dependencies": { @@ -7566,17 +7566,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", - "integrity": "sha512-xfvdgA8AP/vxHgtgU310+WBnLB4uJQ9XdyP17RebG26rLtDrQJV3ZYrcopX91GrHmMoH8bdSwMRh2a//TiJ1jQ==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", + "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/type-utils": "8.8.1", - "@typescript-eslint/utils": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/type-utils": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7600,16 +7600,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.1.tgz", - "integrity": "sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.10.0.tgz", + "integrity": "sha512-E24l90SxuJhytWJ0pTQydFT46Nk0Z+bsLKo/L8rtQSL93rQ6byd1V/QbDpHUTdLPOMsBCcYXZweADNCfOCmOAg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/typescript-estree": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4" }, "engines": { @@ -7629,14 +7629,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.1.tgz", - "integrity": "sha512-X4JdU+66Mazev/J0gfXlcC/dV6JI37h+93W9BRYXrSn0hrE64IoWgVkO9MSJgEzoWkxONgaQpICWg8vAN74wlA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1" + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7647,14 +7647,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.1.tgz", - "integrity": "sha512-qSVnpcbLP8CALORf0za+vjLYj1Wp8HSoiI8zYU5tHxRVj30702Z1Yw4cLwfNKhTPWp5+P+k1pjmD5Zd1nhxiZA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", + "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.8.1", - "@typescript-eslint/utils": "8.8.1", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/utils": "8.10.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7672,9 +7672,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.1.tgz", - "integrity": "sha512-WCcTP4SDXzMd23N27u66zTKMuEevH4uzU8C9jf0RO4E04yVHgQgW+r+TeVTNnO1KIfrL8ebgVVYYMMO3+jC55Q==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", "dev": true, "license": "MIT", "engines": { @@ -7686,14 +7686,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.1.tgz", - "integrity": "sha512-A5d1R9p+X+1js4JogdNilDuuq+EHZdsH9MjTVxXOdVFfTJXunKJR/v+fNNyO4TnoOn5HqobzfRlc70NC6HTcdg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7715,16 +7715,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.1.tgz", - "integrity": "sha512-/QkNJDbV0bdL7H7d0/y0qBbV2HTtf0TIyjSDTvvmQEzeVx8jEImEbLuOA4EsvE8gIgqMitns0ifb5uQhMj8d9w==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", + "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/typescript-estree": "8.8.1" + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7738,13 +7738,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.1.tgz", - "integrity": "sha512-0/TdC3aeRAsW7MDvYRwEc1Uwm0TIBfzjPFgg60UU2Haj5qsCs9cc3zNgY71edqE3LbWfF/WoZQd3lJoDXFQpag==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/types": "8.10.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -8337,9 +8337,9 @@ } }, "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" }, @@ -8803,28 +8803,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": { @@ -8837,6 +8835,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", @@ -9602,9 +9637,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": { @@ -9652,21 +9687,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", @@ -9870,9 +9908,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": { @@ -11943,46 +11981,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": { @@ -12192,14 +12278,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", @@ -12210,6 +12296,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", @@ -12396,13 +12492,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": { @@ -13617,6 +13713,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", @@ -16877,19 +16980,19 @@ "license": "ISC" }, "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.13.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.13.0.tgz", - "integrity": "sha512-dIs5KGy24fbdDhIAg0RxXpFqQp3RwL6wgSMRF9OSuphL/Uc9a4u2/SDJKPLj/zUgtOGKuHrRMrj563+IErj4Cg==", + "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": { @@ -16907,11 +17010,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" } @@ -16973,6 +17079,7 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mime": "cli.js" }, @@ -18855,11 +18962,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", @@ -19222,14 +19332,15 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.167.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.167.0.tgz", - "integrity": "sha512-/zXQ6tuJgiF1d4mgg3UsAi/uoyg7UnfFNQtikuALmaE53xFExpcAKbMfHPG/f54QgTvLxSHyGL1kFl/1uspkGg==", + "version": "1.174.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.174.2.tgz", + "integrity": "sha512-UgS7eRcDVvVz2XSJ09NMX8zBcdpFnPayfiWDNF3xEbJTsIu1GipkkYNrVlsWlq8U1PIrviNm6i0Dyq8daaxssw==", "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": { @@ -19506,15 +19617,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": { @@ -19531,6 +19642,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", @@ -20161,6 +20285,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", @@ -20227,9 +20370,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.79.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz", - "integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==", + "version": "1.80.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.2.tgz", + "integrity": "sha512-9wXY8cGBlUmoUoT+vwOZOFCiS+naiWVjqlreN9ar9PudXbGwlMTFwCR5K9kB4dFumJ6ib98wZyAObJKsWf1nAA==", "dev": true, "license": "MIT", "dependencies": { @@ -20418,38 +20561,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": { @@ -20532,19 +20674,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": { @@ -20783,9 +20925,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": "*" @@ -22058,9 +22200,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": { @@ -22138,14 +22280,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" @@ -22444,9 +22610,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.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 755adfae3d3e..fb1e38afe1cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artemis", - "version": "7.6.0", + "version": "7.6.1", "description": "Interactive Learning with Individual Feedback", "private": true, "license": "MIT", @@ -14,20 +14,20 @@ ], "dependencies": { "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "18.2.8", "@angular/compiler": "18.2.8", "@angular/core": "18.2.8", "@angular/forms": "18.2.8", "@angular/localize": "18.2.8", - "@angular/material": "18.2.8", + "@angular/material": "18.2.9", "@angular/platform-browser": "18.2.8", "@angular/platform-browser-dynamic": "18.2.8", "@angular/router": "18.2.8", "@angular/service-worker": "18.2.8", "@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", @@ -63,17 +63,17 @@ "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.167.0", + "posthog-js": "1.174.2", "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", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -91,16 +91,16 @@ "eslint": "^9.12.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.10.0" }, - "express": "4.21.0", + "express": "5.0.1", "jsdom": "25.0.1", "katex": "0.16.11", "postcss": "8.4.47", @@ -110,7 +110,7 @@ "showdown": "2.1.0" }, "tough-cookie": "5.0.0", - "vite": "5.4.8", + "vite": "5.4.9", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.1.0", "word-wrap": "1.2.5", @@ -119,13 +119,13 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@angular-devkit/build-angular": "18.2.9", "@angular-eslint/builder": "18.3.1", "@angular-eslint/eslint-plugin": "18.3.1", "@angular-eslint/eslint-plugin-template": "18.3.1", "@angular-eslint/schematics": "18.3.1", "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.8", + "@angular/cli": "18.2.9", "@angular/compiler-cli": "18.2.8", "@angular/language-service": "18.2.8", "@sentry/types": "8.34.0", @@ -134,14 +134,14 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.13", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.5", + "@types/node": "22.7.6", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.8.1", - "@typescript-eslint/parser": "8.8.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", "eslint": "9.12.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -162,7 +162,7 @@ "ng-mocks": "14.13.1", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.79.5", + "sass": "1.80.2", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" diff --git a/src/main/java/de/tum/cit/aet/artemis/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/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/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/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/LearningPathResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java index f69dae28f80c..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 @@ -203,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. * 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..ab24012f51fa 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(String name, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, + BuildAgentStatus status, List recentBuildJobs, String publicSshKey) implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -27,4 +27,8 @@ public BuildAgentInformation(BuildAgentInformation agentInformation, List> 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. @@ -178,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); } /** @@ -235,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..e73f8bac244b 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,28 @@ 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.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,7 +41,9 @@ 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.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; @@ -64,6 +75,8 @@ public class SharedQueueProcessingService { private final BuildAgentSshKeyService buildAgentSSHKeyService; + private final TaskScheduler taskScheduler; + private IQueue queue; private IQueue resultQueue; @@ -80,32 +93,102 @@ 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:15}") + private int pauseGracePeriodSeconds; + 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() { 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 (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + pauseBuildAgent(); + } + }); + + ITopic resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); + resumeBuildAgentTopic.addMessageListener(message -> { + if (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + 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); + } } /** @@ -127,23 +210,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 +230,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; } @@ -241,7 +313,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,7 +334,7 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue String publicSshKey = buildAgentSSHKeyService.getPublicKeyAsString(); - return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, active, recentBuildJobs, publicSshKey); + return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, status, recentBuildJobs, publicSshKey); } private List getProcessingJobsOfNode(String memberAddress) { @@ -305,7 +379,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 +421,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 +437,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/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/core/config/PublicResourcesConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/PublicResourcesConfiguration.java index e39b8915c94f..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 @@ -54,12 +54,15 @@ public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) { // 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("/images/course/icons/**").addResourceLocations("file:" + fileUploadPath + "/images/course/icons/").setCacheControl(defaultCacheControl); + registry.addResourceHandler("/course/icons/**").addResourceLocations("file:" + fileUploadPath + "/images/course/icons/").setCacheControl(defaultCacheControl); - registry.addResourceHandler("/images/user/profile-pictures/**").addResourceLocations("file:" + fileUploadPath + "/images/user/profile-pictures/") + registry.addResourceHandler("/user/profile-pictures/**").addResourceLocations("file:" + fileUploadPath + "/images/user/profile-pictures/") .setCacheControl(defaultCacheControl); - registry.addResourceHandler("/images/drag-and-drop/**").addResourceLocations("file:" + fileUploadPath + "/images/drag-and-drop/").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/repository/CourseRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java index fa3bba8a4b73..ad4c3ab139f5 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 @@ -322,6 +322,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. 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/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/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index db71ab34c05a..33e7bd099155 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 @@ -15,6 +15,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; @@ -188,4 +189,44 @@ public ResponseEntity getBuildJobStatistics(@RequestPara 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/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/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/plagiarism/service/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java index ea6ebfdf3b81..30e568ac85a5 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; @@ -312,6 +313,7 @@ public void deleteTempLocalRepository(Repository repository) { private Language getJPlagProgrammingLanguage(ProgrammingExercise programmingExercise) { return switch (programmingExercise.getProgrammingLanguage()) { case C -> new CLanguage(); + case C_PLUS_PLUS -> new CPPLanguage(); case JAVA -> new JavaLanguage(); case JAVASCRIPT -> new JavaScriptLanguage(); case KOTLIN -> new KotlinLanguage(); @@ -319,9 +321,8 @@ private Language getJPlagProgrammingLanguage(ProgrammingExercise programmingExer case R -> new RLanguage(); case RUST -> new RustLanguage(); case SWIFT -> new SwiftLanguage(); - case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, VHDL, RUBY, POWERSHELL, ADA -> - throw new BadRequestAlertException("Programming language " + programmingExercise.getProgrammingLanguage() + " not supported for plagiarism check.", - "ProgrammingExercise", "notSupported"); + case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, SQL, TYPESCRIPT, 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 239ef3674d44..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 @@ -21,4 +21,8 @@ public enum AuthenticationMechanism { * The user used the artemis client code editor to authenticate to the LocalVC */ 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/ProgrammingLanguage.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java index 781ad04f98c6..0fdc216122b9 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 @@ -40,6 +40,7 @@ public enum ProgrammingLanguage { private static final Set ENABLED_LANGUAGES = Set.of( ASSEMBLER, C, + C_PLUS_PLUS, HASKELL, JAVA, JAVASCRIPT, 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/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 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/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 0cbf73da21ff..d4e7de493f52 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 @@ -1048,4 +1048,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/TemplateUpgradePolicyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java index 4c73046b1ab0..cc018ae1eb61 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, R -> defaultRepositoryUpgradeService; - case C_SHARP, C_PLUS_PLUS, SQL, 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 -> defaultRepositoryUpgradeService; + case C_SHARP, SQL, TYPESCRIPT, 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/ci/ContinuousIntegrationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java index b9050501c67a..f2f53467d261 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, R -> "assignment"; - case C_SHARP, C_PLUS_PLUS, SQL, 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 -> "assignment"; + case C_SHARP, SQL, TYPESCRIPT, 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, R -> ""; + case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS -> ""; case C, VHDL, ASSEMBLER, OCAML -> "tests"; - case C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case C_SHARP, SQL, TYPESCRIPT, 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/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java index 45a473da9148..8b948e8f0073 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,6 +1,7 @@ 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; @@ -35,6 +36,7 @@ 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)); 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 f900cc0f6dd1..dc4ff7a8178a 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, R -> jenkinsBuildPlanCreator; - case VHDL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT, R, C_PLUS_PLUS -> jenkinsBuildPlanCreator; + case VHDL, ASSEMBLER, OCAML, C_SHARP, SQL, TYPESCRIPT, 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/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java index bc8292d407bb..704755528d3c 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; @@ -42,6 +43,7 @@ public LocalCIProgrammingLanguageFeatureService() { 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)); 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..a450fc545886 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."); + } } /** 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..9af5fe3b45c1 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"); } /** @@ -135,6 +141,14 @@ public List getBuildAgentInformationWithoutRecentBuildJob 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. * 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..f4bcf1ea2e89 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,7 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), - false); + participation = programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, localVCRepositoryUri.toString()); } catch (EntityNotFoundException e) { throw new LocalVCInternalException( @@ -442,20 +449,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 +553,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 +572,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 +724,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 +758,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/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/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 2225baa81759..957f7435ce0d 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 @@ -534,6 +534,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/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/resources/config/application.yml b/src/main/resources/config/application.yml index 3924e2d804f9..6645696acc2e 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -93,6 +93,8 @@ artemis: 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" management: endpoints: 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/c/fact.yaml b/src/main/resources/templates/aeolus/c/fact.yaml index 9d3527e65503..4278be956fac 100644 --- a/src/main/resources/templates/aeolus/c/fact.yaml +++ b/src/main/resources/templates/aeolus/c/fact.yaml @@ -7,7 +7,7 @@ actions: # Task Description: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # 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 @@ -32,4 +32,3 @@ actions: - 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.yaml b/src/main/resources/templates/aeolus/c/gcc.yaml index 29b2e2c635ec..31cafd647000 100644 --- a/src/main/resources/templates/aeolus/c/gcc.yaml +++ b/src/main/resources/templates/aeolus/c/gcc.yaml @@ -9,7 +9,7 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # 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 diff --git a/src/main/resources/templates/aeolus/c/gcc_static.yaml b/src/main/resources/templates/aeolus/c/gcc_static.yaml index 21c0b506f179..06ad3136f9aa 100644 --- a/src/main/resources/templates/aeolus/c/gcc_static.yaml +++ b/src/main/resources/templates/aeolus/c/gcc_static.yaml @@ -9,7 +9,7 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # 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 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/swift/plain.yaml b/src/main/resources/templates/aeolus/swift/plain.yaml index 48211dee715a..a2be5d469e65 100644 --- a/src/main/resources/templates/aeolus/swift/plain.yaml +++ b/src/main/resources/templates/aeolus/swift/plain.yaml @@ -6,7 +6,7 @@ actions: 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 ${studentParentWorkingDirectoryName} swift build || error=true diff --git a/src/main/resources/templates/aeolus/swift/plain_static.yaml b/src/main/resources/templates/aeolus/swift/plain_static.yaml index 72f683141903..83c76e1a2b61 100644 --- a/src/main/resources/templates/aeolus/swift/plain_static.yaml +++ b/src/main/resources/templates/aeolus/swift/plain_static.yaml @@ -7,7 +7,7 @@ actions: 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 ${studentParentWorkingDirectoryName} swift build || error=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/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/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/course/competencies/competency-management/competency-management.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html index 889576aa7830..e541845c4ede 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) { diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts index ca7ce5bdd1d7..7aad7d529594 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts @@ -13,7 +13,7 @@ import { } from 'app/entities/competency.model'; import { onError } from 'app/shared/util/global.utils'; import { Subject, Subscription } from 'rxjs'; -import { faFileImport, faPencilAlt, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCircleQuestion, faFileImport, faPencilAlt, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; @@ -28,6 +28,7 @@ import { ImportAllCourseCompetenciesResult, } from 'app/course/competencies/components/import-all-course-competencies-modal/import-all-course-competencies-modal.component'; import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { CourseCompetencyExplanationModalComponent } from 'app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component'; @Component({ selector: 'jhi-competency-management', @@ -53,6 +54,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { protected readonly faTrash = faTrash; protected readonly faPencilAlt = faPencilAlt; protected readonly faRobot = faRobot; + protected readonly faCircleQuestion = faCircleQuestion; // other constants readonly getIcon = getIcon; @@ -60,14 +62,14 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { readonly CourseCompetencyType = CourseCompetencyType; // Injected services - private readonly activatedRoute: ActivatedRoute = inject(ActivatedRoute); - private readonly courseCompetencyApiService: CourseCompetencyApiService = inject(CourseCompetencyApiService); - private readonly alertService: AlertService = inject(AlertService); - private readonly modalService: NgbModal = inject(NgbModal); - private readonly profileService: ProfileService = inject(ProfileService); - private readonly irisSettingsService: IrisSettingsService = inject(IrisSettingsService); - private readonly translateService: TranslateService = inject(TranslateService); - private readonly featureToggleService: FeatureToggleService = inject(FeatureToggleService); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); + private readonly alertService = inject(AlertService); + private readonly modalService = inject(NgbModal); + private readonly profileService = inject(ProfileService); + private readonly irisSettingsService = inject(IrisSettingsService); + private readonly translateService = inject(TranslateService); + private readonly featureToggleService = inject(FeatureToggleService); ngOnInit(): void { this.activatedRoute.parent!.params.subscribe(async (params) => { @@ -75,6 +77,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; }); @@ -229,4 +236,12 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { 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/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/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-paths-analytics/learning-paths-analytics.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html new file mode 100644 index 000000000000..86d03a787e5d --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html @@ -0,0 +1,27 @@ +
+
+
+
+
+ +
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else if (instructorCompetencyGraph()) { + + } +
+
+
+ + +
+ + +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss new file mode 100644 index 000000000000..64570aa984c9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss @@ -0,0 +1,7 @@ +.learning-paths-analytics-container { + height: 500px; + + .learning-paths-analytics-graph-selection-container { + border-right: var(--bs-border-width) solid var(--border-color); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts new file mode 100644 index 000000000000..41a8261eb206 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts @@ -0,0 +1,44 @@ +import { Component, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { CompetencyGraphDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { onError } from 'app/shared/util/global.utils'; + +@Component({ + selector: 'jhi-learning-paths-analytics', + standalone: true, + imports: [ArtemisSharedCommonModule, CompetencyGraphComponent], + templateUrl: './learning-paths-analytics.component.html', + styleUrl: './learning-paths-analytics.component.scss', +}) +export class LearningPathsAnalyticsComponent { + protected readonly CompetencyGraphNodeValueType = CompetencyGraphNodeValueType; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + readonly instructorCompetencyGraph = signal(undefined); + + readonly valueSelection = signal(CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS); + + constructor() { + effect(() => this.loadInstructionCompetencyGraph(this.courseId()), { allowSignalWrites: true }); + } + + private async loadInstructionCompetencyGraph(courseId: number): Promise { + try { + this.isLoading.set(true); + const instructorCompetencyGraph = await this.learningPathApiService.getLearningPathInstructorCompetencyGraph(courseId); + this.instructorCompetencyGraph.set(instructorCompetencyGraph); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html new file mode 100644 index 000000000000..3edcb13c956f --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html @@ -0,0 +1,41 @@ +
+
+
+ @if (isEditMode()) { + + } @else { + + } +
+
+
+ @if (isConfigLoading()) { +
+
+ +
+
+ } @else { + + + + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts new file mode 100644 index 000000000000..23e5cb8e8612 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts @@ -0,0 +1,78 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathApiService } from '../../services/learning-path-api.service'; +import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; + +@Component({ + selector: 'jhi-learning-paths-configuration', + standalone: true, + imports: [FontAwesomeModule, ArtemisSharedCommonModule, ArtemisSharedComponentModule], + templateUrl: './learning-paths-configuration.component.html', + styleUrls: ['../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsConfigurationComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isEditMode = signal(false); + readonly configHasBeenChanged = signal(false); + + readonly isConfigLoading = signal(false); + readonly isSaving = signal(false); + private readonly learningPathsConfiguration = signal(undefined); + readonly includeAllGradedExercisesEnabled = computed(() => this.learningPathsConfiguration()?.includeAllGradedExercises ?? false); + + constructor() { + effect(() => this.loadLearningPathsConfiguration(this.courseId()), { allowSignalWrites: true }); + } + + private async loadLearningPathsConfiguration(courseId: number): Promise { + try { + this.isConfigLoading.set(true); + const learningPathsConfiguration = await this.learningPathApiService.getLearningPathsConfiguration(courseId); + this.learningPathsConfiguration.set(learningPathsConfiguration); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isConfigLoading.set(false); + } + } + + protected toggleIncludeAllGradedExercises(): void { + this.configHasBeenChanged.set(true); + this.learningPathsConfiguration.set({ + ...this.learningPathsConfiguration(), + includeAllGradedExercises: !this.includeAllGradedExercisesEnabled(), + }); + } + + protected async saveLearningPathsConfiguration(): Promise { + if (this.configHasBeenChanged()) { + try { + this.isSaving.set(true); + await this.learningPathApiService.updateLearningPathsConfiguration(this.courseId(), this.learningPathsConfiguration()!); + this.alertService.success('artemisApp.learningPathManagement.learningPathsConfiguration.saveSuccess'); + this.isEditMode.set(false); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isSaving.set(false); + } + } else { + this.isEditMode.set(false); + } + } + + protected enableEditMode(): void { + this.isEditMode.set(true); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html new file mode 100644 index 000000000000..cb2e0fb12c93 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html @@ -0,0 +1,40 @@ +
+
+
+ +
+
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else { + @for (healthState of learningPathHealthState(); let first = $first; track healthState) { +
+ +

+ +
+ } @empty { +
+ +
+ } + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss new file mode 100644 index 000000000000..6354cc0912ab --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss @@ -0,0 +1,18 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +.learning-paths-state-container { + border-left: 2px solid $info; + + &.danger-state { + border-left-color: $danger; + } + + &.warning-state { + border-left-color: $warning; + } + + &.info-state { + border-left-color: $info; + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts new file mode 100644 index 000000000000..e5fc57092472 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts @@ -0,0 +1,85 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-learning-paths-state', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-state.component.html', + styleUrls: ['./learning-paths-state.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsStateComponent { + protected readonly faSpinner = faSpinner; + + private readonly baseTranslationKey = 'artemisApp.learningPathManagement.learningPathsState.type'; + readonly translationKeys: Record = { + [HealthStatus.MISSING]: `${this.baseTranslationKey}.missing`, + [HealthStatus.NO_COMPETENCIES]: `${this.baseTranslationKey}.noCompetencies`, + [HealthStatus.NO_RELATIONS]: `${this.baseTranslationKey}.noRelations`, + }; + + readonly stateCssClasses: Record = { + [HealthStatus.MISSING]: 'warning-state', + [HealthStatus.NO_COMPETENCIES]: 'danger-state', + [HealthStatus.NO_RELATIONS]: 'warning-state', + }; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly learningPathHealth = signal(undefined); + readonly learningPathHealthState = computed(() => this.learningPathHealth()?.status ?? []); + + constructor() { + effect(() => this.loadLearningPathHealthState(this.courseId()), { allowSignalWrites: true }); + } + + protected async loadLearningPathHealthState(courseId: number): Promise { + try { + this.isLoading.set(true); + const learningPathHealthState = await this.learningPathApiService.getLearningPathHealthStatus(courseId); + this.learningPathHealth.set(learningPathHealthState); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async handleHealthStateAction(healthState: HealthStatus): Promise { + switch (healthState) { + case HealthStatus.MISSING: + await this.generateMissingLearningPaths(); + break; + case HealthStatus.NO_COMPETENCIES: + case HealthStatus.NO_RELATIONS: + await this.navigateToManageCompetencyPage(); + break; + } + } + + private async navigateToManageCompetencyPage(): Promise { + await this.router.navigate(['../competency-management'], { relativeTo: this.activatedRoute }); + } + + private async generateMissingLearningPaths(): Promise { + try { + await this.learningPathApiService.generateMissingLearningPaths(this.courseId()); + this.alertService.success(`${this.baseTranslationKey}.missing.successAlert`); + await this.loadLearningPathHealthState(this.courseId()); + } catch (error) { + onError(this.alertService, error); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html new file mode 100644 index 000000000000..4f4b23a690e0 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html @@ -0,0 +1,66 @@ +
+
+
+
+ @if (isLoading()) { + + } + +
+
+
+
+
+ + + + + + + + + + + @for (learningPath of learningPaths(); track learningPath.id) { + + + + + + + } @empty { + + + + } + +
#
{{ learningPath.id }} + + + + + {{ learningPath.progress }} % +
+ +
+
+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss new file mode 100644 index 000000000000..e0a8bf4315b9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss @@ -0,0 +1,18 @@ +.learning-paths-table-container { + height: 185px; + overflow-y: auto; + + table { + thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bs-card-bg); + } + } +} + +.pagination { + height: 27px; + overflow-y: hidden; +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts new file mode 100644 index 000000000000..3d5a50483faa --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts @@ -0,0 +1,94 @@ +import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; +import { SearchResult, SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; +import { onError } from 'app/shared/util/global.utils'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyGraphModalComponent } from 'app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; + +enum TableColumn { + ID = 'ID', + USER_NAME = 'USER_NAME', + USER_LOGIN = 'USER_LOGIN', + PROGRESS = 'PROGRESS', +} + +@Component({ + selector: 'jhi-learning-paths-table', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-table.component.html', + styleUrls: ['./learning-paths-table.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsTableComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly modalService = inject(NgbModal); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly searchResults = signal | undefined>(undefined); + readonly learningPaths = computed(() => this.searchResults()?.resultsOnPage ?? []); + + readonly searchTerm = signal(''); + readonly page = signal(1); + private readonly sortingOrder = signal(SortingOrder.ASCENDING); + private readonly sortedColumn = signal(TableColumn.ID); + readonly pageSize = signal(100).asReadonly(); + readonly collectionSize = computed(() => (this.searchResults()?.numberOfPages ?? 1) * this.pageSize()); + + // Debounce the loadLearningPaths function to prevent multiple requests when the user types quickly + private readonly debounceLoadLearningPaths = BaseApiHttpService.debounce(this.loadLearningPaths.bind(this), 300); + + constructor() { + effect( + () => { + // Load learning paths whenever the courseId changes + const courseId = this.courseId(); + untracked(() => this.loadLearningPaths(courseId)); + }, + { allowSignalWrites: true }, + ); + } + + private async loadLearningPaths(courseId: number): Promise { + try { + this.isLoading.set(true); + const searchState = { + page: this.page(), + pageSize: this.pageSize(), + searchTerm: this.searchTerm(), + sortingOrder: this.sortingOrder(), + sortedColumn: this.sortedColumn(), + }; + const searchResults = await this.learningPathApiService.getLearningPathInformation(courseId, searchState); + this.searchResults.set(searchResults); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + search(searchTerm: string): void { + this.searchTerm.set(searchTerm); + this.page.set(1); + this.debounceLoadLearningPaths(this.courseId()); + } + + async setPage(pageNumber: number): Promise { + this.page.set(pageNumber); + await this.loadLearningPaths(this.courseId()); + } + + openCompetencyGraph(learningPathId: number): void { + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, learningPathId); + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html index c8a10304da15..fa2f5b75d63b 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html @@ -8,21 +8,19 @@ @if (!isLoading && health) {

- @if (health.status?.includes(HealthStatus.DISABLED)) { +
+
- -
- -
+
- } +
@if (health.status?.includes(HealthStatus.MISSING)) { } @@ -32,63 +30,61 @@

@if (health.status?.includes(HealthStatus.NO_RELATIONS)) { } - @if (!health.status?.includes(HealthStatus.DISABLED)) { -
-
- - - @if (searchLoading) { - - } -
- - - - - - - - +
+
+ + + @if (searchLoading) { + + } +
+
- # - - - - - - - - - - -
+ + + + + + + + + + + @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { + + + + + + - - - @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { - - - - - - - - } - -
+ # + + + + + + + + + + +
+ {{ learningPath.id }} + + + + + + + + +
- {{ learningPath.id }} - - - - - - - - -
-
- -
+ } + + +
+
- } +
} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts index e6f2eb495f5c..bff608a8cb93 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts @@ -135,10 +135,8 @@ export class LearningPathManagementComponent implements OnInit { .subscribe({ next: (res) => { this.health = res.body!; - if (!this.health.status?.includes(HealthStatus.DISABLED)) { - this.performSearch(this.sort, 0); - this.performSearch(this.search, 300); - } + this.performSearch(this.sort, 0); + this.performSearch(this.search, 300); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), }); diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html new file mode 100644 index 000000000000..09ad272a5bbe --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html @@ -0,0 +1,39 @@ +@if (isLoading()) { +
+
+ +
+
+} @else if (learningPathsEnabled()) { +
+
+ + + + +
+ +
+
+ +
+
+
+ +
+
+} @else { +
+
+

+ + +
+
+} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss new file mode 100644 index 000000000000..be0c2625f1bd --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss @@ -0,0 +1,13 @@ +.enable-learning-paths-container { + max-width: 500px; + text-align: center; +} + +.learning-paths-container { + background: var(--bs-card-bg); +} + +.learning-paths-management-container { + max-height: 220px; + overflow-y: auto; +} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts new file mode 100644 index 000000000000..1dab247186e1 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts @@ -0,0 +1,62 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { LearningPathsConfigurationComponent } from 'app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component'; +import { lastValueFrom, map } from 'rxjs'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { Course } from 'app/entities/course.model'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathsStateComponent } from 'app/course/learning-paths/components/learning-paths-state/learning-paths-state.component'; +import { LearningPathsTableComponent } from 'app/course/learning-paths/components/learning-paths-table/learning-paths-table.component'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { LearningPathsAnalyticsComponent } from 'app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component'; + +@Component({ + selector: 'jhi-learning-path-instructor-page', + standalone: true, + imports: [LearningPathsConfigurationComponent, ArtemisSharedCommonModule, LearningPathsStateComponent, LearningPathsTableComponent, LearningPathsAnalyticsComponent], + templateUrl: './learning-path-instructor-page.component.html', + styleUrl: './learning-path-instructor-page.component.scss', +}) +export class LearningPathInstructorPageComponent { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly courseManagementService = inject(CourseManagementService); + + readonly courseId = toSignal(this.activatedRoute.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); + private readonly course = signal(undefined); + readonly learningPathsEnabled = computed(() => this.course()?.learningPathsEnabled ?? false); + + readonly isLoading = signal(false); + + constructor() { + effect(() => this.loadCourse(this.courseId()), { allowSignalWrites: true }); + } + + private async loadCourse(courseId: number): Promise { + try { + this.isLoading.set(true); + const courseBody = await lastValueFrom(this.courseManagementService.findOneForDashboard(courseId)); + this.course.set(courseBody.body!); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async enableLearningPaths(): Promise { + try { + this.isLoading.set(true); + await this.learningPathApiService.enableLearningPaths(this.courseId()); + this.course.update((course) => ({ ...course!, learningPathsEnabled: true })); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts index a5b5653ce442..898a2de8d301 100644 --- a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts @@ -2,6 +2,7 @@ import { HttpMethod } from 'app/admin/metrics/metrics.model'; import { inject } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { lastValueFrom } from 'rxjs'; +import { SearchTermPageableSearch } from 'app/shared/table/pageable-table'; export abstract class BaseApiHttpService { private readonly httpClient: HttpClient = inject(HttpClient); @@ -67,6 +68,22 @@ export abstract class BaseApiHttpService { } } + /** + * Creates a `HttpParams` object from the given `SearchTermPageableSearch` object. + * @param pageable The pageable object to create the `HttpParams` object from. + * @protected + * + * @return The `HttpParams` object. + */ + protected createHttpSearchParams(pageable: SearchTermPageableSearch): HttpParams { + return new HttpParams() + .set('pageSize', String(pageable.pageSize)) + .set('page', String(pageable.page)) + .set('sortingOrder', pageable.sortingOrder) + .set('searchTerm', pageable.searchTerm) + .set('sortedColumn', pageable.sortedColumn); + } + /** * Constructs a `GET` request that interprets the body as JSON and * returns a Promise of an object of type `T`. @@ -185,4 +202,34 @@ export abstract class BaseApiHttpService { ): Promise { return await this.request(HttpMethod.Patch, url, { body: body, ...options }); } + + /** + * Constructs a `PUT` request that interprets the body as JSON and + * returns a Promise of an object of type `T`. + * + * @param url The endpoint URL excluding the base server url (/api). + * @param body The content to include in the body of the request. + * @param options The HTTP options to send with the request. + * @protected + * + * @return A `Promise` of type `Object` (T), + */ + protected async put( + url: string, + body?: any, + options?: { + headers?: + | HttpHeaders + | { + [header: string]: string | string[]; + }; + params?: + | HttpParams + | { + [param: string]: string | number | boolean | ReadonlyArray; + }; + }, + ): Promise { + return await this.request(HttpMethod.Put, url, { body: body, ...options }); + } } diff --git a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts index 01a43398f1c8..951927be0f8e 100644 --- a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts @@ -4,12 +4,16 @@ import { LearningObjectType, LearningPathCompetencyDTO, LearningPathDTO, + LearningPathInformationDTO, LearningPathNavigationDTO, LearningPathNavigationObjectDTO, LearningPathNavigationOverviewDTO, + LearningPathsConfigurationDTO, } from 'app/entities/competency/learning-path.model'; import { HttpParams } from '@angular/common/http'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; +import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; @Injectable({ providedIn: 'root', @@ -52,6 +56,10 @@ export class LearningPathApiService extends BaseApiHttpService { return await this.get(`learning-path/${learningPathId}/competency-graph`); } + async getLearningPathInstructorCompetencyGraph(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path/competency-instructor-graph`); + } + async getLearningPathCompetencies(learningPathId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies`); } @@ -59,4 +67,29 @@ export class LearningPathApiService extends BaseApiHttpService { async getLearningPathCompetencyLearningObjects(learningPathId: number, competencyId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies/${competencyId}/learning-objects`); } + + async getLearningPathsConfiguration(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-paths/configuration`); + } + + async getLearningPathHealthStatus(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path-health`); + } + + async updateLearningPathsConfiguration(courseId: number, updatedLearningPathsConfiguration: LearningPathsConfigurationDTO): Promise { + await this.put(`courses/${courseId}/learning-paths/configuration`, updatedLearningPathsConfiguration); + } + + async enableLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/enable`); + } + + async generateMissingLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/generate-missing`); + } + + async getLearningPathInformation(courseId: number, pageable: SearchTermPageableSearch): Promise> { + const params = this.createHttpSearchParams(pageable); + return await this.get>(`courses/${courseId}/learning-paths`, { params }); + } } diff --git a/src/main/webapp/app/course/manage/course-management.route.ts b/src/main/webapp/app/course/manage/course-management.route.ts index ad4507123d1a..c85789f6d74e 100644 --- a/src/main/webapp/app/course/manage/course-management.route.ts +++ b/src/main/webapp/app/course/manage/course-management.route.ts @@ -22,7 +22,6 @@ import { CreateTutorialGroupsConfigurationComponent } from 'app/course/tutorial- import { CourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/course-lti-configuration.component'; import { EditCourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/edit-course-lti-configuration.component'; import { CourseManagementTabBarComponent } from 'app/course/manage/course-management-tab-bar/course-management-tab-bar.component'; -import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { BuildQueueComponent } from 'app/localci/build-queue/build-queue.component'; import { ImportCompetenciesComponent } from 'app/course/competencies/import/import-competencies.component'; @@ -33,6 +32,7 @@ import { ImportPrerequisitesComponent } from 'app/course/competencies/import/imp import { CreatePrerequisiteComponent } from 'app/course/competencies/create/create-prerequisite.component'; import { EditPrerequisiteComponent } from 'app/course/competencies/edit/edit-prerequisite.component'; import { CourseImportStandardizedPrerequisitesComponent } from 'app/course/competencies/import-standardized-competencies/course-import-standardized-prerequisites.component'; +import { LearningPathInstructorPageComponent } from 'app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component'; import { FaqComponent } from 'app/faq/faq.component'; import { FaqUpdateComponent } from 'app/faq/faq-update.component'; import { FaqResolve } from 'app/faq/faq.routes'; @@ -321,7 +321,7 @@ export const courseManagementState: Routes = [ }, { path: 'learning-path-management', - component: LearningPathManagementComponent, + component: LearningPathInstructorPageComponent, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], pageTitle: 'artemisApp.learningPath.manageLearningPaths.title', diff --git a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html index c22ec3d50039..26cf22829413 100644 --- a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html +++ b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html @@ -1,9 +1,14 @@
    - @for (auxiliaryRepository of detail.data.auxiliaryRepositories; track auxiliaryRepository) { + @for (auxiliaryRepository of detail.data.auxiliaryRepositories; track auxiliaryRepository.id) { @if (auxiliaryRepository.id && auxiliaryRepository.repositoryUri && detail.data.exerciseId) {
  • Repository: {{ auxiliaryRepository.name }} - + {{ section.headline | artemisTranslate }}
    - @for (detail of section.details; track detail) { + @for (detail of section.details; track $index) { @if (!!detail) { @if (detail.title) {
    diff --git a/src/main/webapp/app/entities/competency/learning-path-health.model.ts b/src/main/webapp/app/entities/competency/learning-path-health.model.ts index 1cbcb13ba367..803dabf9eca2 100644 --- a/src/main/webapp/app/entities/competency/learning-path-health.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path-health.model.ts @@ -1,5 +1,5 @@ export class LearningPathHealthDTO { - public status?: HealthStatus[]; + public status: HealthStatus[] = []; public missingLearningPaths?: number; constructor(status: HealthStatus[]) { @@ -8,18 +8,12 @@ export class LearningPathHealthDTO { } export enum HealthStatus { - OK = 'OK', - DISABLED = 'DISABLED', MISSING = 'MISSING', NO_COMPETENCIES = 'NO_COMPETENCIES', NO_RELATIONS = 'NO_RELATIONS', } function getWarningTranslation(status: HealthStatus, element: string) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - const translation = { [HealthStatus.MISSING]: 'missing', [HealthStatus.NO_COMPETENCIES]: 'noCompetencies', @@ -29,33 +23,17 @@ function getWarningTranslation(status: HealthStatus, element: string) { } export function getWarningTitle(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'title'); } export function getWarningBody(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'body'); } export function getWarningAction(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'action'); } export function getWarningHint(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'hint'); } diff --git a/src/main/webapp/app/entities/competency/learning-path.model.ts b/src/main/webapp/app/entities/competency/learning-path.model.ts index 2c55868d0af0..576afd4e75a5 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -16,9 +16,9 @@ export class LearningPath implements BaseEntity { } export class LearningPathInformationDTO { - public id?: number; - public user?: UserNameAndLoginDTO; - public progress?: number; + public id: number; + public user: UserNameAndLoginDTO; + public progress: number; } export enum LearningObjectType { @@ -58,8 +58,13 @@ export interface LearningPathNavigationOverviewDTO { learningObjects: LearningPathNavigationObjectDTO[]; } +export interface LearningPathsConfigurationDTO { + includeAllGradedExercises: boolean; +} + export enum CompetencyGraphNodeValueType { MASTERY_PROGRESS = 'MASTERY_PROGRESS', + AVERAGE_MASTERY_PROGRESS = 'AVERAGE_MASTERY_PROGRESS', } export interface CompetencyGraphNodeDTO { diff --git a/src/main/webapp/app/entities/programming/build-agent.model.ts b/src/main/webapp/app/entities/programming/build-agent.model.ts index 172a8596b299..86c51ffc8b35 100644 --- a/src/main/webapp/app/entities/programming/build-agent.model.ts +++ b/src/main/webapp/app/entities/programming/build-agent.model.ts @@ -1,12 +1,18 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { BuildJob } from 'app/entities/programming/build-job.model'; +export enum BuildAgentStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + IDLE = 'IDLE', +} + export class BuildAgent implements BaseEntity { public id?: number; public name?: string; public maxNumberOfConcurrentBuildJobs?: number; public numberOfCurrentBuildJobs?: number; public runningBuildJobs?: BuildJob[]; - public status?: boolean; + public status?: BuildAgentStatus; public recentBuildJobs?: BuildJob[]; } diff --git a/src/main/webapp/app/entities/programming/programming-exercise.model.ts b/src/main/webapp/app/entities/programming/programming-exercise.model.ts index 8dde59469c03..d1d039f7cd38 100644 --- a/src/main/webapp/app/entities/programming/programming-exercise.model.ts +++ b/src/main/webapp/app/entities/programming/programming-exercise.model.ts @@ -16,6 +16,7 @@ export enum ProgrammingLanguage { EMPTY = 'EMPTY', ASSEMBLER = 'ASSEMBLER', C = 'C', + C_PLUS_PLUS = 'C_PLUS_PLUS', HASKELL = 'HASKELL', JAVA = 'JAVA', JAVASCRIPT = 'JAVASCRIPT', diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts index cf949670dcf9..583c85ec8b49 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts @@ -172,6 +172,18 @@ export const routes: Routes = [ }, canActivate: [UserRouteAccessService, LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId', + component: RepositoryViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history', component: CommitHistoryComponent, @@ -184,6 +196,18 @@ export const routes: Routes = [ }, canActivate: [LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId/commit-history', + component: CommitHistoryComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/vcs-access-log', component: VcsRepositoryAccessLogViewComponent, @@ -196,6 +220,18 @@ export const routes: Routes = [ }, canActivate: [LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history/:commitHash', component: CommitDetailsViewComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts index e83414219bb6..b44b87386158 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts @@ -185,4 +185,16 @@ export class ProgrammingExerciseParticipationService implements IProgrammingExer retrieveCommitHistoryForTemplateSolutionOrTests(exerciseId: number, repositoryType: string): Observable { return this.http.get(`${this.resourceUrl}${exerciseId}/commit-history/${repositoryType}`); } + + /** + * Get the commit history for a specific auxiliary repository + * @param exerciseId the exercise the repository belongs to + * @param repositoryType the repositories type + * @param auxiliaryRepositoryId the id of the repository + */ + retrieveCommitHistoryForAuxiliaryRepository(exerciseId: number, auxiliaryRepositoryId: number): Observable { + const params: { [key: string]: number } = {}; + params['repositoryId'] = auxiliaryRepositoryId; + return this.http.get(`${this.resourceUrl}${exerciseId}/commit-history/AUXILIARY`, { params: params }); + } } diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts index 82077ff4ffd6..5b1a793288ff 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts @@ -283,6 +283,16 @@ export class ProgrammingExerciseService { ); } + /** + * Finds the programming exercise for the given exerciseId with its auxiliary repositories + * @param programmingExerciseId of the programming exercise to retrieve + */ + findWithAuxiliaryRepository(programmingExerciseId: number): Observable { + return this.http.get(`${this.resourceUrl}/${programmingExerciseId}/with-auxiliary-repository`, { + observe: 'response', + }); + } + private setLatestResultForTemplateAndSolution(programmingExercise: ProgrammingExercise) { if (programmingExercise.templateParticipation) { const latestTemplateResult = this.getLatestResult(programmingExercise.templateParticipation); diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html index 8f66c32f99dd..59cc94f7c1ba 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html @@ -42,6 +42,8 @@ ngbTooltip="{{ 'artemisApp.programmingExercise.allowOfflineIde.alertNoTheia' | artemisTranslate }}" /> } + } @else { + }
@@ -74,6 +76,8 @@ ngbTooltip="{{ 'artemisApp.programmingExercise.allowOnlineEditor.alertNoTheia' | artemisTranslate }}" /> } + } @else { + }
@@ -99,6 +103,8 @@ [placement]="'top'" ngbTooltip="{{ 'artemisApp.programmingExercise.allowOnlineIde.alert' | artemisTranslate }}" /> + } @else { + }
diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts index a3215aa04c48..555b5d0e84d8 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts @@ -2,6 +2,7 @@ import { StudentParticipation } from 'app/entities/participation/student-partici import { TemplateProgrammingExerciseParticipation } from 'app/entities/participation/template-programming-exercise-participation.model'; import { SolutionProgrammingExerciseParticipation } from 'app/entities/participation/solution-programming-exercise-participation.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; +import { AuxiliaryRepository } from 'app/entities/programming/programming-exercise-auxiliary-repository-model'; /** * Enumeration defining type of the exported file. @@ -49,6 +50,7 @@ export type FileSubmission = { [fileName: string]: string | undefined }; export enum DomainType { PARTICIPATION = 'PARTICIPATION', TEST_REPOSITORY = 'TEST_REPOSITORY', + AUXILIARY_REPOSITORY = 'AUXILIARY_REPOSITORY', } /** @@ -94,7 +96,8 @@ export enum ResizeType { export type DomainParticipationChange = [DomainType.PARTICIPATION, StudentParticipation | TemplateProgrammingExerciseParticipation | SolutionProgrammingExerciseParticipation]; export type DomainTestRepositoryChange = [DomainType.TEST_REPOSITORY, ProgrammingExercise]; -export type DomainChange = DomainParticipationChange | DomainTestRepositoryChange; +export type DomainAuxiliaryRepositoryChange = [DomainType.AUXILIARY_REPOSITORY, AuxiliaryRepository]; +export type DomainChange = DomainParticipationChange | DomainTestRepositoryChange | DomainAuxiliaryRepositoryChange; /** * Enumeration defining the state of git. diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts index eba9259107c1..a045a3b2b62b 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts @@ -66,6 +66,9 @@ export class CodeEditorConflictStateService extends DomainDependentService imple private getDomainKey = () => { const [domainType, domainValue] = this.domain; + if (domainType === DomainType.AUXILIARY_REPOSITORY) { + return `auxiliary-${domainValue.id!.toString()}`; + } return `${domainType === DomainType.PARTICIPATION ? 'participation' : 'test'}-${domainValue.id!.toString()}`; }; } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts index fe6e71ac586a..b124042fec13 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts @@ -35,6 +35,8 @@ export abstract class DomainDependentEndpointService extends DomainDependentServ return `api/repository/${domainValue.id}`; case DomainType.TEST_REPOSITORY: return `api/test-repository/${domainValue.id}`; + case DomainType.AUXILIARY_REPOSITORY: + return `api/auxiliary-repository/${domainValue.id}`; } } } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts index 84d080b67efb..cebaae778af9 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts @@ -1,6 +1,11 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { DomainChange, DomainParticipationChange, DomainTestRepositoryChange } from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; +import { + DomainAuxiliaryRepositoryChange, + DomainChange, + DomainParticipationChange, + DomainTestRepositoryChange, +} from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; /** * This service provides subscribing services with the most recently selected domain (participation vs repository). @@ -9,7 +14,7 @@ import { DomainChange, DomainParticipationChange, DomainTestRepositoryChange } f @Injectable({ providedIn: 'root' }) export class DomainService { protected domain: DomainChange; - private subject = new BehaviorSubject(undefined); + private subject = new BehaviorSubject(undefined); constructor() {} diff --git a/src/main/webapp/app/exercises/shared/result/result.component.html b/src/main/webapp/app/exercises/shared/result/result.component.html index 911320c183df..65c4be9ca9de 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -61,7 +61,7 @@ } @if (!isInSidebarCard) { - ({{ result!.completionDate | artemisTimeAgo }} ) + ({{ result!.completionDate | artemisTimeAgo }}) } @if (hasBuildArtifact() && participation.type === ParticipationType.PROGRAMMING) { diff --git a/src/main/webapp/app/lecture/lecture-detail.component.ts b/src/main/webapp/app/lecture/lecture-detail.component.ts index a36705a78930..ef9e7344b4eb 100644 --- a/src/main/webapp/app/lecture/lecture-detail.component.ts +++ b/src/main/webapp/app/lecture/lecture-detail.component.ts @@ -89,7 +89,7 @@ export class LectureDetailComponent implements OnInit, OnDestroy { } } /** - * Trigger the Ingeston of this Lecture in Iris. + * Trigger the ingestion of this lecture in Iris. */ ingestLectureInPyris() { this.lectureService.ingestLecturesInPyris(this.lecture.course!.id!, this.lecture.id).subscribe({ diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html index ea1b2da0f1d2..1a88dba6a6d8 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html @@ -1,10 +1,25 @@
@if (buildAgent) { -
-
-

- : -

{{ buildAgent.name }}

+
+
+
+

+ : +

{{ buildAgent.name }}

+
+
+ @if (buildAgent.status === 'PAUSED') { + + } @else { + + } +
@@ -28,10 +43,16 @@

{{ buildAgent.nam - @if (value) { - - } @else { - + @switch (value) { + @case ('ACTIVE') { + + } + @case ('IDLE') { + + } + @case ('PAUSED') { + + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts index 074c1e704695..de19544b01ad 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts @@ -2,12 +2,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; -import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; import { TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { ActivatedRoute } from '@angular/router'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; +import { AlertService, AlertType } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-build-agent-details', @@ -28,12 +29,15 @@ export class BuildAgentDetailsComponent implements OnInit, OnDestroy { faExclamationCircle = faExclamationCircle; faExclamationTriangle = faExclamationTriangle; faTimes = faTimes; + readonly faPause = faPause; + readonly faPlay = faPlay; constructor( private websocketService: JhiWebsocketService, private buildAgentsService: BuildAgentsService, private route: ActivatedRoute, private buildQueueService: BuildQueueService, + private alertService: AlertService, ) {} ngOnInit() { @@ -105,4 +109,52 @@ export class BuildAgentDetailsComponent implements OnInit, OnDestroy { const url = `/api/build-log/${resultId}`; window.open(url, '_blank'); } + + pauseBuildAgent(): void { + if (this.buildAgent.name) { + this.buildAgentsService.pauseBuildAgent(this.buildAgent.name).subscribe({ + next: () => { + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentPaused', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + }, + }); + } else { + this.alertService.addAlert({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + } + } + + resumeBuildAgent(): void { + if (this.buildAgent.name) { + this.buildAgentsService.resumeBuildAgent(this.buildAgent.name).subscribe({ + next: () => { + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentResumed', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }, + }); + } else { + this.alertService.addAlert({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + } + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html index a9b878d14d33..0982463d75a9 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html @@ -41,10 +41,16 @@

- @if (value) { - - } @else { - + @switch (value) { + @case ('ACTIVE') { + + } + @case ('IDLE') { + + } + @case ('PAUSED') { + + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index 99905ad80c51..b701b85fe5d0 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -27,4 +27,28 @@ export class BuildAgentsService { }), ); } + + /** + * Pause Build Agent + */ + pauseBuildAgent(agentName: string): Observable { + const encodedAgentName = encodeURIComponent(agentName); + return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/pause`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to pause build agent ${agentName}\n${err.message}`)); + }), + ); + } + + /** + * Resume Build Agent + */ + resumeBuildAgent(agentName: string): Observable { + const encodedAgentName = encodeURIComponent(agentName); + return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/resume`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to resume build agent ${agentName}\n${err.message}`)); + }), + ); + } } diff --git a/src/main/webapp/app/localvc/commit-history/commit-history.component.ts b/src/main/webapp/app/localvc/commit-history/commit-history.component.ts index fe98dd02cb53..f734f9f2eec3 100644 --- a/src/main/webapp/app/localvc/commit-history/commit-history.component.ts +++ b/src/main/webapp/app/localvc/commit-history/commit-history.component.ts @@ -24,6 +24,7 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { participationId: number; exerciseId: number; repositoryType: string; + repositoryId?: number; paramSub: Subscription; commits: CommitInfo[]; commitsInfoSubscription: Subscription; @@ -53,6 +54,7 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { this.participationId = Number(params['participationId']); this.exerciseId = Number(params['exerciseId']); this.repositoryType = params['repositoryType']; + this.repositoryId = Number(params['repositoryId']); if (this.repositoryType) { this.loadDifferentParticipation(); } else { @@ -88,6 +90,8 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { } else if (this.repositoryType === 'TESTS') { this.isTestRepository = true; this.participation = this.exercise.templateParticipation!; + } else if (this.repositoryType === 'AUXILIARY') { + this.participation = this.exercise.templateParticipation!; } }), ) @@ -121,26 +125,59 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { } /** - * Retrieves the commit history for the participation and filters out the commits that have no submission. + * Retrieves the commit history and handles it depending on repository type * The last commit is always the template commit and is added to the list of commits. * @private */ private handleCommits() { - if (this.repositoryType) { - this.commitsInfoSubscription = this.programmingExerciseParticipationService - .retrieveCommitHistoryForTemplateSolutionOrTests(this.exerciseId, this.repositoryType) - .subscribe((commits) => { - this.commits = this.sortCommitsByTimestampDesc(commits); - if (!this.isTestRepository) { - this.setCommitResults(); - } - }); + if (!this.repositoryType) { + this.handleParticipationCommits(); + } else if (this.repositoryType === 'AUXILIARY') { + this.handleAuxiliaryRepositoryCommits(); } else { - this.commitsInfoSubscription = this.programmingExerciseParticipationService.retrieveCommitHistoryForParticipation(this.participation.id!).subscribe((commits) => { + this.handleTemplateSolutionTestRepositoryCommits(); + } + } + + /** + * Retrieves the commit history and filters out the commits that have no submission. + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleParticipationCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService.retrieveCommitHistoryForParticipation(this.participation.id!).subscribe((commits) => { + this.commits = this.sortCommitsByTimestampDesc(commits); + this.setCommitResults(); + }); + } + + /** + * Retrieves the commit history for an auxiliary repository + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleAuxiliaryRepositoryCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService + .retrieveCommitHistoryForAuxiliaryRepository(this.exerciseId, this.repositoryId!) + .subscribe((commits) => { this.commits = this.sortCommitsByTimestampDesc(commits); - this.setCommitResults(); }); - } + } + + /** + * Retrieves the commit history for template/solution/test repositories. + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleTemplateSolutionTestRepositoryCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService + .retrieveCommitHistoryForTemplateSolutionOrTests(this.exerciseId, this.repositoryType) + .subscribe((commits) => { + this.commits = this.sortCommitsByTimestampDesc(commits); + if (!this.isTestRepository) { + this.setCommitResults(); + } + }); } /** diff --git a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts index 72e9bbdfc3ad..8e0b537d7abd 100644 --- a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts +++ b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts @@ -91,11 +91,14 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { this.participationCouldNotBeFetched = false; const exerciseId = Number(params['exerciseId']); const participationId = Number(params['participationId']); + const repositoryId = Number(params['repositoryId']); this.repositoryType = participationId ? 'USER' : params['repositoryType']; this.vcsAccessLogRoute = this.router.url + '/vcs-access-log'; this.enableVcsAccessLog = this.router.url.includes('course-management') && params['repositoryType'] !== 'TESTS'; if (this.repositoryType === 'USER') { this.loadStudentParticipation(participationId); + } else if (this.repositoryType === 'AUXILIARY') { + this.loadAuxiliaryRepository(exerciseId, repositoryId); } else { this.loadDifferentParticipation(this.repositoryType, exerciseId); } @@ -190,4 +193,27 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { }), ); } + + private loadAuxiliaryRepository(exerciseId: number, auxiliaryRepositoryId: number) { + this.programmingExerciseService + .findWithAuxiliaryRepository(exerciseId) + .pipe( + tap((exerciseResponse) => { + this.exercise = exerciseResponse.body!; + const auxiliaryRepo = this.exercise.auxiliaryRepositories?.find((repo) => repo.id === auxiliaryRepositoryId); + if (auxiliaryRepo) { + this.domainService.setDomain([DomainType.AUXILIARY_REPOSITORY, auxiliaryRepo]); + this.repositoryUri = auxiliaryRepo.repositoryUri!; + } + }), + ) + .subscribe({ + next: () => { + this.loadingParticipation = false; + }, + error: () => { + this.participationCouldNotBeFetched = true; + }, + }); + } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 90b2559fa81a..e4cd32cdbecb 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -29,10 +29,14 @@ [itemSelected]="conversationSelected" [courseId]="course.id" [sidebarData]="sidebarData" - (onPlusPressed)="onAccordionPlusButtonPressed($event)" + (onCreateChannelPressed)="openCreateChannelDialog()" + (onBrowsePressed)="openChannelOverviewDialog()" + (onDirectChatPressed)="openCreateOneToOneChatDialog()" + (onGroupChatPressed)="openCreateGroupChatDialog()" [showAddOption]="CHANNEL_TYPE_SHOW_ADD_OPTION" [channelTypeIcon]="CHANNEL_TYPE_ICON" [collapseState]="DEFAULT_COLLAPSE_STATE" + [inCommunication]="true" />
@if (course && !activeConversation && isCodeOfConductPresented) { diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 2d56484f8a46..da7dd332f047 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -1,12 +1,12 @@ -import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { Post } from 'app/entities/metis/post.model'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { EMPTY, Subject, Subscription, from, take, takeUntil } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { EMPTY, Observable, Subject, Subscription, from, take, takeUntil } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; -import { ChannelSubType, getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { MetisService } from 'app/shared/metis/metis.service'; import { Course, isMessagingEnabled } from 'app/entities/course.model'; import { PageType, SortDirection } from 'app/shared/metis/metis.util'; @@ -16,11 +16,12 @@ import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/ import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData } from 'app/types/sidebar'; import { CourseOverviewService } from 'app/overview/course-overview.service'; import { GroupChatCreateDialogComponent } from 'app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component'; -import { defaultFirstLayerDialogOptions } from 'app/overview/course-conversations/other/conversation.util'; +import { defaultFirstLayerDialogOptions, defaultSecondLayerDialogOptions } from 'app/overview/course-conversations/other/conversation.util'; import { UserPublicInfoDTO } from 'app/core/user/user.model'; import { OneToOneChatCreateDialogComponent } from 'app/overview/course-conversations/dialogs/one-to-one-chat-create-dialog/one-to-one-chat-create-dialog.component'; -import { ChannelsOverviewDialogComponent } from 'app/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component'; +import { ChannelAction, ChannelsOverviewDialogComponent } from 'app/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component'; const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, @@ -114,7 +115,9 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { faFilter = faFilter; faSearch = faSearch; - // MetisConversationService is created in course overview, so we can use it here + createChannelFn?: (channel: ChannelDTO) => Observable; + channelActions$ = new EventEmitter(); + constructor( private router: Router, private activatedRoute: ActivatedRoute, @@ -162,6 +165,16 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.isServiceSetUp = true; this.isLoading = false; } + this.channelActions$ + .pipe( + debounceTime(500), + distinctUntilChanged((prev, curr) => prev.action === curr.action && prev.channel.id === curr.channel.id), + takeUntil(this.ngUnsubscribe), + ) + .subscribe((channelAction) => { + this.performChannelAction(channelAction); + }); + this.createChannelFn = (channel: ChannelDTO) => this.metisConversationService.createChannel(channel); }); this.profileSubscription = this.profileService.getProfileInfo()?.subscribe((profileInfo) => { @@ -170,6 +183,21 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + performChannelAction(channelAction: ChannelAction) { + if (this.createChannelFn) { + this.createChannelFn(channelAction.channel) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe({ + complete: () => { + this.prepareSidebarData(); + }, + error: (error) => { + console.error('Error creating channel:', error); + }, + }); + } + } + subscribeToQueryParameter() { this.activatedRoute.queryParams.pipe(take(1), takeUntil(this.ngUnsubscribe)).subscribe((queryParams) => { if (queryParams.conversationId) { @@ -270,8 +298,8 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { storageId: 'conversation', groupedData: this.accordionConversationGroups, ungroupedData: this.sidebarConversations, - showAccordionAddOption: true, showAccordionLeadingIcon: true, + messagingEnabled: isMessagingEnabled(this.course), }; } @@ -284,16 +312,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.courseOverviewService.setSidebarCollapseState('conversation', this.isCollapsed); } - onAccordionPlusButtonPressed(chatType: string) { - if (chatType === 'groupChats') { - this.openCreateGroupChatDialog(); - } else if (chatType === 'directMessages') { - this.openCreateOneToOneChatDialog(); - } else { - this.openChannelOverviewDialog(chatType); - } - } - openCreateGroupChatDialog() { const modalRef: NgbModalRef = this.modalService.open(GroupChatCreateDialogComponent, defaultFirstLayerDialogOptions); modalRef.componentInstance.course = this.course; @@ -332,8 +350,22 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } - openChannelOverviewDialog(groupKey: string) { - const subType = this.getChannelSubType(groupKey); + openCreateChannelDialog() { + const modalRef: NgbModalRef = this.modalService.open(ChannelsCreateDialogComponent, defaultSecondLayerDialogOptions); + modalRef.componentInstance.course = this.course; + modalRef.componentInstance.initialize(); + from(modalRef.result) + .pipe( + catchError(() => EMPTY), + takeUntil(this.ngUnsubscribe), + ) + .subscribe((channel: ChannelDTO) => { + this.channelActions$.emit({ action: 'create', channel }); + }); + } + + openChannelOverviewDialog() { + const subType = null; const modalRef: NgbModalRef = this.modalService.open(ChannelsOverviewDialogComponent, defaultFirstLayerDialogOptions); modalRef.componentInstance.course = this.course; modalRef.componentInstance.createChannelFn = subType === ChannelSubType.GENERAL ? this.metisConversationService.createChannel : undefined; @@ -363,22 +395,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } - getChannelSubType(groupKey: string) { - if (groupKey === 'exerciseChannels') { - return ChannelSubType.EXERCISE; - } - if (groupKey === 'generalChannels') { - return ChannelSubType.GENERAL; - } - if (groupKey === 'lectureChannels') { - return ChannelSubType.LECTURE; - } - if (groupKey === 'examChannels') { - return ChannelSubType.EXAM; - } - return ChannelSubType.GENERAL; - } - toggleChannelSearch() { this.channelSearchCollapsed = !this.channelSearchCollapsed; } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html index cd5b295812a3..e7c6413b8b22 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html @@ -1,5 +1,5 @@ @if (isInitialized) { -
+
} diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts index 64cb3410ae78..9a9b9b56bb6e 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts @@ -9,7 +9,7 @@ import { onError } from 'app/shared/util/global.utils'; import { EMPTY, Subject, from, takeUntil } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { canChangeChannelArchivalState, canDeleteChannel, canLeaveConversation } from 'app/shared/metis/conversations/conversation-permissions.utils'; import { GroupChatService } from 'app/shared/metis/conversations/group-chat.service'; import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; @@ -42,7 +42,7 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); - faTimes = faTimes; + readonly faTrash = faTrash; conversationAsChannel: ChannelDTO | undefined; canLeaveConversation: boolean; diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component.html index d8e0e8a5aa1a..b84a700f6e35 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component.html @@ -1,5 +1,5 @@ @if (isInitialized) { -
+