diff --git a/build.gradle b/build.gradle index b59c9016479f..1cb102989859 100644 --- a/build.gradle +++ b/build.gradle @@ -15,16 +15,16 @@ plugins { id "idea" id "jacoco" id "org.springframework.boot" version "${spring_boot_version}" - id "io.spring.dependency-management" version "1.1.2" + id "io.spring.dependency-management" version "1.1.3" id "com.google.cloud.tools.jib" version "3.3.2" id "com.github.node-gradle.node" version "${gradle_node_plugin_version}" - id "com.diffplug.spotless" version "6.20.0" + id "com.diffplug.spotless" version "6.21.0" // this allows us to find outdated dependencies via ./gradlew dependencyUpdates - id "com.github.ben-manes.versions" version "0.47.0" + id "com.github.ben-manes.versions" version "0.48.0" id "com.github.andygoossens.modernizer" version "${modernizer_plugin_version}" id "com.gorylenko.gradle-git-properties" version "2.4.1" id "info.solidsoft.pitest" version "1.9.11" - id "org.owasp.dependencycheck" version "8.3.1" + id "org.owasp.dependencycheck" version "8.4.0" id "com.adarshr.test-logger" version "3.2.0" } @@ -122,7 +122,7 @@ tasks.register("testReport", TestReport) { } jacoco { - toolVersion = "0.8.8" + toolVersion = "0.8.10" } jar { @@ -263,15 +263,15 @@ dependencies { implementation "org.springdoc:springdoc-openapi-ui:1.7.0" implementation "com.vdurmont:semver4j:3.1.0" - implementation "com.github.docker-java:docker-java-core:3.3.2" - implementation "com.github.docker-java:docker-java-transport-httpclient5:3.3.2" + implementation "com.github.docker-java:docker-java-core:3.3.3" + implementation "com.github.docker-java:docker-java-transport-httpclient5:3.3.3" // import JHipster dependencies BOM implementation platform("tech.jhipster:jhipster-dependencies:${jhipster_dependencies_version}") 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.11.2" + implementation "io.micrometer:micrometer-registry-prometheus:1.11.4" implementation "net.logstash.logback:logstash-logback-encoder:7.4" implementation "com.fasterxml.jackson.datatype:jackson-datatype-hppc:${fasterxml_version}" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${fasterxml_version}" @@ -288,7 +288,7 @@ dependencies { implementation "org.apache.commons:commons-math3:3.6.1" implementation "javax.transaction:javax.transaction-api:1.3" implementation "org.hibernate:hibernate-entitymanager:${hibernate_version}" - implementation "org.liquibase:liquibase-core:4.23.0" + implementation "org.liquibase:liquibase-core:4.23.2" implementation "org.springframework.boot:spring-boot-starter-validation:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-loader-tools:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-mail:${spring_boot_version}" @@ -305,16 +305,16 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-thymeleaf:${spring_boot_version}" implementation "org.springframework.ldap:spring-ldap-core:2.4.1" - implementation "org.springframework.data:spring-data-ldap:2.7.14" + implementation "org.springframework.data:spring-data-ldap:2.7.15" implementation "org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:3.1.7" implementation "org.springframework.cloud:spring-cloud-starter-config:3.1.8" implementation "org.springframework.boot:spring-boot-starter-cloud-connectors:2.2.13.RELEASE" - implementation "io.netty:netty-all:4.1.96.Final" - implementation "io.projectreactor.netty:reactor-netty:1.1.9" + implementation "io.netty:netty-all:4.1.97.Final" + implementation "io.projectreactor.netty:reactor-netty:1.1.11" implementation "org.springframework:spring-messaging:5.3.29" - implementation "org.springframework.retry:spring-retry:2.0.2" + implementation "org.springframework.retry:spring-retry:2.0.3" implementation "org.springframework.security:spring-security-config:${spring_security_version}" implementation "org.springframework.security:spring-security-data:${spring_security_version}" @@ -333,15 +333,15 @@ dependencies { implementation "io.springfox:springfox-bean-validators:3.0.0" implementation "mysql:mysql-connector-java:8.0.33" implementation "org.postgresql:postgresql:42.6.0" - implementation "com.h2database:h2:2.2.220" + implementation "com.h2database:h2:2.2.222" // zalando problem spring web can only be updated when we support Spring 6 implementation "org.zalando:problem-spring-web:0.27.0" implementation "com.ibm.icu:icu4j:73.2" implementation "com.github.seancfoley:ipaddress:5.4.0" - implementation "org.apache.maven:maven-model:3.9.3" - implementation "org.apache.pdfbox:pdfbox:2.0.29" - implementation "com.google.protobuf:protobuf-java:3.23.4" + implementation "org.apache.maven:maven-model:3.9.4" + implementation "org.apache.pdfbox:pdfbox:3.0.0" + implementation "com.google.protobuf:protobuf-java:3.24.3" implementation "org.apache.commons:commons-csv:1.10.0" implementation "org.commonmark:commonmark:0.21.0" implementation "commons-fileupload:commons-fileupload:1.5" @@ -366,14 +366,14 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter:${junit_version}" testImplementation "org.mockito:mockito-core:${mockito_version}" testImplementation "org.mockito:mockito-junit-jupiter:${mockito_version}" - testImplementation "io.github.classgraph:classgraph:4.8.161" + testImplementation "io.github.classgraph:classgraph:4.8.162" testImplementation "org.awaitility:awaitility:4.2.0" testImplementation "org.apache.maven.shared:maven-invoker:3.2.0" - testImplementation "org.gradle:gradle-tooling-api:8.2.1" + testImplementation "org.gradle:gradle-tooling-api:8.3" testImplementation "org.apache.maven.surefire:surefire-report-parser:3.1.2" testImplementation "com.opencsv:opencsv:5.8" testImplementation "io.zonky.test:embedded-database-spring-test:2.3.0" - testImplementation "com.tngtech.archunit:archunit:1.0.1" + testImplementation "com.tngtech.archunit:archunit:1.1.0" testImplementation "org.skyscreamer:jsonassert:1.5.1" // Lightweight JSON library needed for the internals of the MockRestServiceServer @@ -417,7 +417,7 @@ tasks.withType(Test).configureEach { } wrapper { - gradleVersion = "8.2.1" + gradleVersion = "8.3" } tasks.register("stage") { diff --git a/gradle.properties b/gradle.properties index f78ac154d0fc..a3781fb28016 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,22 +7,22 @@ npm_version=9.6.0 # Dependency versions jhipster_dependencies_version=7.9.3 -spring_boot_version=2.7.14 +spring_boot_version=2.7.15 spring_security_version=5.7.10 hibernate_version=5.6.15.Final jaxb_runtime_version=4.0.3 -hazelcast_version=5.3.1 +hazelcast_version=5.3.2 junit_version=5.10.0 -mockito_version=5.4.0 +mockito_version=5.5.0 fasterxml_version=2.15.2 -jgit_version=6.6.0.202305301015-r -checkstyle_version=10.12.1 +jgit_version=6.7.0.202309050840-r +checkstyle_version=10.12.3 jplag_version=4.3.0 slf4j_version=1.7.36 -sentry_version=6.27.0 +sentry_version=6.29.0 # gradle plugin version -gradle_node_plugin_version=5.0.0 +gradle_node_plugin_version=7.0.0 apt_plugin_version=0.21 liquibase_plugin_version=2.1.1 modernizer_plugin_version=1.8.0 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f4197d5f4b9..ac72c34e8acc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/package-lock.json b/package-lock.json index 6ce1f04ecd2a..3ffa9dda7921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,18 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "16.2.3", - "@angular/cdk": "16.2.2", - "@angular/common": "16.2.3", - "@angular/compiler": "16.2.3", - "@angular/core": "16.2.3", - "@angular/forms": "16.2.3", - "@angular/localize": "16.2.3", - "@angular/material": "16.2.2", - "@angular/platform-browser": "16.2.3", - "@angular/platform-browser-dynamic": "16.2.3", - "@angular/router": "16.2.3", - "@angular/service-worker": "16.2.3", + "@angular/animations": "16.2.5", + "@angular/cdk": "16.2.4", + "@angular/common": "16.2.5", + "@angular/compiler": "16.2.5", + "@angular/core": "16.2.5", + "@angular/forms": "16.2.5", + "@angular/localize": "16.2.5", + "@angular/material": "16.2.4", + "@angular/platform-browser": "16.2.5", + "@angular/platform-browser-dynamic": "16.2.5", + "@angular/router": "16.2.5", + "@angular/service-worker": "16.2.5", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "16.0.1", "@fingerprintjs/fingerprintjs": "4.0.1", @@ -34,16 +34,16 @@ "@ng-bootstrap/ng-bootstrap": "15.1.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular-ivy": "7.66.0", - "@sentry/tracing": "7.66.0", - "@sentry/types": "7.66.0", + "@sentry/angular-ivy": "7.69.0", + "@sentry/tracing": "7.69.0", + "@sentry/types": "7.69.0", "@swimlane/ngx-charts": "20.4.1", "@swimlane/ngx-graph": "8.2.2", - "ace-builds": "1.24.1", + "ace-builds": "1.25.1", "bootstrap": "5.3.1", "brace": "0.11.1", "compare-versions": "6.1.0", - "core-js": "3.32.1", + "core-js": "3.32.2", "crypto-js": "4.1.1", "dayjs": "1.11.9", "diff-match-patch-typescript": "1.0.8", @@ -70,37 +70,37 @@ "split.js": "1.6.5", "ts-cacheable": "1.0.9", "tslib": "2.6.2", - "uuid": "9.0.0", + "uuid": "9.0.1", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", "zone.js": "0.13.0" }, "devDependencies": { "@angular-builders/jest": "16.0.1", - "@angular-eslint/builder": "16.1.1", - "@angular-eslint/eslint-plugin": "16.1.1", - "@angular-eslint/eslint-plugin-template": "16.1.1", - "@angular-eslint/schematics": "16.1.1", - "@angular-eslint/template-parser": "16.1.1", - "@angular/cli": "16.2.1", - "@angular/compiler-cli": "16.2.3", - "@angular/language-service": "16.2.3", - "@types/crypto-js": "4.1.1", + "@angular-eslint/builder": "16.1.2", + "@angular-eslint/eslint-plugin": "16.1.2", + "@angular-eslint/eslint-plugin-template": "16.1.2", + "@angular-eslint/schematics": "16.1.2", + "@angular-eslint/template-parser": "16.1.2", + "@angular/cli": "16.2.2", + "@angular/compiler-cli": "16.2.5", + "@angular/language-service": "16.2.5", + "@types/crypto-js": "4.1.2", "@types/d3-shape": "3.1.2", "@types/dompurify": "3.0.2", "@types/jest": "29.5.4", "@types/lodash-es": "4.17.9", - "@types/node": "20.5.7", + "@types/node": "20.6.0", "@types/papaparse": "5.3.8", "@types/showdown": "2.0.1", "@types/smoothscroll-polyfill": "0.3.1", "@types/sockjs-client": "1.5.1", "@types/uuid": "9.0.3", - "@typescript-eslint/eslint-plugin": "6.5.0", - "@typescript-eslint/parser": "6.5.0", - "eslint": "8.48.0", + "@typescript-eslint/eslint-plugin": "6.7.0", + "@typescript-eslint/parser": "6.7.0", + "eslint": "8.49.0", "eslint-config-prettier": "9.0.0", - "eslint-plugin-deprecation": "1.5.0", + "eslint-plugin-deprecation": "2.0.0", "eslint-plugin-jest": "27.2.3", "eslint-plugin-jest-extended": "2.0.0", "eslint-plugin-prettier": "5.0.0", @@ -116,7 +116,7 @@ "lint-staged": "14.0.1", "ng-mocks": "14.11.0", "prettier": "3.0.3", - "sass": "1.66.1", + "sass": "1.67.0", "ts-jest": "29.1.1", "typescript": "5.1.6", "weak-napi": "2.0.2", @@ -381,12 +381,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.1.tgz", - "integrity": "sha512-rXXO5zSI/iN6JtU3oU+vKfOB1N8n1iCH9aLudtJfO5zT9r29FIvV4YMmHO0iu78i4IhQAeJdr42cvrGPp8Y41A==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.2.tgz", + "integrity": "sha512-KeXIlibVrQEwIKbR9GViLKc3m1SXi/xuSXgIvSv+22FNu5i91ScsAhYLe65sDUL6m6MM1XQQMS46XN1Z9bRqQw==", "dev": true, "dependencies": { - "@angular-devkit/core": "16.2.1", + "@angular-devkit/core": "16.2.2", "jsonc-parser": "3.2.0", "magic-string": "0.30.1", "ora": "5.4.1", @@ -398,10 +398,37 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.2.tgz", + "integrity": "sha512-6H4FsvP3rLJaGiWpIhCFPS7ZeNoM4sSrnFtRhhecu6s7MidzE4aqzuGdzJpzLammw1KL+DuTlN0gpLtM1Bvcwg==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "2.3.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-eslint/builder": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-16.1.1.tgz", - "integrity": "sha512-NaB/A0mmlzp7laiucRUsRyoCrOE1In3UifsGP0vD6yjUpefk4g0v+0vCg8mhsIky8gYDtBE9YRfUiLA9FlF/FA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-16.1.2.tgz", + "integrity": "sha512-Y95IBEWqzWA7SyIh5nlPuFasw/4lOILrAdY5Ji6tOpIJgNFoiR9K1UcH46i34r3384ApN8GEQJ7FlK6D6qCOJA==", "dev": true, "dependencies": { "@nx/devkit": "16.5.1", @@ -413,18 +440,18 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-16.1.1.tgz", - "integrity": "sha512-TB01AWZBDfrZBxN1I50HfBXtC7q4NI5fwl1aS4tOfef2/kQjTtR9zmha8CsxjDkAOa9tA/4MUayAMqEBQLuHKQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-16.1.2.tgz", + "integrity": "sha512-wDiHPFsKTijMcQUPNcoHOJ5kezIPCCbmDK6LHH7hAdAC/eDY9NHL5e4zQ2Xkf3/r1PFuwVLGTwwreEHlmeENDw==", "dev": true }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-16.1.1.tgz", - "integrity": "sha512-GauEwFGEcgIdsld4cVarFJYYxaRbMLzbpxyvBUDFg4LNjlcQNt7zfqXRLJoZAaFJFPtGtAoo1+6BlEKErsntuQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-16.1.2.tgz", + "integrity": "sha512-lYVvoKUIOg/ez15yfN4zY2A++vnIeJe1xh2ADNTmmjSh2PFV24K9YOgrTbgrY3Ul9kzGDTBkvYqslq+IvMGdIw==", "dev": true, "dependencies": { - "@angular-eslint/utils": "16.1.1", + "@angular-eslint/utils": "16.1.2", "@typescript-eslint/utils": "5.62.0" }, "peerDependencies": { @@ -433,13 +460,13 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-16.1.1.tgz", - "integrity": "sha512-hwbpiUxLIY3TnZycieh+G4fbTWGMfzKx076O5Vuh2H4ZfXfs6ZXoi3Z0TH6X9lTmdgrwzOg1v4o5kdqu7MqPBg==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-16.1.2.tgz", + "integrity": "sha512-2qsoUgPg9Qp4EVUJRwWcJ+8JMxBb0ma3pNBjFmY6LOd59igRYorJKfWep4Nln1EicYRDRsCLzeLHO976+b1yaQ==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "16.1.1", - "@angular-eslint/utils": "16.1.1", + "@angular-eslint/bundled-angular-compiler": "16.1.2", + "@angular-eslint/utils": "16.1.2", "@typescript-eslint/type-utils": "5.62.0", "@typescript-eslint/utils": "5.62.0", "aria-query": "5.3.0", @@ -451,13 +478,13 @@ } }, "node_modules/@angular-eslint/schematics": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-16.1.1.tgz", - "integrity": "sha512-KlR01gpURPjz5OcoEvmKv3zi8l6lFpXYmqkXbGMCz828QlqBz1X7iGLAPJki+WUFSFKbRsf4qqaWq6O/8vph7Q==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-16.1.2.tgz", + "integrity": "sha512-319i47NU6nfaAaQTQYN7k320proTIBCueWGt+fbT11210CMqQriFmD+B85AatCwQgMgLd8Rhs1/F7YL2OOhegA==", "dev": true, "dependencies": { - "@angular-eslint/eslint-plugin": "16.1.1", - "@angular-eslint/eslint-plugin-template": "16.1.1", + "@angular-eslint/eslint-plugin": "16.1.2", + "@angular-eslint/eslint-plugin-template": "16.1.2", "@nx/devkit": "16.5.1", "ignore": "5.2.4", "nx": "16.5.1", @@ -469,12 +496,12 @@ } }, "node_modules/@angular-eslint/template-parser": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-16.1.1.tgz", - "integrity": "sha512-ZJ+M4+JGYcsIP/t+XiuzL5A5pCjjCen272U3/M/WqIMDDxyIKrHubK1bVtr2kndCEudqud+WyJU0ub13UIwGgw==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-16.1.2.tgz", + "integrity": "sha512-vIkPOShVJLBEHYY3jISCVvJF3lXL//Y70J8T9lY2CBowgqp6AzzJ6cZU7JxrORN6b64rBUVvUtCGo8L36GvfuA==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "16.1.1", + "@angular-eslint/bundled-angular-compiler": "16.1.2", "eslint-scope": "^7.0.0" }, "peerDependencies": { @@ -483,12 +510,12 @@ } }, "node_modules/@angular-eslint/utils": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-16.1.1.tgz", - "integrity": "sha512-cmSTyFFY2TMLjhKdju0KQ9GB6nnXt1AbY9tZ0UtWGo3NKbrBUogc+PR9ma17VRAGhvdj/sSVkStphJH3F7rUgQ==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-16.1.2.tgz", + "integrity": "sha512-2yfEK3BPSJsUhP4JCz0EB6ktu4E4+/zc9qdtZvPWNF/eww2J/oYVPjY47C/HVg4MXpjJTI8vbdkvcnxrICIkfw==", "dev": true, "dependencies": { - "@angular-eslint/bundled-angular-compiler": "16.1.1", + "@angular-eslint/bundled-angular-compiler": "16.1.2", "@typescript-eslint/utils": "5.62.0" }, "peerDependencies": { @@ -497,9 +524,9 @@ } }, "node_modules/@angular/animations": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-16.2.3.tgz", - "integrity": "sha512-MEjCWjN7RcHNFHkDYB3ZvEQqt94EzwevVXfld6rcOZNwJxcOVyi7+nQQ1YhWLPSW81HF76bpwD3RWWhZpKdXQQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-16.2.5.tgz", + "integrity": "sha512-2reD50S9zWvhewRvwl320iuRICN9s0fI+3nKULlwcyJ0praLRhJ1SnaAK3NEEu7MWo3n9sb3iVTzA6S9qZRJ4g==", "dependencies": { "tslib": "^2.3.0" }, @@ -507,13 +534,13 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/core": "16.2.3" + "@angular/core": "16.2.5" } }, "node_modules/@angular/cdk": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.2.2.tgz", - "integrity": "sha512-luUmeIFuEX4N3EOLhg1DM2hgsR+Is1Qd0a5xflbo30hZFnufppyzjaOvljNYUFtNTD9BaQRXaZDFA2cyTgfzZw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.2.4.tgz", + "integrity": "sha512-Hnh7Gs+gAkBnRYIMkDXRElEPAmBFas37isIfOtiqEmkgmSPFxsPpDOXK1soXeDk8U+yNmDWnO0fcHPp/pobHCw==", "dependencies": { "tslib": "^2.3.0" }, @@ -527,15 +554,15 @@ } }, "node_modules/@angular/cli": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.2.1.tgz", - "integrity": "sha512-nuCc0VOGjuUFQo1Pu9CyFQ4VTy7OuwTiwxOG9qbut4FSGz2CO9NeqoamPUuy6rpKVu5JxVe+L6Y4OFaNKv2n3Q==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.2.2.tgz", + "integrity": "sha512-PmhR/NMVVCiATXxHLkVCV781Q5aa5DaYye9+plZGX3rdKTilEunRNIfT13w7IuRfa0K/pKZj6PJU1S6yb7sqZg==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1602.1", - "@angular-devkit/core": "16.2.1", - "@angular-devkit/schematics": "16.2.1", - "@schematics/angular": "16.2.1", + "@angular-devkit/architect": "0.1602.2", + "@angular-devkit/core": "16.2.2", + "@angular-devkit/schematics": "16.2.2", + "@schematics/angular": "16.2.2", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.1", @@ -560,10 +587,52 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.1602.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1602.2.tgz", + "integrity": "sha512-JFIeKKW7V2+/C8+pTReM6gfQkVU9l1IR1OCb9vvHWTRvuTr7E5h2L1rUInnmLiRWkEvkYfG29B+UPpYlkVl9oQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "16.2.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.2.tgz", + "integrity": "sha512-6H4FsvP3rLJaGiWpIhCFPS7ZeNoM4sSrnFtRhhecu6s7MidzE4aqzuGdzJpzLammw1KL+DuTlN0gpLtM1Bvcwg==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "2.3.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular/common": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-16.2.3.tgz", - "integrity": "sha512-hOC2yqISBRAzltuVJQ3CEJxHRp9mWggysp0or5HydbcmvB6WIroECL7U0u36VA95zC9SXnymHA13OwiFPpmahA==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-16.2.5.tgz", + "integrity": "sha512-MCPSZfPXTEqdkswPczivwjqV117YeVjObtyxZsDAwrTZHzYBtfQreQG1XJ1IRRgDncznP6ke0mdH9LyD2LgZKQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -571,14 +640,14 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/core": "16.2.3", + "@angular/core": "16.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-16.2.3.tgz", - "integrity": "sha512-bFc7YRHNdBJZD2HiORBQun2p40emvEt8D4JwXnW1JIStAWKJOXLyEjx045wNddqH7NpUq8AE2F1i82hIDNQZ1g==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-16.2.5.tgz", + "integrity": "sha512-DpLfWWZFk4lbr81W7sLRt15+/nbyyqTvz+UmGcrSfKBTSbV0VSoUjC3XZeIdPWoIgQXiKUCpaC0YXw0BjaOl0g==", "dependencies": { "tslib": "^2.3.0" }, @@ -586,7 +655,7 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/core": "16.2.3" + "@angular/core": "16.2.5" }, "peerDependenciesMeta": { "@angular/core": { @@ -595,9 +664,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-16.2.3.tgz", - "integrity": "sha512-4p1tDeeONiq/zceC0T6unXDuqyWiAe7v2Ag7+ewwM9V8BF+YOEpEI/41lxzmbK2U1YUvG3jWfZyw3ertQlMp0Q==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-16.2.5.tgz", + "integrity": "sha512-6TtyFxro4iukVXhLlzxz7sVCMfAlNQhSYnizIJRSW31uQ0Uku8rjlUmX1tCAmhW6CacLumiz2tcy04Xn/QFWyw==", "dependencies": { "@babel/core": "7.22.5", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -617,7 +686,7 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/compiler": "16.2.3", + "@angular/compiler": "16.2.5", "typescript": ">=4.9.3 <5.2" } }, @@ -651,9 +720,9 @@ } }, "node_modules/@angular/core": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-16.2.3.tgz", - "integrity": "sha512-YCzm7Rd2l0Ti0dZ1Mw3OfoQqlLolDN6jBEPy9Ah1s/KB+jKwNK9An3g8A9H6/jQIFwHCtxRad3LYH5ftknNMBQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-16.2.5.tgz", + "integrity": "sha512-Po2LMUnPg23D2qI7EYaoA4x3lRswx9nxfpwROzfFPbMNJ3JVbTK0HkTD2dFPGxRua2UjfJTb1um23tEGO4OGMQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -666,9 +735,9 @@ } }, "node_modules/@angular/forms": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-16.2.3.tgz", - "integrity": "sha512-d2ELs3PU4o1Yb89w4X3trD3CFWrDUsuFKs1hyNSYPWqCmcQ+tAfr9mizYPTVPSvee/RPRBqDEa0YTzfVpOvX4Q==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-16.2.5.tgz", + "integrity": "sha512-iYJImRji1OiYIcC2mDBcXhtvPfAoEGT+HqZpivu+/ZPLuf+QegC9+ktJw90SQXR+xccmpkUb9MsJ52SN2MgkPA==", "dependencies": { "tslib": "^2.3.0" }, @@ -676,25 +745,25 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/common": "16.2.3", - "@angular/core": "16.2.3", - "@angular/platform-browser": "16.2.3", + "@angular/common": "16.2.5", + "@angular/core": "16.2.5", + "@angular/platform-browser": "16.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-16.2.3.tgz", - "integrity": "sha512-AFBq643tXGSoBUFNd0c1vJzReehtrqUZCzCRV3Gv5FNgb5xTfNdh3txIK4Cz/XhdSou+f2Yq6keFDlj2RYnlmA==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-16.2.5.tgz", + "integrity": "sha512-lYNRN4+iavDuAs86lRHuiTUxtVtsarCZPeoG6K1TEvrXrvmIbTtAbvONNMMnteO9ltCTduyREF9/sefE2Qw/Rg==", "dev": true, "engines": { "node": "^16.14.0 || >=18.10.0" } }, "node_modules/@angular/localize": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-16.2.3.tgz", - "integrity": "sha512-8dw4Vf3lqwQK4RBzBjYVNNoXlMJAyEF3pREqQo5aWcGTbBOAwM7SQuRQfxM8yjZZaXcKQEv/XJfEym5UVzGaUw==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-16.2.5.tgz", + "integrity": "sha512-vDtrBlbWOqtATqaBv3gmxBT0e8TfxwW+4J47S8u5Pbi1ZAnQfDkD9MNivC6/CAifFMcxN1pH8NALwLXOUga1PA==", "dependencies": { "@babel/core": "7.22.5", "fast-glob": "3.3.0", @@ -709,8 +778,8 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/compiler": "16.2.3", - "@angular/compiler-cli": "16.2.3" + "@angular/compiler": "16.2.5", + "@angular/compiler-cli": "16.2.5" } }, "node_modules/@angular/localize/node_modules/@babel/core": { @@ -758,9 +827,9 @@ } }, "node_modules/@angular/material": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-16.2.2.tgz", - "integrity": "sha512-0SaBPZsZ1jxq5yJeey+V2k7nq1Izw63fjxkyLx7rCcdowwwoBnG/dZsY97/5Qs2cZX0J+Z0iNpMYVJZd72GsvQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-16.2.4.tgz", + "integrity": "sha512-TIZ/0MKObn5YU9n/VReghJJKqgkqyzrWVNEJ8UgOP6MV5o+kAbqLSmlDJEyjLIwJF0vPnJ3UP6qbEOfEi1OLaA==", "dependencies": { "@material/animation": "15.0.0-canary.bc9ae6c9c.0", "@material/auto-init": "15.0.0-canary.bc9ae6c9c.0", @@ -813,7 +882,7 @@ }, "peerDependencies": { "@angular/animations": "^16.0.0 || ^17.0.0", - "@angular/cdk": "16.2.2", + "@angular/cdk": "16.2.4", "@angular/common": "^16.0.0 || ^17.0.0", "@angular/core": "^16.0.0 || ^17.0.0", "@angular/forms": "^16.0.0 || ^17.0.0", @@ -822,9 +891,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-16.2.3.tgz", - "integrity": "sha512-adWINGgjIMxwbWJhkMwpEfb4FRFMda5X6ahxWQX2E03Nl0kzePI6cvlJqAgp+iBwTkieWeU8BThJk2/rMkS3bw==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-16.2.5.tgz", + "integrity": "sha512-p+1GH/M4Vwoyp7brKkNBcMTxscoZxA1zehetFlNr8kArXWiISgPolyqOVzvT6cycYKu5uSRLnvHOTDss6xrAuA==", "dependencies": { "tslib": "^2.3.0" }, @@ -832,9 +901,9 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/animations": "16.2.3", - "@angular/common": "16.2.3", - "@angular/core": "16.2.3" + "@angular/animations": "16.2.5", + "@angular/common": "16.2.5", + "@angular/core": "16.2.5" }, "peerDependenciesMeta": { "@angular/animations": { @@ -843,9 +912,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-16.2.3.tgz", - "integrity": "sha512-Y3cYob1VGzT1xSMbuLGVxPlyuhv4zshYEo/yy2626YD63DigqYwGzj+gT0JoU1eNuXw2UWp3R67d9F8SC015Jw==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-16.2.5.tgz", + "integrity": "sha512-kzC4z/KmLss8Du9uM1Q16r+3EqDExKKHnrb3G3tuEQ1jTvYCysdWoooVSBmtIlQUw13znpBm1B7XLoyviFvnwA==", "dependencies": { "tslib": "^2.3.0" }, @@ -853,16 +922,16 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/common": "16.2.3", - "@angular/compiler": "16.2.3", - "@angular/core": "16.2.3", - "@angular/platform-browser": "16.2.3" + "@angular/common": "16.2.5", + "@angular/compiler": "16.2.5", + "@angular/core": "16.2.5", + "@angular/platform-browser": "16.2.5" } }, "node_modules/@angular/router": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-16.2.3.tgz", - "integrity": "sha512-xjF5v6BzXanPB0VoIxeKXg1DO95nKJ9UjTsmB5ZOufDcqQXE81NAnH7iEKOymvU7aacqrgD467vcDtGNWJdfQQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-16.2.5.tgz", + "integrity": "sha512-5IXhe6G7zYFUwHSfUgPw+I/q6M1AcfSyaOVcjMFQ94bVSWEMq5KrGCDc8HQtkdw7GqJ4txwbyQKSKp7khpqShQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -870,16 +939,16 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/common": "16.2.3", - "@angular/core": "16.2.3", - "@angular/platform-browser": "16.2.3", + "@angular/common": "16.2.5", + "@angular/core": "16.2.5", + "@angular/platform-browser": "16.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-16.2.3.tgz", - "integrity": "sha512-CKBtxA7oHbozP22hD15ZdGeh4Wl4BqNRG93O1RI74zEY2Nq0UgDc/rDkEtfe0bGWLBhU+q5q/6M+DPSy6erqzA==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-16.2.5.tgz", + "integrity": "sha512-rHSFkrzyOunWwAQNtTC01ry2inrutlCad8MChK+fHCAhD2maWbNHtIelXR5ylojx7EyTUY0TPL30D60z2mXbwA==", "dependencies": { "tslib": "^2.3.0" }, @@ -890,8 +959,8 @@ "node": "^16.14.0 || >=18.10.0" }, "peerDependencies": { - "@angular/common": "16.2.3", - "@angular/core": "16.2.3" + "@angular/common": "16.2.5", + "@angular/core": "16.2.5" } }, "node_modules/@assemblyscript/loader": { @@ -976,25 +1045,25 @@ } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.10.tgz", - "integrity": "sha512-Av0qubwDQxC56DoUReVDeLfMEjYYSN1nZrTUrWkXd7hpU73ymRANkbuDm3yni9npkn+RXy9nNbEJZEzXr7xrfQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", "dev": true, "peer": true, "dependencies": { - "@babel/types": "^7.22.10" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz", - "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", "dependencies": { "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.5", + "@babel/helper-validator-option": "^7.22.15", "browserslist": "^4.21.9", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1004,16 +1073,16 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.11.tgz", - "integrity": "sha512-y1grdYL4WzmUDBRGK0pDbIoFd7UZKoDurDzWEoNMYoj1EL+foGRQNyPWDcC+YyegN5y1DUsFFmzjGijB3nSVAQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz", + "integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==", "dev": true, "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.5", "@babel/helper-function-name": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", + "@babel/helper-member-expression-to-functions": "^7.22.15", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-replace-supers": "^7.22.9", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", @@ -1028,9 +1097,9 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.9.tgz", - "integrity": "sha512-+svjVa/tFwsNSG4NEy1h85+HQ5imbT92Q5/bgtS7P0GTQlP8WuFdqsiABmQouhiFGyV66oGxZFpeYHza1rNsKw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", "dev": true, "peer": true, "dependencies": { @@ -1094,39 +1163,39 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", - "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.15.tgz", + "integrity": "sha512-qLNsZbgrNh0fDQBCPocSL8guki1hcPvltGDv/NxvUoABwFq7GkKSu1nRXeJkVZc+wJvne2E0RKQz+2SQrz6eAA==", "dev": true, "peer": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", - "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", - "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "version": "7.22.17", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.17.tgz", + "integrity": "sha512-XouDDhQESrLHTpnBtCKExJdyY4gJCdrvH2Pyv8r8kovX2U8G0dRUOT45T9XlbLtuu9CLXP15eusnkprhoPV5iQ==", "dependencies": { "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", "@babel/helper-simple-access": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.5" + "@babel/helper-validator-identifier": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -1157,15 +1226,15 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.9.tgz", - "integrity": "sha512-8WWC4oR4Px+tr+Fp0X3RHDVfINGpF3ad1HIbrc8A77epiR6eMMc6jsgozkzT2uDiOOdoS9cLIQ+XD2XvI2WSmQ==", + "version": "7.22.17", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.17.tgz", + "integrity": "sha512-bxH77R5gjH3Nkde6/LuncQoLaP16THYPscurp1S8z7S9ZgezCyV3G8Hc+TZiCmY8pz4fp8CvKSgtJMW0FkLAxA==", "dev": true, "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-wrap-function": "^7.22.9" + "@babel/helper-wrap-function": "^7.22.17" }, "engines": { "node": ">=6.9.0" @@ -1236,44 +1305,72 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.15.tgz", + "integrity": "sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", - "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.10.tgz", - "integrity": "sha512-OnMhjWjuGYtdoO3FmsEFWvBStBAe2QOgwOLsLNDjN+aaiMD8InJk1/O3HSD8lkqTjCgg5YI34Tz15KNNA3p+nQ==", + "version": "7.22.17", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.17.tgz", + "integrity": "sha512-nAhoheCMlrqU41tAojw9GpVEKDlTS8r3lzFmF0lP52LwblCPbuFSO7nGIZoIcoU5NIm1ABrna0cJExE4Ay6l2Q==", "dev": true, "peer": true, "dependencies": { "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.10" + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.17" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function/node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.11.tgz", - "integrity": "sha512-vyOXC8PBWaGc5h7GMsNx68OH33cypkEDJCHvYVVgVbbxJDROYVtexSk0gK5iCF1xNjRIN2s8ai7hwkWDq5szWg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.15.tgz", + "integrity": "sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==", "dependencies": { - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.11", - "@babel/types": "^7.22.11" + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -1293,9 +1390,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.14", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.14.tgz", - "integrity": "sha512-1KucTHgOvaw/LzCVrEOAyXkr9rQlp0A1HiHRYnSUE9dmb8PvPW7o5sscg+5169r54n3vGlbx6GevTE/Iw/P3AQ==", + "version": "7.22.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz", + "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==", "bin": { "parser": "bin/babel-parser.js" }, @@ -1304,9 +1401,9 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz", - "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz", + "integrity": "sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==", "dev": true, "peer": true, "dependencies": { @@ -1320,15 +1417,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz", - "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz", + "integrity": "sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==", "dev": true, "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.5" + "@babel/plugin-transform-optional-chaining": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -1341,6 +1438,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead.", "dev": true, "peer": true, "dependencies": { @@ -1373,6 +1471,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-unicode-property-regex instead.", "dev": true, "peer": true, "dependencies": { @@ -1686,9 +1785,9 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.11.tgz", - "integrity": "sha512-0pAlmeRJn6wU84zzZsEOx1JV1Jf8fqO9ok7wofIJwUnplYo247dcd24P+cMJht7ts9xkzdtB0EPHmOb7F+KzXw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz", + "integrity": "sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w==", "dev": true, "peer": true, "dependencies": { @@ -1739,9 +1838,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz", - "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz", + "integrity": "sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw==", "dev": true, "peer": true, "dependencies": { @@ -1790,19 +1889,19 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz", - "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz", + "integrity": "sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==", "dev": true, "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-environment-visitor": "^7.22.5", "@babel/helper-function-name": "^7.22.5", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.9", "@babel/helper-split-export-declaration": "^7.22.6", "globals": "^11.1.0" }, @@ -1831,9 +1930,9 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz", - "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz", + "integrity": "sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ==", "dev": true, "peer": true, "dependencies": { @@ -1931,9 +2030,9 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz", - "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz", + "integrity": "sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==", "dev": true, "peer": true, "dependencies": { @@ -2048,13 +2147,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.11.tgz", - "integrity": "sha512-o2+bg7GDS60cJMgz9jWqRUsWkMzLCxp+jFDeDUT5sjRlAxcJWZ2ylNdI7QQ2+CH5hWu7OnN+Cv3htt7AkSf96g==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.15.tgz", + "integrity": "sha512-jWL4eh90w0HQOTKP2MoXXUpVxilxsB2Vl4ji69rSjS3EcZ/v4sBmn+A3NpepuJzBhOaEBbR7udonlHHn5DWidg==", "dev": true, "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.9", + "@babel/helper-module-transforms": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-simple-access": "^7.22.5" }, @@ -2169,17 +2268,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.11.tgz", - "integrity": "sha512-nX8cPFa6+UmbepISvlf5jhQyaC7ASs/7UxHmMkuJ/k5xSHvDPPaibMo+v3TXwU/Pjqhep/nFNpd3zn4YR59pnw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz", + "integrity": "sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==", "dev": true, "peer": true, "dependencies": { "@babel/compat-data": "^7.22.9", - "@babel/helper-compilation-targets": "^7.22.10", + "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.22.5" + "@babel/plugin-transform-parameters": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -2223,9 +2322,9 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.22.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.12.tgz", - "integrity": "sha512-7XXCVqZtyFWqjDsYDY4T45w4mlx1rf7aOgkc/Ww76xkgBiOlmjPkx36PBLHa1k1rwWvVgYMPsbuVnIamx2ZQJw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz", + "integrity": "sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A==", "dev": true, "peer": true, "dependencies": { @@ -2241,9 +2340,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz", - "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz", + "integrity": "sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==", "dev": true, "peer": true, "dependencies": { @@ -2654,18 +2753,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz", - "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==", + "version": "7.22.17", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.17.tgz", + "integrity": "sha512-xK4Uwm0JnAMvxYZxOVecss85WxTEIbTa7bnGyf/+EgCL5Zt3U7htUpEOWv9detPlamGKuRzCqw74xVglDWpPdg==", "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.22.15", "@babel/helper-environment-visitor": "^7.22.5", "@babel/helper-function-name": "^7.22.5", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.11", - "@babel/types": "^7.22.11", + "@babel/parser": "^7.22.16", + "@babel/types": "^7.22.17", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2674,11 +2773,11 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz", + "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==", "dependencies": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.22.15", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -2688,12 +2787,12 @@ } }, "node_modules/@babel/types": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz", - "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==", + "version": "7.22.17", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.17.tgz", + "integrity": "sha512-YSQPHLFtQNE5xN9tHuZnzu8vPr61wVTBZdfv1meex1NBosa4iT05k/Jw06ddJugi4bk7The/oSwQGFcksmEJQg==", "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.15", "to-fast-properties": "^2.0.0" }, "engines": { @@ -3229,9 +3328,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz", - "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", + "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4214,6 +4313,14 @@ "uuid": "9.0.0" } }, + "node_modules/@ls1intum/apollon/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@material/animation": { "version": "15.0.0-canary.bc9ae6c9c.0", "resolved": "https://registry.npmjs.org/@material/animation/-/animation-15.0.0-canary.bc9ae6c9c.0.tgz", @@ -5250,6 +5357,150 @@ "node": ">= 10" } }, + "node_modules/@nx/nx-darwin-x64": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-16.5.1.tgz", + "integrity": "sha512-j9HmL1l8k7EVJ3eOM5y8COF93gqrydpxCDoz23ZEtsY+JHY77VAiRQsmqBgEx9GGA2dXi9VEdS67B0+1vKariw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-freebsd-x64": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-16.5.1.tgz", + "integrity": "sha512-CXSPT01aVS869tvCCF2tZ7LnCa8l41wJ3mTVtWBkjmRde68E5Up093hklRMyXb3kfiDYlfIKWGwrV4r0eH6x1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm-gnueabihf": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-16.5.1.tgz", + "integrity": "sha512-BhrumqJSZCWFfLFUKl4CAUwR0Y0G2H5EfFVGKivVecEQbb+INAek1aa6c89evg2/OvetQYsJ+51QknskwqvLsA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-gnu": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-16.5.1.tgz", + "integrity": "sha512-x7MsSG0W+X43WVv7JhiSq2eKvH2suNKdlUHEG09Yt0vm3z0bhtym1UCMUg3IUAK7jy9hhLeDaFVFkC6zo+H/XQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-arm64-musl": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-16.5.1.tgz", + "integrity": "sha512-J+/v/mFjOm74I0PNtH5Ka+fDd+/dWbKhpcZ2R1/6b9agzZk+Ff/SrwJcSYFXXWKbPX+uQ4RcJoytT06Zs3s0ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-gnu": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-16.5.1.tgz", + "integrity": "sha512-igooWJ5YxQ94Zft7IqgL+Lw0qHaY15Btw4gfK756g/YTYLZEt4tTvR1y6RnK/wdpE3sa68bFTLVBNCGTyiTiDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-linux-x64-musl": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-16.5.1.tgz", + "integrity": "sha512-zF/exnPqFYbrLAduGhTmZ7zNEyADid2bzNQiIjJkh8Y6NpDwrQIwVIyvIxqynsjMrIs51kBH+8TUjKjj2Jgf5A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-arm64-msvc": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-16.5.1.tgz", + "integrity": "sha512-qtqiLS9Y9TYyAbbpq58kRoOroko4ZXg5oWVqIWFHoxc5bGPweQSJCROEqd1AOl2ZDC6BxfuVHfhDDop1kK05WA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nx/nx-win32-x64-msvc": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-16.5.1.tgz", + "integrity": "sha512-kUJBLakK7iyA9WfsGGQBVennA4jwf5XIgm0lu35oMOphtZIluvzItMt0EYBmylEROpmpEIhHq0P6J9FA+WH0Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@parcel/watcher": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.0.4.tgz", @@ -5329,9 +5580,9 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.21", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", - "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "version": "1.0.0-next.23", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", + "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", "dev": true }, "node_modules/@popperjs/core": { @@ -5396,13 +5647,13 @@ "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" }, "node_modules/@schematics/angular": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.2.1.tgz", - "integrity": "sha512-e3ckgvSv+OA+4xUBpOqVOvNM8FqY/yXaWqs/Ob0uQ/zPL1iVa/MCAoB25KqYQPnq21hEwE4zqIIQFKasKBIqMA==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-16.2.2.tgz", + "integrity": "sha512-OqPhpodkQx9pzSz7H2AGeEbf3ut6WOkJFP2YlX2JIGholfG/0FQMJmfTEyRoFXCBeVIDGt3sOmlfK7An0PS8uA==", "dev": true, "dependencies": { - "@angular-devkit/core": "16.2.1", - "@angular-devkit/schematics": "16.2.1", + "@angular-devkit/core": "16.2.2", + "@angular-devkit/schematics": "16.2.2", "jsonc-parser": "3.2.0" }, "engines": { @@ -5411,14 +5662,41 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.2.tgz", + "integrity": "sha512-6H4FsvP3rLJaGiWpIhCFPS7ZeNoM4sSrnFtRhhecu6s7MidzE4aqzuGdzJpzLammw1KL+DuTlN0gpLtM1Bvcwg==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "2.3.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^16.14.0 || >=18.10.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@sentry-internal/tracing": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.66.0.tgz", - "integrity": "sha512-3vCgC2hC3T45pn53yTDVcRpHoJTBxelDPPZVsipAbZnoOVPkj7n6dNfDhj3I3kwWCBPahPkXmE+R4xViR8VqJg==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.69.0.tgz", + "integrity": "sha512-4BgeWZUj9MO6IgfO93C9ocP3+AdngqujF/+zB2rFdUe+y9S6koDyUC7jr9Knds/0Ta72N/0D6PwhgSCpHK8s0Q==", "dependencies": { - "@sentry/core": "7.66.0", - "@sentry/types": "7.66.0", - "@sentry/utils": "7.66.0", + "@sentry/core": "7.69.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0", "tslib": "^2.4.1 || ^1.9.3" }, "engines": { @@ -5426,13 +5704,13 @@ } }, "node_modules/@sentry/angular-ivy": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/angular-ivy/-/angular-ivy-7.66.0.tgz", - "integrity": "sha512-+/Z+c38J2PdZ7Okg8dC+tdu0tTdSz4Pngne4PPHkOsq4VP2pz4JUyMGDBqd7LaMXtcVurMLjxaweINFDC5Cb0w==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/angular-ivy/-/angular-ivy-7.69.0.tgz", + "integrity": "sha512-tXyTebex0O8ZUqS9RyTMNsVPM8z1Hr63W36Utg7jNd9Fi6XItQFWvOt4ME8/I89DIuCMBzhxCpwgPQqJolrhOQ==", "dependencies": { - "@sentry/browser": "7.66.0", - "@sentry/types": "7.66.0", - "@sentry/utils": "7.66.0", + "@sentry/browser": "7.69.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0", "tslib": "^2.4.1" }, "engines": { @@ -5446,15 +5724,15 @@ } }, "node_modules/@sentry/browser": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.66.0.tgz", - "integrity": "sha512-rW037rf8jkhyykG38+HUdwkRCKHJEMM5NkCqPIO5zuuxfLKukKdI2rbvgJ93s3/9UfsTuDFcKFL1u43mCn6sDw==", - "dependencies": { - "@sentry-internal/tracing": "7.66.0", - "@sentry/core": "7.66.0", - "@sentry/replay": "7.66.0", - "@sentry/types": "7.66.0", - "@sentry/utils": "7.66.0", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.69.0.tgz", + "integrity": "sha512-5ls+zu2PrMhHCIIhclKQsWX5u6WH0Ez5/GgrCMZTtZ1d70ukGSRUvpZG9qGf5Cw1ezS1LY+1HCc3whf8x8lyPw==", + "dependencies": { + "@sentry-internal/tracing": "7.69.0", + "@sentry/core": "7.69.0", + "@sentry/replay": "7.69.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0", "tslib": "^2.4.1 || ^1.9.3" }, "engines": { @@ -5462,12 +5740,12 @@ } }, "node_modules/@sentry/core": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.66.0.tgz", - "integrity": "sha512-WMAEPN86NeCJ1IT48Lqiz4MS5gdDjBwP4M63XP4msZn9aujSf2Qb6My5uT87AJr9zBtgk8MyJsuHr35F0P3q1w==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.69.0.tgz", + "integrity": "sha512-V6jvK2lS8bhqZDMFUtvwe2XvNstFQf5A+2LMKCNBOV/NN6eSAAd6THwEpginabjet9dHsNRmMk7WNKvrUfQhZw==", "dependencies": { - "@sentry/types": "7.66.0", - "@sentry/utils": "7.66.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0", "tslib": "^2.4.1 || ^1.9.3" }, "engines": { @@ -5475,43 +5753,43 @@ } }, "node_modules/@sentry/replay": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.66.0.tgz", - "integrity": "sha512-5Y2SlVTOFTo3uIycv0mRneBakQtLgWkOnsJaC5LB0Ip0TqVKiMCbQ578vvXp+yvRj4LcS1gNd98xTTNojBoQNg==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.69.0.tgz", + "integrity": "sha512-oUqWyBPFUgShdVvgJtV65EQH9pVDmoYVQMOu59JI6FHVeL3ald7R5Mvz6GaNLXsirvvhp0yAkcAd2hc5Xi6hDw==", "dependencies": { - "@sentry/core": "7.66.0", - "@sentry/types": "7.66.0", - "@sentry/utils": "7.66.0" + "@sentry/core": "7.69.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry/tracing": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.66.0.tgz", - "integrity": "sha512-9bnz2EcOwjeMZAuYJnrwcRrImu9c10p7A0iDB8b2HLcp7gpuCkJbJyGoC1xeKD7reVD0BPq3VIbeHSwCcQufoQ==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.69.0.tgz", + "integrity": "sha512-nhwJXyLU2KT6ci3YRUCkpFQH7RL9lpEuVDHqaJ9xLql766FJ7A7jKtRGSaefgRzJvvdKHUVboIjZnSvqIu8gWw==", "dependencies": { - "@sentry-internal/tracing": "7.66.0" + "@sentry-internal/tracing": "7.69.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/types": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.66.0.tgz", - "integrity": "sha512-uUMSoSiar6JhuD8p7ON/Ddp4JYvrVd2RpwXJRPH1A4H4Bd4DVt1mKJy1OLG6HdeQv39XyhB1lPZckKJg4tATPw==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.69.0.tgz", + "integrity": "sha512-zPyCox0mzitzU6SIa1KIbNoJAInYDdUpdiA+PoUmMn2hFMH1llGU/cS7f4w/mAsssTlbtlBi72RMnWUCy578bw==", "engines": { "node": ">=8" } }, "node_modules/@sentry/utils": { - "version": "7.66.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.66.0.tgz", - "integrity": "sha512-9GYUVgXjK66uXXcLXVMXVzlptqMtq1eJENCuDeezQiEFrNA71KkLDg00wESp+LL+bl3wpVTBApArpbF6UEG5hQ==", + "version": "7.69.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.69.0.tgz", + "integrity": "sha512-4eBixe5Y+0EGVU95R4NxH3jkkjtkE4/CmSZD4In8SCkWGSauogePtq6hyiLsZuP1QHdpPb9Kt0+zYiBb2LouBA==", "dependencies": { - "@sentry/types": "7.66.0", + "@sentry/types": "7.69.0", "tslib": "^2.4.1 || ^1.9.3" }, "engines": { @@ -5827,9 +6105,9 @@ } }, "node_modules/@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "version": "3.4.36", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", + "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", "dev": true, "peer": true, "dependencies": { @@ -5837,9 +6115,9 @@ } }, "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", - "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.1.tgz", + "integrity": "sha512-iaQslNbARe8fctL5Lk+DsmgWOM83lM+7FzP0eQUJs1jd3kBE8NWqBTIT2S8SqQOJjxvt2eyIjpOuYeRXq2AdMw==", "dev": true, "peer": true, "dependencies": { @@ -5848,9 +6126,9 @@ } }, "node_modules/@types/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.2.tgz", + "integrity": "sha512-t33RNmTu5ufG/sorROIafiCVJMx3jz95bXUMoPAZcUD14fxMXnuTzqzXZoxpR0tNx2xpw11Dlmem9vGCsrSOfA==", "dev": true }, "node_modules/@types/d3-path": { @@ -6019,9 +6297,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.197", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", - "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", + "version": "4.14.198", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.198.tgz", + "integrity": "sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==", "dev": true }, "node_modules/@types/lodash-es": { @@ -6041,9 +6319,9 @@ "peer": true }, "node_modules/@types/node": { - "version": "20.5.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", - "integrity": "sha512-dP7f3LdZIysZnmvP3ANJYTSwg+wLLl8p7RqniVlV7j+oXSXAbt9h0WIBFmJy5inWZoX9wZN6eXx+YXd9Rh3RBA==", + "version": "20.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.0.tgz", + "integrity": "sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==", "dev": true }, "node_modules/@types/papaparse": { @@ -6218,16 +6496,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.5.0.tgz", - "integrity": "sha512-2pktILyjvMaScU6iK3925uvGU87E+N9rh372uGZgiMYwafaw9SXq86U04XPq3UH6tzRvNgBsub6x2DacHc33lw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.0.tgz", + "integrity": "sha512-gUqtknHm0TDs1LhY12K2NA3Rmlmp88jK9Tx8vGZMfHeNMLE3GH2e9TRub+y+SOjuYgtOmok+wt1AyDPZqxbNag==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.5.0", - "@typescript-eslint/type-utils": "6.5.0", - "@typescript-eslint/utils": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/type-utils": "6.7.0", + "@typescript-eslint/utils": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -6253,13 +6531,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.5.0.tgz", - "integrity": "sha512-f7OcZOkRivtujIBQ4yrJNIuwyCQO1OjocVqntl9dgSIZAdKqicj3xFDqDOzHDlGCZX990LqhLQXWRnQvsapq8A==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.7.0.tgz", + "integrity": "sha512-f/QabJgDAlpSz3qduCyQT0Fw7hHpmhOzY/Rv6zO3yO+HVIdPfIWhrQoAyG+uZVtWAIS85zAyzgAFfyEr+MgBpg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.5.0", - "@typescript-eslint/utils": "6.5.0", + "@typescript-eslint/typescript-estree": "6.7.0", + "@typescript-eslint/utils": "6.7.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -6280,17 +6558,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.5.0.tgz", - "integrity": "sha512-9nqtjkNykFzeVtt9Pj6lyR9WEdd8npPhhIPM992FWVkZuS6tmxHfGVnlUcjpUP2hv8r4w35nT33mlxd+Be1ACQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.7.0.tgz", + "integrity": "sha512-MfCq3cM0vh2slSikQYqK2Gq52gvOhe57vD2RM3V4gQRZYX4rDPnKLu5p6cm89+LJiGlwEXU8hkYxhqqEC/V3qA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.5.0", - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/typescript-estree": "6.5.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.0", "semver": "^7.5.4" }, "engines": { @@ -6305,15 +6583,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.5.0.tgz", - "integrity": "sha512-LMAVtR5GN8nY0G0BadkG0XIe4AcNMeyEy3DyhKGAh9k4pLSMBO7rF29JvDBpZGCmp5Pgz5RLHP6eCpSYZJQDuQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.7.0.tgz", + "integrity": "sha512-jZKYwqNpNm5kzPVP5z1JXAuxjtl2uG+5NpaMocFPTNC2EdYIgbXIPImObOkhbONxtFTTdoZstLZefbaK+wXZng==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.5.0", - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/typescript-estree": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4" }, "engines": { @@ -6333,13 +6611,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.5.0.tgz", - "integrity": "sha512-A8hZ7OlxURricpycp5kdPTH3XnjG85UpJS6Fn4VzeoH4T388gQJ/PGP4ole5NfKt4WDVhmLaQ/dBLNDC4Xl/Kw==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.7.0.tgz", + "integrity": "sha512-lAT1Uau20lQyjoLUQ5FUMSX/dS07qux9rYd5FGzKz/Kf8W8ccuvMyldb8hadHdK/qOI7aikvQWqulnEq2nCEYA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0" + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -6434,9 +6712,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.5.0.tgz", - "integrity": "sha512-eqLLOEF5/lU8jW3Bw+8auf4lZSbbljHR2saKnYqON12G/WsJrGeeDHWuQePoEf9ro22+JkbPfWQwKEC5WwLQ3w==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.7.0.tgz", + "integrity": "sha512-ihPfvOp7pOcN/ysoj0RpBPOx3HQTJTrIN8UZK+WFd3/iDeFHHqeyYxa4hQk4rMhsz9H9mXpR61IzwlBVGXtl9Q==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -6447,13 +6725,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.5.0.tgz", - "integrity": "sha512-q0rGwSe9e5Kk/XzliB9h2LBc9tmXX25G0833r7kffbl5437FPWb2tbpIV9wAATebC/018pGa9fwPDuvGN+LxWQ==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.0.tgz", + "integrity": "sha512-dPvkXj3n6e9yd/0LfojNU8VMUGHWiLuBZvbM6V6QYD+2qxqInE7J+J/ieY2iGwR9ivf/R/haWGkIj04WVUeiSQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.5.0", - "@typescript-eslint/visitor-keys": "6.5.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/visitor-keys": "6.7.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -6596,12 +6874,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.5.0.tgz", - "integrity": "sha512-yCB/2wkbv3hPsh02ZS8dFQnij9VVQXJMN/gbQsaaY+zxALkZnxa/wagvLEFsAWMPv7d7lxQmNsIzGU1w/T/WyA==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.0.tgz", + "integrity": "sha512-/C1RVgKFDmGMcVGeD8HjKv2bd72oI1KxQDeY8uc66gw9R0OK0eMq48cA+jv9/2Ag6cdrsUGySm1yzYmfz0hxwQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.5.0", + "@typescript-eslint/types": "6.7.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -6964,9 +7242,9 @@ } }, "node_modules/ace-builds": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.24.1.tgz", - "integrity": "sha512-TLcxMxiTRX5Eq9bBVSd/bTJlanCBULiv/IULLohJDDaCAfcpZKJBVSd4OWfN/j2c2jCLc+jhpNWGELiJZw3wPw==" + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.25.1.tgz", + "integrity": "sha512-pB4N8wvl+tUEwD12BovBUpd6B+IpASOShd8WlufwFnXCfBQk/4nwmpN5vZSsvd6v5G7YaP9/PPdQK4cq2ZRzng==" }, "node_modules/acorn": { "version": "8.10.0", @@ -8067,9 +8345,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001525", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001525.tgz", - "integrity": "sha512-/3z+wB4icFt3r0USMwxujAqRvaD/B7rvGTsKhbhSQErVrJvkZCLhgNLJxU8MevahQVH6hCU9FsHdNUFbiwmE7Q==", + "version": "1.0.30001529", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001529.tgz", + "integrity": "sha512-n2pUQYGAkrLG4QYj2desAh+NqsJpHbNmVZz87imptDdxLAtjxary7Df/psdfyDGmskJK/9Dt9cPnx5RZ3CU4Og==", "funding": [ { "type": "opencollective", @@ -8626,9 +8904,9 @@ } }, "node_modules/core-js": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.1.tgz", - "integrity": "sha512-lqufgNn9NLnESg5mQeYsxQP5w7wrViSj0jr/kv6ECQiByzQkrn1MKvV0L3acttpDqfQrHLwr2KCMgX5b8X+lyQ==", + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz", + "integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -8636,9 +8914,9 @@ } }, "node_modules/core-js-compat": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.1.tgz", - "integrity": "sha512-GSvKDv4wE0bPnQtjklV101juQ85g6H3rm5PDP20mqlS5j0kXF3pP97YvAu5hl+uFHqMictp3b2VxOHljWMAtuA==", + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.2.tgz", + "integrity": "sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==", "dev": true, "peer": true, "dependencies": { @@ -8655,15 +8933,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cosmiconfig": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz", - "integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.4.tgz", + "integrity": "sha512-SF+2P8+o/PTV05rgsAjDzL4OFdVXAulSfC/L19VaeVT7+tpOOSscCt2QLxDZ+CLxF2WOiq6y1K5asvs8qUJT/Q==", "dev": true, "peer": true, "dependencies": { - "import-fresh": "^3.2.1", + "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.0.0", + "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "engines": { @@ -8671,6 +8949,14 @@ }, "funding": { "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/cosmiconfig/node_modules/argparse": { @@ -9693,9 +9979,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.506", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.506.tgz", - "integrity": "sha512-xxGct4GPAKSRlrLBtJxJFYy74W11zX6PO9GyHgl/U+2s3Dp0ZEwAklDfNHXOWcvH7zWMpsmgbR0ggEuaYAVvHA==" + "version": "1.4.513", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.513.tgz", + "integrity": "sha512-cOB0xcInjm+E5qIssHeXJ29BaUyWpMyFKT5RB3bsLENDheCja0wMkHJyiPl0NBE/VzDI7JDuNEQWhe6RitEUcw==" }, "node_modules/emittery": { "version": "0.13.1", @@ -9973,16 +10259,16 @@ } }, "node_modules/eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz", - "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", + "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.48.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint/js": "8.49.0", + "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.12.4", @@ -10039,18 +10325,43 @@ } }, "node_modules/eslint-plugin-deprecation": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-deprecation/-/eslint-plugin-deprecation-1.5.0.tgz", - "integrity": "sha512-mRcssI/tLROueBQ6yf4LnnGTijbMsTCPIpbRbPj5R5wGYVCpk1zDmAS0SEkgcUDXOPc22qMNFR24Qw7vSPrlTA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-deprecation/-/eslint-plugin-deprecation-2.0.0.tgz", + "integrity": "sha512-OAm9Ohzbj11/ZFyICyR5N6LbOIvQMp7ZU2zI7Ej0jIc8kiGUERXPNMfw2QqqHD1ZHtjMub3yPZILovYEYucgoQ==", "dev": true, "dependencies": { - "@typescript-eslint/utils": "^5.57.0", + "@typescript-eslint/utils": "^6.0.0", "tslib": "^2.3.1", "tsutils": "^3.21.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0", - "typescript": "^3.7.5 || ^4.0.0 || ^5.0.0" + "eslint": "^7.0.0 || ^8.0.0", + "typescript": "^4.2.4 || ^5.0.0" + } + }, + "node_modules/eslint-plugin-deprecation/node_modules/@typescript-eslint/utils": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.7.0.tgz", + "integrity": "sha512-MfCq3cM0vh2slSikQYqK2Gq52gvOhe57vD2RM3V4gQRZYX4rDPnKLu5p6cm89+LJiGlwEXU8hkYxhqqEC/V3qA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.7.0", + "@typescript-eslint/types": "6.7.0", + "@typescript-eslint/typescript-estree": "6.7.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" } }, "node_modules/eslint-plugin-jest": { @@ -10960,9 +11271,9 @@ } }, "node_modules/fraction.js": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz", - "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.6.tgz", + "integrity": "sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==", "dev": true, "peer": true, "engines": { @@ -12595,9 +12906,9 @@ "integrity": "sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw==" }, "node_modules/jackspeak": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.1.tgz", - "integrity": "sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.3.tgz", + "integrity": "sha512-R2bUw+kVZFS/h1AZqBKrSgDmdmjApzgY0AlCPumopFiAlbUxE2gf+SCuBzQ0cP5hHmUmFYF5yw55T97Th5Kstg==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -13523,9 +13834,9 @@ } }, "node_modules/jest-environment-jsdom/node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.1.tgz", + "integrity": "sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==", "dev": true, "engines": { "node": ">=10.0.0" @@ -14726,9 +15037,9 @@ } }, "node_modules/jiti": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", - "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", + "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", "dev": true, "peer": true, "bin": { @@ -17923,9 +18234,9 @@ } }, "node_modules/pure-rand": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", - "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.3.tgz", + "integrity": "sha512-KddyFewCsO0j3+np81IQ+SweXLDnDQTs5s67BOnrYmYe/yNmUhttQyGsYzy8yUnoljGAQ9sl38YB4vH8ur7Y+w==", "dev": true, "funding": [ { @@ -18562,9 +18873,9 @@ } }, "node_modules/rollup": { - "version": "3.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", - "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.0.tgz", + "integrity": "sha512-nszM8DINnx1vSS+TpbWKMkxem0CDWk3cSit/WWCBVs9/JZ1I/XLwOsiUglYuYReaeWWSsW9kge5zE5NZtf/a4w==", "dev": true, "peer": true, "bin": { @@ -18649,9 +18960,9 @@ "integrity": "sha512-LRneZZRXNgjzwG4bDQdOTSbze3fHm1EAKN/8bePxnlEZiBmkYEDggaHbuvHI9/hoqHbGfsEA7tWS9GhYHZBBsw==" }, "node_modules/sass": { - "version": "1.66.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz", - "integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==", + "version": "1.67.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.67.0.tgz", + "integrity": "sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -19708,9 +20019,9 @@ } }, "node_modules/tar": { - "version": "6.1.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", - "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", + "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", "dev": true, "dependencies": { "chownr": "^2.0.0", @@ -20111,9 +20422,9 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.2.tgz", - "integrity": "sha512-Cbu4nIqnEdd+THNEsBdkolnOXhg0I8XteoHaEKgvsxpsbWda4IsUut2c187HxywQCvveojow0Dgw/amxtSKVkQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", "dev": true, "engines": { "node": ">=16.13.0" @@ -20490,9 +20801,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -20954,9 +21269,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.1.tgz", + "integrity": "sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==", "dev": true, "peer": true, "engines": { diff --git a/package.json b/package.json index 33c99708233e..a21c26412c31 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "16.2.3", - "@angular/cdk": "16.2.2", - "@angular/common": "16.2.3", - "@angular/compiler": "16.2.3", - "@angular/core": "16.2.3", - "@angular/forms": "16.2.3", - "@angular/localize": "16.2.3", - "@angular/material": "16.2.2", - "@angular/platform-browser": "16.2.3", - "@angular/platform-browser-dynamic": "16.2.3", - "@angular/router": "16.2.3", - "@angular/service-worker": "16.2.3", + "@angular/animations": "16.2.5", + "@angular/cdk": "16.2.4", + "@angular/common": "16.2.5", + "@angular/compiler": "16.2.5", + "@angular/core": "16.2.5", + "@angular/forms": "16.2.5", + "@angular/localize": "16.2.5", + "@angular/material": "16.2.4", + "@angular/platform-browser": "16.2.5", + "@angular/platform-browser-dynamic": "16.2.5", + "@angular/router": "16.2.5", + "@angular/service-worker": "16.2.5", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "16.0.1", "@fingerprintjs/fingerprintjs": "4.0.1", @@ -37,16 +37,16 @@ "@ng-bootstrap/ng-bootstrap": "15.1.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular-ivy": "7.66.0", - "@sentry/tracing": "7.66.0", - "@sentry/types": "7.66.0", + "@sentry/angular-ivy": "7.69.0", + "@sentry/tracing": "7.69.0", + "@sentry/types": "7.69.0", "@swimlane/ngx-charts": "20.4.1", "@swimlane/ngx-graph": "8.2.2", - "ace-builds": "1.24.1", + "ace-builds": "1.25.1", "bootstrap": "5.3.1", "brace": "0.11.1", "compare-versions": "6.1.0", - "core-js": "3.32.1", + "core-js": "3.32.2", "crypto-js": "4.1.1", "dayjs": "1.11.9", "diff-match-patch-typescript": "1.0.8", @@ -73,7 +73,7 @@ "split.js": "1.6.5", "ts-cacheable": "1.0.9", "tslib": "2.6.2", - "uuid": "9.0.0", + "uuid": "9.0.1", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", "zone.js": "0.13.0" @@ -95,30 +95,30 @@ }, "devDependencies": { "@angular-builders/jest": "16.0.1", - "@angular-eslint/builder": "16.1.1", - "@angular-eslint/eslint-plugin": "16.1.1", - "@angular-eslint/eslint-plugin-template": "16.1.1", - "@angular-eslint/schematics": "16.1.1", - "@angular-eslint/template-parser": "16.1.1", - "@angular/cli": "16.2.1", - "@angular/compiler-cli": "16.2.3", - "@angular/language-service": "16.2.3", - "@types/crypto-js": "4.1.1", + "@angular-eslint/builder": "16.1.2", + "@angular-eslint/eslint-plugin": "16.1.2", + "@angular-eslint/eslint-plugin-template": "16.1.2", + "@angular-eslint/schematics": "16.1.2", + "@angular-eslint/template-parser": "16.1.2", + "@angular/cli": "16.2.2", + "@angular/compiler-cli": "16.2.5", + "@angular/language-service": "16.2.5", + "@types/crypto-js": "4.1.2", "@types/d3-shape": "3.1.2", "@types/dompurify": "3.0.2", "@types/jest": "29.5.4", "@types/lodash-es": "4.17.9", - "@types/node": "20.5.7", + "@types/node": "20.6.0", "@types/papaparse": "5.3.8", "@types/showdown": "2.0.1", "@types/smoothscroll-polyfill": "0.3.1", "@types/sockjs-client": "1.5.1", "@types/uuid": "9.0.3", - "@typescript-eslint/eslint-plugin": "6.5.0", - "@typescript-eslint/parser": "6.5.0", - "eslint": "8.48.0", + "@typescript-eslint/eslint-plugin": "6.7.0", + "@typescript-eslint/parser": "6.7.0", + "eslint": "8.49.0", "eslint-config-prettier": "9.0.0", - "eslint-plugin-deprecation": "1.5.0", + "eslint-plugin-deprecation": "2.0.0", "eslint-plugin-jest": "27.2.3", "eslint-plugin-jest-extended": "2.0.0", "eslint-plugin-prettier": "5.0.0", @@ -134,7 +134,7 @@ "lint-staged": "14.0.1", "ng-mocks": "14.11.0", "prettier": "3.0.3", - "sass": "1.66.1", + "sass": "1.67.0", "ts-jest": "29.1.1", "typescript": "5.1.6", "weak-napi": "2.0.2", diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java index f4bd0ce6fe18..60441190ccb5 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java @@ -499,7 +499,7 @@ public Submission findLatestSubmissionWithRatedResultWithCompletionDate(Particip boolean isAssessmentOver = ignoreAssessmentDueDate || ExerciseDateService.isAfterAssessmentDueDate(this); boolean isProgrammingExercise = participation.getExercise() instanceof ProgrammingExercise; // Check that submission was submitted in time (rated). For non programming exercises we check if the assessment due date has passed (if set) - boolean ratedOrPractice = Boolean.TRUE.equals(result.isRated()) || participation.isTestRun(); + boolean ratedOrPractice = Boolean.TRUE.equals(result.isRated()) || participation.isPracticeMode(); boolean noProgrammingAndAssessmentOver = !isProgrammingExercise && isAssessmentOver; // For programming exercises we check that the assessment due date has passed (if set) for manual results otherwise we always show the automatic result boolean programmingAfterAssessmentOrAutomatic = isProgrammingExercise && ((result.isManual() && isAssessmentOver) || result.isAutomatic()); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/ProgrammingExercise.java b/src/main/java/de/tum/in/www1/artemis/domain/ProgrammingExercise.java index 5f79a608e1e7..74c5da833ffe 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/ProgrammingExercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/ProgrammingExercise.java @@ -673,8 +673,8 @@ public Set findResultsFilteredForStudents(Participation participation) { public List findRelevantParticipation(List participations) { List participationOfExercise = participations.stream() .filter(participation -> participation.getExercise() != null && participation.getExercise().equals(this)).toList(); - List gradedParticipations = participationOfExercise.stream().filter(participation -> !participation.isTestRun()).toList(); - List practiceParticipations = participationOfExercise.stream().filter(Participation::isTestRun).toList(); + List gradedParticipations = participationOfExercise.stream().filter(participation -> !participation.isPracticeMode()).toList(); + List practiceParticipations = participationOfExercise.stream().filter(Participation::isPracticeMode).toList(); if (gradedParticipations.size() > 1) { gradedParticipations = super.findRelevantParticipation(gradedParticipations); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Result.java b/src/main/java/de/tum/in/www1/artemis/domain/Result.java index aeed3aa7bebc..a19e5f2f84b2 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Result.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Result.java @@ -236,7 +236,7 @@ public void setRatedIfNotAfterDueDate() { if (submission.getType() == SubmissionType.INSTRUCTOR || submission.getType() == SubmissionType.TEST) { this.rated = true; } - else if (submission.getType() == SubmissionType.ILLEGAL || participation.isTestRun()) { + else if (submission.getType() == SubmissionType.ILLEGAL || participation.isPracticeMode()) { this.rated = false; } else { diff --git a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/DataExportState.java b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/DataExportState.java index 5f4331897e67..1789c1d2ce5d 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/DataExportState.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/DataExportState.java @@ -8,10 +8,28 @@ public enum DataExportState { REQUESTED, IN_CREATION, EMAIL_SENT, DOWNLOADED, DOWNLOADED_DELETED, DELETED, FAILED; + /** + * Checks if the data export can be downloaded. + *

+ * The data export can be downloaded if its state is either EMAIL_SENT or DOWNLOADED. + * The state is EMAIL_SENT if the data export has been created and the user has been notified via email. + * The state is DOWNLOADED if the user has downloaded the data export at least once. + * + * @return true if the data export can be downloaded, false otherwise + */ public boolean isDownloadable() { return this == DOWNLOADED || this == EMAIL_SENT; } + /** + * Checks if the data export has been downloaded. + *

+ * The data export has been downloaded if its state is either DOWNLOADED or DOWNLOADED_DELETED. + * The state is DOWNLOADED if the user has downloaded the data export at least once, but it has not been deleted yet. + * The state is DOWNLOADED_DELETED if the user has downloaded the data export at least once, and it has been deleted. + * + * @return true if the data export has been downloaded, false otherwise + */ public boolean hasBeenDownloaded() { return this == DOWNLOADED || this == DOWNLOADED_DELETED; } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/participation/Participation.java b/src/main/java/de/tum/in/www1/artemis/domain/participation/Participation.java index 21290f5d510a..7a6e8bfda395 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/participation/Participation.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/participation/Participation.java @@ -144,6 +144,25 @@ public void setTestRun(boolean testRun) { this.testRun = testRun; } + /** + * Same as {@link #isTestRun} since {@link Participation#testRun} is used to determine if a participation in a course exercise is used for practice purposes + * + * @return true if the participation is only used for practicing after the due date + */ + @JsonIgnore + public boolean isPracticeMode() { + return Boolean.TRUE.equals(testRun); + } + + /** + * Same as {@link #setTestRun} since {@link Participation#testRun} is used to determine if a participation in a course exercise is used for practice purposes + * + * @param practiceMode sets the testRun flag to this value + */ + public void setPracticeMode(boolean practiceMode) { + this.testRun = practiceMode; + } + public Set getResults() { return results; } @@ -282,7 +301,7 @@ private Optional findLatestSubmission(boolean includeI * @return the same string with "practice-" added to the front if this is a test run participation */ public String addPracticePrefixIfTestRun(String string) { - return (isTestRun() ? "practice-" : "") + string; + return (isPracticeMode() ? "practice-" : "") + string; } @Override diff --git a/src/main/java/de/tum/in/www1/artemis/repository/DataExportRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/DataExportRepository.java index 9315ccd9d78a..4344193419f0 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/DataExportRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/DataExportRepository.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.repository; +import java.util.List; import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; @@ -55,12 +56,23 @@ default DataExport findByIdElseThrow(long dataExportId) { """) Set findAllToBeDeleted(); + /** + * Find all data exports for the given user ordered by their request date descending. + * We use this sorting because this allows us to always get the latest data export without a doing any other calculations. + *

+ * This is relevant if more than one data export exists that can be downloaded. + * This can happen if the user had requested a data export that was created and the admin requested another data export for the same user that has been created. + * + * @param userId the id of the user to find the data exports for + * @return a list of data exports for the given user ordered by their request date descending + */ @Query(""" SELECT dataExport FROM DataExport dataExport WHERE dataExport.user.id = :userId + ORDER BY dataExport.createdDate DESC """) - Set findAllDataExportsByUserId(long userId); + List findAllDataExportsByUserIdOrderByRequestDateDesc(long userId); @Query(""" SELECT dataExport diff --git a/src/main/java/de/tum/in/www1/artemis/service/CourseScoreCalculationService.java b/src/main/java/de/tum/in/www1/artemis/service/CourseScoreCalculationService.java index c9544259c25f..a7e8c64e6c3b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/CourseScoreCalculationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/CourseScoreCalculationService.java @@ -201,7 +201,7 @@ public CourseForDashboardDTO getScoresAndParticipationResults(Course course, Gra // TODO: Look into refactoring the fetchParticipationsWithSubmissionsAndResultsForCourses method in the CourseService to always initialize the participations (to an // empty list if there aren't any). This way you don't need this very unintuitive check for the initialization state. if (Hibernate.isInitialized(exercise.getStudentParticipations())) { - exercise.getStudentParticipations().stream().filter(participation -> !participation.isTestRun()).forEach(participation -> { + exercise.getStudentParticipations().stream().filter(participation -> !participation.isPracticeMode()).forEach(participation -> { participation.setExercise(exercise); gradedStudentParticipations.add(participation); }); diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileService.java b/src/main/java/de/tum/in/www1/artemis/service/FileService.java index dcfbab75454f..72f84a879408 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/FileService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/FileService.java @@ -26,7 +26,6 @@ import org.apache.commons.io.filefilter.FileFilterUtils; import org.apache.commons.io.filefilter.IOFileFilter; import org.apache.commons.lang3.math.NumberUtils; -import org.apache.pdfbox.io.MemoryUsageSetting; import org.apache.pdfbox.multipdf.PDFMergerUtility; import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.apache.tomcat.util.http.fileupload.IOUtils; @@ -1203,7 +1202,7 @@ public Optional mergePdfFiles(List paths, String mergedPdfFileNa for (String path : paths) { File file = new File(path); if (file.exists()) { - pdfMerger.addSource(new File(path)); + pdfMerger.addSource(file); } } @@ -1212,7 +1211,7 @@ public Optional mergePdfFiles(List paths, String mergedPdfFileNa pdfMerger.setDestinationDocumentInformation(pdDocumentInformation); pdfMerger.setDestinationStream(outputStream); - pdfMerger.mergeDocuments(MemoryUsageSetting.setupTempFileOnly()); + pdfMerger.mergeDocuments(null); } catch (IOException e) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java index e73e33e39546..fc04fa5bf323 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java @@ -6,6 +6,7 @@ import javax.validation.constraints.NotNull; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.multipdf.Splitter; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentInformation; @@ -55,18 +56,22 @@ public LectureUnitProcessingService(SlideSplitterService slideSplitterService, F */ public List splitAndSaveUnits(LectureUnitInformationDTO lectureUnitInformationDTO, MultipartFile file, Lecture lecture) throws IOException { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = PDDocument.load(file.getBytes())) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = Loader.loadPDF(file.getBytes())) { List units = new ArrayList<>(); Splitter pdfSplitter = new Splitter(); for (LectureUnitSplitDTO lectureUnit : lectureUnitInformationDTO.units()) { + // make sure output stream doesn't contain old data + outputStream.reset(); + AttachmentUnit attachmentUnit = new AttachmentUnit(); Attachment attachment = new Attachment(); PDDocumentInformation pdDocumentInformation = new PDDocumentInformation(); pdfSplitter.setStartPage(lectureUnit.startPage()); pdfSplitter.setEndPage(lectureUnit.endPage()); - pdfSplitter.setSplitAtPage(lectureUnit.endPage()); + // split only based on start and end page + pdfSplitter.setSplitAtPage(document.getNumberOfPages()); List documentUnits = pdfSplitter.split(document); pdDocumentInformation.setTitle(lectureUnit.unitName()); @@ -165,7 +170,7 @@ public LectureUnitInformationDTO getSplitUnitData(MultipartFile file) { * @return The prepared map */ private Outline separateIntoUnits(MultipartFile file) throws IOException { - try (PDDocument document = PDDocument.load(file.getBytes())) { + try (PDDocument document = Loader.loadPDF(file.getBytes())) { Map outlineMap = new HashMap<>(); Splitter pdfSplitter = new Splitter(); PDFTextStripper pdfStripper = new PDFTextStripper(); diff --git a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java index da50d02f8fa6..951d97edf8b1 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java @@ -118,7 +118,7 @@ public StudentParticipation startExercise(Exercise exercise, Participant partici public StudentParticipation startExerciseWithInitializationDate(Exercise exercise, Participant participant, boolean createInitialSubmission, ZonedDateTime initializationDate) { // common for all exercises Optional optionalStudentParticipation = findOneByExerciseAndParticipantAnyState(exercise, participant); - if (optionalStudentParticipation.isPresent() && optionalStudentParticipation.get().isTestRun() && exercise.isCourseExercise()) { + if (optionalStudentParticipation.isPresent() && optionalStudentParticipation.get().isPracticeMode() && exercise.isCourseExercise()) { // In case there is already a practice participation, set it to inactive optionalStudentParticipation.get().setInitializationState(InitializationState.INACTIVE); studentParticipationRepository.saveAndFlush(optionalStudentParticipation.get()); @@ -285,7 +285,7 @@ public StudentParticipation startPracticeMode(Exercise exercise, Participant par participation.setInitializationState(InitializationState.UNINITIALIZED); participation.setExercise(exercise); participation.setParticipant(participant); - participation.setTestRun(true); + participation.setPracticeMode(true); participation = studentParticipationRepository.saveAndFlush(participation); } else { @@ -394,7 +394,7 @@ public ProgrammingExerciseStudentParticipation resumeProgrammingExercise(Program // If a graded participation gets reset after the due date set the state back to finished. Otherwise, the participation is initialized var dueDate = ExerciseDateService.getDueDate(participation); - if (!participation.isTestRun() && dueDate.isPresent() && ZonedDateTime.now().isAfter(dueDate.get())) { + if (!participation.isPracticeMode() && dueDate.isPresent() && ZonedDateTime.now().isAfter(dueDate.get())) { participation.setInitializationState(InitializationState.FINISHED); } else { @@ -646,7 +646,7 @@ public void cleanupBuildPlan(ProgrammingExerciseStudentParticipation participati // If a graded participation gets cleaned up after the due date set the state back to finished. Otherwise, the participation is initialized var dueDate = ExerciseDateService.getDueDate(participation); - if (!participation.isTestRun() && dueDate.isPresent() && ZonedDateTime.now().isAfter(dueDate.get())) { + if (!participation.isPracticeMode() && dueDate.isPresent() && ZonedDateTime.now().isAfter(dueDate.get())) { participation.setInitializationState(InitializationState.FINISHED); } else { diff --git a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java index 46aeaa058d6f..cd624ad5be21 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java @@ -217,7 +217,7 @@ public List filterFeedbackForClient(Result result) { * @param result a result of this participation */ public void filterSensitiveInformationIfNecessary(final Participation participation, final Result result) { - this.filterSensitiveInformationIfNecessary(participation, List.of(result)); + this.filterSensitiveInformationIfNecessary(participation, List.of(result), Optional.empty()); } /** @@ -225,29 +225,41 @@ public void filterSensitiveInformationIfNecessary(final Participation participat * * @param participation the results belong to. * @param results collection of results of this participation + * @param user the user for which the information should be filtered if it is an empty optional, the currently logged-in user is used */ - public void filterSensitiveInformationIfNecessary(final Participation participation, final Collection results) { + public void filterSensitiveInformationIfNecessary(final Participation participation, final Collection results, Optional user) { results.forEach(Result::filterSensitiveInformation); - if (!authCheckService.isAtLeastTeachingAssistantForExercise(participation.getExercise())) { - // The test cases marked as after_due_date should only be shown after all - // students can no longer submit so that no unfair advantage is possible. - // - // For course exercises, this applies only to automatic results. For manual ones the instructors - // are responsible to set an appropriate assessment due date. - // - // For exams, we filter sensitive results until the results are published. - // For test exam exercises, this is the case when the student submitted the test exam. - - Exercise exercise = participation.getExercise(); - if (exercise.isExamExercise()) { - filterSensitiveFeedbacksInExamExercise(participation, results, exercise); + if (user.isPresent()) { + if (!authCheckService.isAtLeastTeachingAssistantForExercise(participation.getExercise(), user.get())) { + filterInformation(participation, results); } - else { - filterSensitiveFeedbackInCourseExercise(participation, results, exercise); + } + else { + if (!authCheckService.isAtLeastTeachingAssistantForExercise(participation.getExercise())) { + filterInformation(participation, results); } } } + private void filterInformation(Participation participation, Collection results) { + // The test cases marked as after_due_date should only be shown after all + // students can no longer submit so that no unfair advantage is possible. + // + // For course exercises, this applies only to automatic results. For manual ones the instructors + // are responsible to set an appropriate assessment due date. + // + // For exams, we filter sensitive results until the results are published. + // For test exam exercises, this is the case when the student submitted the test exam. + + Exercise exercise = participation.getExercise(); + if (exercise.isExamExercise()) { + filterSensitiveFeedbacksInExamExercise(participation, results, exercise); + } + else { + filterSensitiveFeedbackInCourseExercise(participation, results, exercise); + } + } + private void filterSensitiveFeedbackInCourseExercise(Participation participation, Collection results, Exercise exercise) { boolean beforeLatestDueDate = exerciseDateService.isBeforeLatestDueDate(exercise); boolean participationBeforeDueDate = exerciseDateService.isBeforeDueDate(participation); diff --git a/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java b/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java index 87558232de2f..623f49fe8933 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/SlideSplitterService.java @@ -8,6 +8,7 @@ import javax.imageio.ImageIO; import org.apache.commons.io.FilenameUtils; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; @@ -48,7 +49,7 @@ public SlideSplitterService(FileService fileService, SlideRepository slideReposi public void splitAttachmentUnitIntoSingleSlides(AttachmentUnit attachmentUnit) { String attachmentPath = fileService.actualPathForPublicPath(attachmentUnit.getAttachment().getLink()); File file = new File(attachmentPath); - try (PDDocument document = PDDocument.load(file)) { + try (PDDocument document = Loader.loadPDF(file)) { String pdfFilename = file.getName(); splitAttachmentUnitIntoSingleSlides(document, attachmentUnit, pdfFilename); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCommunicationDataService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCommunicationDataService.java index 3b05483bd5e2..639cd17f67ac 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCommunicationDataService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCommunicationDataService.java @@ -25,6 +25,8 @@ /** * A service to create the communication data export for users + * This includes messages (posts), thread replies (answer posts) and reactions to posts and answer posts + * All communication data is exported per course and stored in a CSV file. */ @Service public class DataExportCommunicationDataService { @@ -67,6 +69,12 @@ public void createCommunicationDataExport(long userId, Path workingDirectory) th createCommunicationDataExportIfReactionsToAnswerPostsExist(workingDirectory, reactionsToAnswerPostsPerCourse); } + /** + * Creates the communication data export for a course if only reactions to answer posts exist + * + * @param workingDirectory the directory where the export is stored + * @param reactionsToAnswerPostsPerCourse the reactions to answer posts grouped by course + */ private void createCommunicationDataExportIfReactionsToAnswerPostsExist(Path workingDirectory, Map> reactionsToAnswerPostsPerCourse) throws IOException { // it can happen that only answer post reactions exist in a course but neither posts, nor answer posts nor reactions to posts for (var entry : reactionsToAnswerPostsPerCourse.entrySet()) { @@ -78,6 +86,13 @@ private void createCommunicationDataExportIfReactionsToAnswerPostsExist(Path wor } } + /** + * Creates the communication data export for a course if only reactions to posts (and potentially to answer posts) exist + * + * @param workingDirectory the directory where the export is stored + * @param reactionsToPostsPerCourse the reactions to posts grouped by course + * @param reactionsToAnswerPostsPerCourse the reactions to answer posts grouped by course + */ private void createCommunicationDataExportIfReactionsToPostsExist(Path workingDirectory, Map> reactionsToPostsPerCourse, Map> reactionsToAnswerPostsPerCourse) throws IOException { // it can happen that only reactions exist in a course but no post or answer post @@ -91,6 +106,14 @@ private void createCommunicationDataExportIfReactionsToPostsExist(Path workingDi } } + /** + * Creates the communication data export for a course if only answer posts (and potentially reactions to post and answer posts) exist + * + * @param workingDirectory the directory where the export is stored + * @param answerPostsPerCourse the answer posts grouped by course + * @param reactionsToPostsPerCourse the reactions to posts grouped by course + * @param reactionsToAnswerPostsPerCourse the reactions to answer posts grouped by course + */ private void createCommunicationDataExportIfAnswerPostsExist(Path workingDirectory, Map> answerPostsPerCourse, Map> reactionsToPostsPerCourse, Map> reactionsToAnswerPostsPerCourse) throws IOException { // it can happen that an answer post and reactions exist in a course but no post @@ -105,6 +128,15 @@ private void createCommunicationDataExportIfAnswerPostsExist(Path workingDirecto } } + /** + * Creates the communication data export for a course if posts exist + * + * @param workingDirectory the directory where the export is stored + * @param postsPerCourse the posts grouped by course + * @param answerPostsPerCourse the answer posts grouped by course + * @param reactionsToPostsPerCourse the reactions to posts grouped by course + * @param reactionsToAnswerPostsPerCourse the reactions to answer posts grouped by course + */ private void createCommunicationDataExportIfPostsExist(Path workingDirectory, Map> postsPerCourse, Map> answerPostsPerCourse, Map> reactionsToPostsPerCourse, Map> reactionsToAnswerPostsPerCourse) throws IOException { // this covers all cases where at least one post in a course exists @@ -121,6 +153,15 @@ private void createCommunicationDataExportIfPostsExist(Path workingDirectory, Ma } } + /** + * Creates the actual CSV file containing the communication data for a course + * + * @param courseDir the directory where the CSV file is stored + * @param postsInCourse the posts in the course + * @param answerPostsInCourse the answer posts in the course + * @param postReactionsInCourse the reactions to posts in the course + * @param answerPostReactionsInCourse the reactions to answer posts in the course + */ private void createCommunicationDataCsvFile(Path courseDir, List postsInCourse, List answerPostsInCourse, List postReactionsInCourse, List answerPostReactionsInCourse) throws IOException { diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCreationService.java index 6385ad16e8c5..bbc02bf87f73 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportCreationService.java @@ -30,6 +30,8 @@ /** * A service to create data exports for users + * This service is responsible for creating the data export, delegating most tasks to the {@link DataExportExerciseCreationService} and {@link DataExportExamCreationService} + * and notifying the user about the creation. */ @Service public class DataExportCreationService { @@ -88,7 +90,7 @@ private DataExport createDataExportWithContent(DataExport dataExport) throws IOE var userId = dataExport.getUser().getId(); var user = dataExport.getUser(); var workingDirectory = prepareDataExport(dataExport); - dataExportExerciseCreationService.createExercisesExport(workingDirectory, userId); + dataExportExerciseCreationService.createExercisesExport(workingDirectory, user); dataExportExamCreationService.createExportForExams(userId, workingDirectory); dataExportCommunicationDataService.createCommunicationDataExport(userId, workingDirectory); addGeneralUserInformation(user, workingDirectory); @@ -97,6 +99,17 @@ private DataExport createDataExportWithContent(DataExport dataExport) throws IOE return finishDataExportCreation(dataExport, dataExportPath); } + /** + * Adds a markdown file with the title README.md to the data export. + *

+ * This file contains information Art. 15 GDPR requires us to provide to the user. + * The file is retrieved from the resources folder. + * The file is added to the root of the data export. + * + * @param workingDirectory the directory in which the data export is created + * @throws IOException if the file could not be copied + * @throws URISyntaxException if the resource file path is invalid + */ private void addReadmeFile(Path workingDirectory) throws IOException, URISyntaxException { var readmeInDataExportPath = workingDirectory.resolve("README.md"); var readmeTemplatePath = Path.of("templates", "dataexport", "README.md"); @@ -105,6 +118,8 @@ private void addReadmeFile(Path workingDirectory) throws IOException, URISyntaxE /** * Creates the data export for the given user. + *

+ * This includes creation of the export and notifying the user about the creation. * * @param dataExport the data export to be created * @return true if the export was successful, false otherwise @@ -129,6 +144,15 @@ public boolean createDataExport(DataExport dataExport) { return true; } + /** + * Handles the case of a failed data export creation. + *

+ * This includes setting the state of the data export to failed, notifying the user about the failure and sending an email to the admin with the exception why the export + * failed. + * + * @param dataExport the data export that failed to be created + * @param exception the exception that occurred during the creation + */ private void handleCreationFailure(DataExport dataExport, Exception exception) { dataExport.setDataExportState(DataExportState.FAILED); dataExport = dataExportRepository.save(dataExport); @@ -141,6 +165,13 @@ private void handleCreationFailure(DataExport dataExport, Exception exception) { mailService.sendDataExportFailedEmailToAdmin(admin.get(), dataExport, exception); } + /** + * Finishes the creation of the data export by setting the file path to the zip file, the state to EMAIL_SENT and the creation finished date. + * + * @param dataExport the data export whose creation is finished + * @param dataExportPath the path to the zip file containing the data export + * @return the updated data export from the database + */ private DataExport finishDataExportCreation(DataExport dataExport, Path dataExportPath) { dataExport.setFilePath(dataExportPath.toString()); dataExport.setCreationFinishedDate(ZonedDateTime.now()); @@ -149,6 +180,15 @@ private DataExport finishDataExportCreation(DataExport dataExport, Path dataExpo return dataExportRepository.save(dataExport); } + /** + * Prepares the data export by creating the working directory, scheduling it for deletion and setting the state to IN_CREATION. + *

+ * If the path where the data exports are stored does not exist yet, it will be created. + * + * @param dataExport the data export to be prepared + * @return the path to the working directory + * @throws IOException if the working directory could not be created + */ private Path prepareDataExport(DataExport dataExport) throws IOException { if (!Files.exists(dataExportsPath)) { Files.createDirectories(dataExportsPath); @@ -161,6 +201,14 @@ private Path prepareDataExport(DataExport dataExport) throws IOException { return workingDirectory; } + /** + * Adds the general user information to the data export. + *

+ * This includes the login, name, email, and registration number (matriculation number). + * + * @param user the user for which the information should be added + * @param workingDirectory the directory in which the information should be stored + */ private void addGeneralUserInformation(User user, Path workingDirectory) throws IOException { String[] headers = { "login", "name", "email", "registration number" }; CSVFormat csvFormat = CSVFormat.DEFAULT.builder().setHeader(headers).build(); @@ -171,11 +219,18 @@ private void addGeneralUserInformation(User user, Path workingDirectory) throws } } + /** + * Creates the zip file containing the data export. + * + * @param userLogin the login of the user for which the data export was created + * @param workingDirectory the directory containing the data export + * @return the path to the zip file + * @throws IOException if the zip file could not be created + */ private Path createDataExportZipFile(String userLogin, Path workingDirectory) throws IOException { // There should actually never exist more than one data export for a user at a time (once the feature is fully implemented), but to be sure the name is unique, we add the // current timestamp return zipFileService.createZipFileWithFolderContent(dataExportsPath.resolve("data-export_" + userLogin + ZonedDateTime.now().toEpochSecond() + ZIP_FILE_EXTENSION), workingDirectory, null); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExamCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExamCreationService.java index 126c8c204a61..af105f6ba49d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExamCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExamCreationService.java @@ -7,9 +7,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -18,14 +16,18 @@ import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.GradingScale; import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.repository.GradingScaleRepository; import de.tum.in.www1.artemis.repository.StudentExamRepository; import de.tum.in.www1.artemis.service.exam.ExamService; import de.tum.in.www1.artemis.web.rest.dto.ExamScoresDTO; /** * A service to create the data export for exams the user has participated in. + * This includes exercise participations and general information such as working time. + * Results are only included if the results are already published. */ @Service public class DataExportExamCreationService { @@ -38,11 +40,14 @@ public class DataExportExamCreationService { private final ExamService examService; - public DataExportExamCreationService(StudentExamRepository studentExamRepository, DataExportExerciseCreationService dataExportExerciseCreationService, - ExamService examService) { + private final GradingScaleRepository gradingScaleRepository; + + public DataExportExamCreationService(StudentExamRepository studentExamRepository, DataExportExerciseCreationService dataExportExerciseCreationService, ExamService examService, + GradingScaleRepository gradingScaleRepository) { this.studentExamRepository = studentExamRepository; this.dataExportExerciseCreationService = dataExportExerciseCreationService; this.examService = examService; + this.gradingScaleRepository = gradingScaleRepository; } /** @@ -61,14 +66,24 @@ public void createExportForExams(long userId, Path workingDirectory) throws IOEx var exam = studentExam.getExam(); var examTitle = exam.getSanitizedExamTitle(); var courseDirPath = retrieveCourseDirPath(workingDirectory, exam.getCourse()); - createDirectoryIfNotExistent(courseDirPath); + var examsDirPath = courseDirPath.resolve("exams"); + createDirectoryIfNotExistent(examsDirPath); var examDirectoryName = EXAM_DIRECTORY_PREFIX + examTitle + "_" + studentExam.getId(); - var examWorkingDir = Files.createDirectories(courseDirPath.resolve(examDirectoryName)); - createStudentExamExport(studentExam, examWorkingDir); + var examWorkingDirPath = examsDirPath.resolve(examDirectoryName); + createDirectoryIfNotExistent(examWorkingDirPath); + createStudentExamExport(studentExam, examWorkingDirPath); } } } + /** + * Creates the data export for the given student exam. + *

+ * This includes extracting all exercise participations, general exam information such as working time, and the results if the results are published. + * + * @param studentExam the student exam belonging to the user for which the data export should be created + * @param examWorkingDir the directory in which the information about the exam should be stored + */ private void createStudentExamExport(StudentExam studentExam, Path examWorkingDir) throws IOException { for (var exercise : studentExam.getExercises()) { // since the behavior is undefined if multiple student exams for the same exam and student combination exist, the exercise can be null @@ -76,10 +91,10 @@ private void createStudentExamExport(StudentExam studentExam, Path examWorkingDi continue; } if (exercise instanceof ProgrammingExercise programmingExercise) { - dataExportExerciseCreationService.createProgrammingExerciseExport(programmingExercise, examWorkingDir, studentExam.getUser().getId()); + dataExportExerciseCreationService.createProgrammingExerciseExport(programmingExercise, examWorkingDir, studentExam.getUser()); } else { - dataExportExerciseCreationService.createNonProgrammingExerciseExport(exercise, examWorkingDir, studentExam.getUser().getId()); + dataExportExerciseCreationService.createNonProgrammingExerciseExport(exercise, examWorkingDir, studentExam.getUser()); } } // leave out the results if the results are not published yet to avoid leaking information through the data export @@ -89,11 +104,18 @@ private void createStudentExamExport(StudentExam studentExam, Path examWorkingDi addGeneralExamInformation(studentExam, examWorkingDir); } + /** + * Adds the results of the student to the data export. + * + * @param studentExam the student exam for which the results should be added + * @param examWorkingDir the directory in which the results should be stored + */ private void addExamScores(StudentExam studentExam, Path examWorkingDir) throws IOException { var studentExamGrade = examService.getStudentExamGradeForDataExport(studentExam); var studentResult = studentExamGrade.studentResult(); + var gradingScale = gradingScaleRepository.findByExamId(studentExam.getExam().getId()); List headers = new ArrayList<>(); - var examResults = getExamResultsStreamToPrint(studentResult, headers); + var examResults = getExamResultsStreamToPrint(studentResult, headers, gradingScale); CSVFormat csvFormat = CSVFormat.DEFAULT.builder().setHeader(headers.toArray(new String[0])).build(); try (final CSVPrinter printer = new CSVPrinter( Files.newBufferedWriter(examWorkingDir.resolve(EXAM_DIRECTORY_PREFIX + studentExam.getId() + "_result" + CSV_FILE_EXTENSION)), csvFormat)) { @@ -102,21 +124,29 @@ private void addExamScores(StudentExam studentExam, Path examWorkingDir) throws } } - private Stream getExamResultsStreamToPrint(ExamScoresDTO.StudentResult studentResult, List headers) { + /** + * Returns a stream of the exam results that should be included in the exam results CSV file. + * + * @param studentResult the result belonging to the student exam + * @param headers a list containing the column headers that should be included in the CSV file + * @param gradingScaleOptional the optional grading scale of the exam + * @return a stream of information that should be included in the exam results CSV file + */ + private Stream getExamResultsStreamToPrint(ExamScoresDTO.StudentResult studentResult, List headers, Optional gradingScaleOptional) { var builder = Stream.builder(); if (studentResult.overallPointsAchieved() != null) { builder.add(studentResult.overallPointsAchieved()); headers.add("overall points"); } - if (studentResult.hasPassed() != null) { + if (studentResult.hasPassed() != null && gradingScaleOptional.isPresent()) { builder.add(studentResult.hasPassed()); headers.add("passed"); } - if (studentResult.overallGrade() != null) { + if (studentResult.overallGrade() != null && gradingScaleOptional.isPresent()) { builder.add(studentResult.overallGrade()); headers.add("overall grade"); } - if (studentResult.gradeWithBonus() != null) { + if (studentResult.gradeWithBonus() != null && gradingScaleOptional.isPresent()) { builder.add(studentResult.gradeWithBonus()); headers.add("grade with bonus"); } @@ -127,15 +157,63 @@ private Stream getExamResultsStreamToPrint(ExamScoresDTO.StudentResult studen return builder.build(); } + /** + * Adds general information about the student exam to the data export. + *

+ * This includes information such as if the exam was started, if it is a test exam, when it was started, if it was submitted, when it was submitted, the working time, and the + * individual end of the working time. + * + * @param studentExam the student exam for which the information should be added + * @param examWorkingDir the directory in which the information should be stored + */ private void addGeneralExamInformation(StudentExam studentExam, Path examWorkingDir) throws IOException { - String[] headers = { "started", "testExam", "started at", "submitted", "submitted at", "working time (in minutes)", "individual end date" }; - CSVFormat csvFormat = CSVFormat.DEFAULT.builder().setHeader(headers).build(); + List headers = new ArrayList<>(); + var generalExamInformation = getGeneralExamInformationStreamToPrint(studentExam, headers); + CSVFormat csvFormat = CSVFormat.DEFAULT.builder().setHeader(headers.toArray(new String[0])).build(); try (CSVPrinter printer = new CSVPrinter(Files.newBufferedWriter(examWorkingDir.resolve(EXAM_DIRECTORY_PREFIX + studentExam.getId() + CSV_FILE_EXTENSION)), csvFormat)) { - printer.printRecord(studentExam.isStarted(), studentExam.isTestExam(), studentExam.getStartedDate(), studentExam.isSubmitted(), studentExam.getSubmissionDate(), - studentExam.getWorkingTime() / 60, studentExam.getIndividualEndDate()); + printer.printRecord(generalExamInformation); printer.flush(); } } + /** + * Returns a stream of the general exam information that should be included in the general exam information CSV file. + * Do not include information if it is not available, this means null. + * + * @param studentExam the student exam for which the information should be added + * @param headers a list containing the column headers that should be included in the CSV file + * @return a stream of information that should be included in the general exam information CSV file + */ + private Stream getGeneralExamInformationStreamToPrint(StudentExam studentExam, List headers) { + var builder = Stream.builder(); + if (studentExam.isStarted() != null) { + builder.add(studentExam.isStarted()); + headers.add("started"); + } + headers.add("test exam"); + builder.add(studentExam.isTestExam()); + if (studentExam.getStartedDate() != null) { + builder.add(studentExam.getStartedDate()); + headers.add("started at"); + } + if (studentExam.isSubmitted() != null) { + builder.add(studentExam.isSubmitted()); + headers.add("submitted"); + } + if (studentExam.getSubmissionDate() != null) { + builder.add(studentExam.getSubmissionDate()); + headers.add("submitted at"); + } + if (studentExam.getWorkingTime() != null) { + builder.add(studentExam.getWorkingTime() / 60); + headers.add("working time (in minutes)"); + } + if (studentExam.getIndividualEndDate() != null) { + builder.add(studentExam.getIndividualEndDate()); + headers.add("individual end date"); + } + return builder.build(); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java index f29034fbf690..73a08df82c95 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportExerciseCreationService.java @@ -1,7 +1,9 @@ package de.tum.in.www1.artemis.service.dataexport; import static de.tum.in.www1.artemis.service.dataexport.DataExportQuizExerciseCreationService.TXT_FILE_EXTENSION; +import static de.tum.in.www1.artemis.service.dataexport.DataExportUtil.createDirectoryIfNotExistent; import static de.tum.in.www1.artemis.service.dataexport.DataExportUtil.retrieveCourseDirPath; +import static de.tum.in.www1.artemis.service.util.RoundingUtil.roundToNDecimalPlaces; import java.io.File; import java.io.IOException; @@ -20,7 +22,7 @@ import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.enumeration.ComplaintType; +import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.metis.AnswerPost; import de.tum.in.www1.artemis.domain.modeling.ModelingSubmission; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; @@ -29,20 +31,23 @@ import de.tum.in.www1.artemis.repository.ComplaintRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; -import de.tum.in.www1.artemis.service.ExerciseDateService; -import de.tum.in.www1.artemis.service.FileService; +import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.connectors.apollon.ApollonConversionService; import de.tum.in.www1.artemis.service.programming.ProgrammingExerciseExportService; import de.tum.in.www1.artemis.web.rest.dto.RepositoryExportOptionsDTO; /** - * A service to create the data export for exercise participations of the user + * A service to create the data export for exercise participations of the user. + * It is responsible for creating the export for programming exercises and modeling, text, and file upload exercises. + * For quiz exercises it delegates the creation of the export to {@link DataExportQuizExerciseCreationService}. */ @Service public class DataExportExerciseCreationService { private static final String PDF_FILE_EXTENSION = ".pdf"; + private static final String EXERCISE_PREFIX = "exercise_"; + static final String CSV_FILE_EXTENSION = ".csv"; private final Path repoClonePath; @@ -64,10 +69,14 @@ public class DataExportExerciseCreationService { private final ExerciseRepository exerciseRepository; + private final ResultService resultService; + + private final AuthorizationCheckService authCheckService; + public DataExportExerciseCreationService(@Value("${artemis.repo-download-clone-path}") Path repoClonePath, FileService fileService, ProgrammingExerciseExportService programmingExerciseExportService, DataExportQuizExerciseCreationService dataExportQuizExerciseCreationService, PlagiarismCaseRepository plagiarismCaseRepository, Optional apollonConversionService, ComplaintRepository complaintRepository, - ExerciseRepository exerciseRepository) { + ExerciseRepository exerciseRepository, ResultService resultService, AuthorizationCheckService authCheckService) { this.fileService = fileService; this.programmingExerciseExportService = programmingExerciseExportService; this.dataExportQuizExerciseCreationService = dataExportQuizExerciseCreationService; @@ -76,53 +85,57 @@ public DataExportExerciseCreationService(@Value("${artemis.repo-download-clone-p this.complaintRepository = complaintRepository; this.exerciseRepository = exerciseRepository; this.repoClonePath = repoClonePath; + this.resultService = resultService; + this.authCheckService = authCheckService; } /** * Creates the export for all exercises the user participated in. * * @param workingDirectory the directory the export should be created in - * @param userId the id of the user that requested the export + * @param user the user for which the export should be created * @throws IOException if an error occurs while accessing the file system */ - public void createExercisesExport(Path workingDirectory, long userId) throws IOException { + public void createExercisesExport(Path workingDirectory, User user) throws IOException { // retrieve all exercises as we cannot retrieve the exercises by course because a user might have participated in a course they are no longer a member of (they have // unenrolled) - var allExerciseParticipations = exerciseRepository.getAllExercisesUserParticipatedInWithEagerParticipationsSubmissionsResultsFeedbacksByUserId(userId); + var allExerciseParticipations = exerciseRepository.getAllExercisesUserParticipatedInWithEagerParticipationsSubmissionsResultsFeedbacksByUserId(user.getId()); var exerciseParticipationsPerCourse = allExerciseParticipations.stream().collect(Collectors.groupingBy(Exercise::getCourseViaExerciseGroupOrCourseMember)); for (var entry : exerciseParticipationsPerCourse.entrySet()) { var course = entry.getKey(); Path courseDir = retrieveCourseDirPath(workingDirectory, course); var exercises = entry.getValue(); + Path exercisesDir = courseDir.resolve("exercises"); if (!exercises.isEmpty()) { - Files.createDirectory(courseDir); + createDirectoryIfNotExistent(exercisesDir); } for (var exercise : exercises) { if (exercise instanceof ProgrammingExercise programmingExercise) { - createProgrammingExerciseExport(programmingExercise, courseDir, userId); + createProgrammingExerciseExport(programmingExercise, exercisesDir, user); } else { - createNonProgrammingExerciseExport(exercise, courseDir, userId); + createNonProgrammingExerciseExport(exercise, exercisesDir, user); } } } } /** - * Creates an export for a given programming exercise. Includes submission information, the repository from the VCS and potential plagiarism cases. + * Creates an export for a given programming exercise. + *

+ * Includes submission information, the repository from the VCS and potential plagiarism cases. * * @param programmingExercise the programming exercise for which the export should be created - * @param courseDir the directory that is used for the course the exercise belongs to - * @param userId the id of the user that requested the export + * @param exercisesDir the directory where all exercises of a course should be stored + * @param user the user for which the export should be created * @throws IOException if an error occurs while accessing the file system */ - - public void createProgrammingExerciseExport(ProgrammingExercise programmingExercise, Path courseDir, long userId) throws IOException { - Path exerciseDir = courseDir.resolve(programmingExercise.getSanitizedExerciseTitle()); + public void createProgrammingExerciseExport(ProgrammingExercise programmingExercise, Path exercisesDir, User user) throws IOException { + Path exerciseDir = exercisesDir.resolve(EXERCISE_PREFIX + programmingExercise.getSanitizedExerciseTitle()); if (!Files.exists(exerciseDir)) { Files.createDirectory(exerciseDir); } - createSubmissionsResultsExport(programmingExercise, exerciseDir); + createSubmissionsResultsExport(programmingExercise, exerciseDir, user); RepositoryExportOptionsDTO repositoryExportOptions = new RepositoryExportOptionsDTO(); repositoryExportOptions.setExportAllParticipants(false); repositoryExportOptions.setAnonymizeRepository(false); @@ -141,7 +154,7 @@ public void createProgrammingExerciseExport(ProgrammingExercise programmingExerc programmingExerciseExportService.exportStudentRepositories(programmingExercise, listOfProgrammingExerciseParticipations, repositoryExportOptions, tempRepoWorkingDir, exerciseDir, Collections.synchronizedList(new ArrayList<>())); - createPlagiarismCaseInfoExport(programmingExercise, exerciseDir, userId); + createPlagiarismCaseInfoExport(programmingExercise, exerciseDir, user.getId()); } @@ -150,21 +163,35 @@ public void createProgrammingExerciseExport(ProgrammingExercise programmingExerc * * @param exercise the exercise for which the export should be created * @param courseDir the directory that is used for the course the exercise belongs to - * @param userId the id of the user that requested the export + * @param user the user for which the export should be created * @throws IOException if an error occurs while accessing the file system */ - public void createNonProgrammingExerciseExport(Exercise exercise, Path courseDir, long userId) throws IOException { - Path exercisePath = courseDir.resolve(exercise.getSanitizedExerciseTitle()); + public void createNonProgrammingExerciseExport(Exercise exercise, Path courseDir, User user) throws IOException { + Path exercisePath = courseDir.resolve(EXERCISE_PREFIX + exercise.getSanitizedExerciseTitle()); if (!Files.exists(exercisePath)) { Files.createDirectory(exercisePath); } - createSubmissionsResultsExport(exercise, exercisePath); - createPlagiarismCaseInfoExport(exercise, exercisePath, userId); + createSubmissionsResultsExport(exercise, exercisePath, user); + createPlagiarismCaseInfoExport(exercise, exercisePath, user.getId()); } - private void createSubmissionsResultsExport(Exercise exercise, Path exerciseDir) throws IOException { - boolean includeResults = exercise.isExamExercise() && exercise.getExamViaExerciseGroupOrCourseMember().resultsPublished() - || exercise.isCourseExercise() && ExerciseDateService.isAfterAssessmentDueDate(exercise); + /** + * Creates the export for the submission of the user to the given exercise. + *

+ * Includes the submission information and the submission content and the results if the results are + * published. + * For quiz exercises it delegates the creation of the export to {@link DataExportQuizExerciseCreationService}. + * + * @param exercise the exercise for which the export should be created + * @param exerciseDir the directory in which the export should be created + * @param user the user for which the export should be created + */ + private void createSubmissionsResultsExport(Exercise exercise, Path exerciseDir, User user) throws IOException { + // quizzes do not have an assessment due date, so we need to check if they have ended according to their due date + boolean isInstructor = authCheckService.isAtLeastInstructorForExercise(exercise, user); + boolean includeResults = (exercise.isExamExercise() && exercise.getExamViaExerciseGroupOrCourseMember().resultsPublished()) + || (exercise.isCourseExercise() && ExerciseDateService.isAfterAssessmentDueDate(exercise) && !(exercise instanceof QuizExercise)) + || (exercise.isCourseExercise() && exercise instanceof QuizExercise quizExercise && quizExercise.isQuizEnded()) || isInstructor; for (var participation : exercise.getStudentParticipations()) { for (var submission : participation.getSubmissions()) { createSubmissionCsvFile(submission, exerciseDir); @@ -180,13 +207,22 @@ else if (submission instanceof ModelingSubmission modelingSubmission) { else if (submission instanceof QuizSubmission) { dataExportQuizExerciseCreationService.createQuizAnswersExport((QuizExercise) exercise, participation, exerciseDir, includeResults); } - if (includeResults) { - createResultsAndComplaintFiles(submission, exerciseDir); + // for a programming exercise, we want to include the results that are visible before the assessment due date + if (includeResults || exercise instanceof ProgrammingExercise) { + boolean programmingExerciseBeforeAssessmentDueDate = exercise instanceof ProgrammingExercise && !ExerciseDateService.isAfterAssessmentDueDate(exercise); + createResultsAndComplaintFiles(submission, exerciseDir, user, programmingExerciseBeforeAssessmentDueDate, isInstructor); } } } } + /** + * Stores the modeling submission as pdf if the apollon profile is active and the apollon conversion service works, otherwise stores it as json file. + * + * @param modelingSubmission the modeling submission for which the content should be stored + * @param outputDir the directory in which the content should be stored + * @throws IOException if the file cannot be written + */ private void storeModelingSubmissionContent(ModelingSubmission modelingSubmission, Path outputDir) throws IOException { if (modelingSubmission.getModel() == null) { log.warn("Cannot include modeling submission content in data export because content is null for submission with id: {}", modelingSubmission.getId()); @@ -202,12 +238,22 @@ private void storeModelingSubmissionContent(ModelingSubmission modelingSubmissio try (var modelAsPdf = apollonConversionService.get().convertModel(modelingSubmission.getModel())) { Files.write(outputDir.resolve(fileName + PDF_FILE_EXTENSION), modelAsPdf.readAllBytes()); } - catch (IOException e) { + catch (Exception e) { log.warn("Failed to include the model as pdf, going to include it as plain JSON file."); addModelJsonWithExplanationHowToView(modelingSubmission.getModel(), outputDir, fileName); } } + /** + * Stores the given model as json file and adds a markdown file with an explanation how to view the model. + *

+ * Used if the Apollon Conversion Service is not available or an error occurs while using it. + * + * @param model the model belonging to the submission as JSON string + * @param outputDir the directory in which the content should be stored + * @param fileName the file name of the JSON file + * @throws IOException if the file cannot be written + */ private void addModelJsonWithExplanationHowToView(String model, Path outputDir, String fileName) throws IOException { Files.writeString(outputDir.resolve(fileName + ".json"), model); String explanation = """ @@ -216,6 +262,13 @@ private void addModelJsonWithExplanationHowToView(String model, Path outputDir, Files.writeString(outputDir.resolve("view_model.md"), explanation); } + /** + * Stores the text submission content as txt file. + * + * @param textSubmission the text submission for which the content should be stored + * @param outputDir the directory in which the content should be stored + * @throws IOException if the file cannot be written + */ private void storeTextSubmissionContent(TextSubmission textSubmission, Path outputDir) throws IOException { // text can be null which leads to an exception if (textSubmission.getText() != null) { @@ -226,32 +279,56 @@ private void storeTextSubmissionContent(TextSubmission textSubmission, Path outp } } - private void createResultsAndComplaintFiles(Submission submission, Path outputDir) throws IOException { + /** + * Creates a txt file containing the results with the score, the number of passed test cases if it is a programming exercise + * and the feedbacks (both manual and automatic). + * + * @param submission the submission for which the results should be stored + * @param outputDir the directory in which the results should be stored + * @param user the user for which the export should be created + * @param programmingExerciseBeforeAssessmentDueDate whether the programming exercise is before the assessment due date + * @param isInstructor whether the user is an instructor in the course the exercise belongs to + * @throws IOException if the file cannot be written + */ + private void createResultsAndComplaintFiles(Submission submission, Path outputDir, User user, boolean programmingExerciseBeforeAssessmentDueDate, boolean isInstructor) + throws IOException { StringBuilder resultScoreAndFeedbacks = new StringBuilder(); for (var result : submission.getResults()) { if (result != null) { + // Do not include the results if the assessment due date is in the future and the assessment is not automatic and the user is not an instructor + // We only consider programming exercises here because for other exercises this method is not called if the assessment due date is in the future + if (programmingExerciseBeforeAssessmentDueDate && result.getAssessmentType() != AssessmentType.AUTOMATIC && !isInstructor) { + continue; + } + resultService.filterSensitiveInformationIfNecessary(submission.getParticipation(), List.of(result), Optional.of(user)); var score = result.getScore(); if (score != null) { resultScoreAndFeedbacks.append("Score of submission: ").append(score).append("%").append(" ") - .append(score * submission.getParticipation().getExercise().getMaxPoints() / 100).append(" Points").append("\n"); + .append(roundToNDecimalPlaces(score * submission.getParticipation().getExercise().getMaxPoints() / 100, 2)).append(" Points").append("\n"); } if (submission instanceof ProgrammingSubmission && result.getPassedTestCaseCount() != null && result.getTestCaseCount() != null && result.getTestCaseCount() > 0) { resultScoreAndFeedbacks.append("Passed test cases: ").append(result.getPassedTestCaseCount()).append("/").append(result.getTestCaseCount()).append("\n"); } + if (submission instanceof ProgrammingSubmission programmingSubmission && programmingSubmission.isBuildFailed()) { + resultScoreAndFeedbacks.append("Build failed").append("\n"); + } for (var feedback : result.getFeedbacks()) { - resultScoreAndFeedbacks.append("- Feedback: "); - // null if it's manual feedback - if (feedback.getText() != null) { - resultScoreAndFeedbacks.append(feedback.getText()).append("\t"); + if (feedback != null) { + resultScoreAndFeedbacks.append("- Feedback: "); + + // null if it's manual feedback + if (feedback.getText() != null) { + resultScoreAndFeedbacks.append(feedback.getText()).append("\t"); + } + // null if the test case passes + if (feedback.getDetailText() != null) { + resultScoreAndFeedbacks.append(feedback.getDetailText()).append("\t"); + } + if (feedback.getCredits() != null) { + resultScoreAndFeedbacks.append(feedback.getCredits()); + } + resultScoreAndFeedbacks.append("\n"); } - // null if the test case passes - if (feedback.getDetailText() != null) { - resultScoreAndFeedbacks.append(feedback.getDetailText()).append("\t"); - } - if (feedback.getCredits() != null) { - resultScoreAndFeedbacks.append(feedback.getCredits()); - } - resultScoreAndFeedbacks.append("\n"); } Files.writeString(outputDir.resolve("submission_" + submission.getId() + "_result_" + result.getId() + TXT_FILE_EXTENSION), resultScoreAndFeedbacks); } @@ -263,6 +340,15 @@ private void createResultsAndComplaintFiles(Submission submission, Path outputDi } } + /** + * Creates a CSV file containing the complaint data. + *

+ * Complaint can be either a complaint or a more feedback request. + * + * @param complaint the complaint for which the data should be stored + * @param outputDir the directory in which the data should be stored + * @throws IOException if the file cannot be written + */ private void addComplaintData(Complaint complaint, Path outputDir) throws IOException { List headers = new ArrayList<>(); var dataStreamBuilder = Stream.builder(); @@ -292,6 +378,14 @@ private void addComplaintData(Complaint complaint, Path outputDir) throws IOExce } } + /** + * Creates a CSV file containing the plagiarism case information. + * + * @param exercise the exercise for which the plagiarism case information should be stored + * @param exercisePath the directory in which the plagiarism case information should be stored + * @param userId the id of the user that requested the export and that is involved in the plagiarism case + * @throws IOException if the file cannot be written + */ private void createPlagiarismCaseInfoExport(Exercise exercise, Path exercisePath, long userId) throws IOException { var plagiarismCaseOptional = plagiarismCaseRepository.findByStudentIdAndExerciseIdWithPostAndAnswerPost(userId, exercise.getId()); List headers = new ArrayList<>(); @@ -329,6 +423,14 @@ else if (plagiarismCase.getVerdict() == PlagiarismVerdict.WARNING) { } } + /** + * Copies the file upload submission file to the data export working directory if it still exists. + * + * @param submissionFilePath the path to the file upload submission file + * @param outputDir the directory to which the file should be copied + * @param fileUploadSubmission the file upload submission for which the file should be copied + * @throws IOException if the file cannot be copied + */ private void copyFileUploadSubmissionFile(String submissionFilePath, Path outputDir, FileUploadSubmission fileUploadSubmission) throws IOException { try { FileUtils.copyDirectory(new File(submissionFilePath), outputDir.toFile()); @@ -339,12 +441,28 @@ private void copyFileUploadSubmissionFile(String submissionFilePath, Path output } } + /** + * Adds a markdown file to the data export working directory that informs the user that the file for the file upload submission no longer exists. + * + * @param outputDir the directory in which the file should be stored + * @param fileUploadSubmission the file upload submission for which the file should be stored + * @throws IOException if the file cannot be written + */ private void addInfoThatFileForFileUploadSubmissionNoLongerExists(Path outputDir, FileUploadSubmission fileUploadSubmission) throws IOException { var exercise = fileUploadSubmission.getParticipation().getExercise(); Files.writeString(outputDir.resolve("submission_file_no_longer_exists.md"), String.format("Your submitted file for the exercise %s no longer exists on the file system.", exercise)); } + /** + * Creates a CSV file containing the submission information. + *

+ * This includes the id, the submission date and the commit hash if it is a programming exercise. + * + * @param submission the submission for which the information should be stored + * @param outputPath the directory in which the information should be stored + * @throws IOException if the file cannot be written + */ private void createSubmissionCsvFile(Submission submission, Path outputPath) throws IOException { List headers = new ArrayList<>(List.of("id", "submissionDate")); if (submission instanceof ProgrammingSubmission) { @@ -360,6 +478,14 @@ private void createSubmissionCsvFile(Submission submission, Path outputPath) thr } } + /** + * Returns a stream of the submission information that should be included in the CSV file. + *

+ * This includes the id, the submission date and the commit hash if it is a programming exercise. + * + * @param submission the submission for which the information should be stored + * @return a stream of the submission information that should be included in the CSV file + */ private Stream getSubmissionStreamToPrint(Submission submission) { var builder = Stream.builder(); builder.add(submission.getId()).add(submission.getSubmissionDate()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportQuizExerciseCreationService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportQuizExerciseCreationService.java index bcc98cd6dedb..6b51e2ecf0ff 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportQuizExerciseCreationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportQuizExerciseCreationService.java @@ -16,7 +16,10 @@ import de.tum.in.www1.artemis.service.DragAndDropQuizAnswerConversionService; /** - * A service to create the data export for quiz exercise participations + * A service to create the data export for quiz exercise participations. + * This includes creating a pdf highlighting the submitted answers for drag and drop questions and + * txt files containing the submitted answers for multiple choice and short answer questions. + * Additionally, the results can be included in the export if the due date is over. */ @Service public class DataExportQuizExerciseCreationService { @@ -80,6 +83,13 @@ else if (submittedAnswer instanceof MultipleChoiceSubmittedAnswer multipleChoice } + /** + * Creates a txt file containing the submitted answers for a multiple choice question and information if the answer was correct or not if includeResults is true. + * + * @param multipleChoiceSubmittedAnswer the submitted answer to a multiple choice question that should be included in the export + * @param includeResults true if the results should be included in the export (if the assessment due date or result publication date is over) + * @return the content for the txt file as a string + */ private String createExportForMultipleChoiceAnswerQuestion(MultipleChoiceSubmittedAnswer multipleChoiceSubmittedAnswer, boolean includeResults) { StringBuilder stringBuilder = new StringBuilder(); MultipleChoiceQuestion question = (MultipleChoiceQuestion) multipleChoiceSubmittedAnswer.getQuizQuestion(); @@ -105,6 +115,15 @@ private String createExportForMultipleChoiceAnswerQuestion(MultipleChoiceSubmitt return stringBuilder.toString(); } + /** + * Adds an explanation to the answer option if no result should be included in the export. + *

+ * The explanation contains information if the answer option was selected or not or if it is invalid. + * + * @param multipleChoiceSubmittedAnswer the submitted answer to a multiple choice question that should be included in the export + * @param stringBuilder the string builder user to create the txt file content + * @param answerOption the answer option for which the explanation should be added + */ private void addExplanationToAnswerOptionWithoutResult(MultipleChoiceSubmittedAnswer multipleChoiceSubmittedAnswer, StringBuilder stringBuilder, AnswerOption answerOption) { if (answerOption.isInvalid()) { stringBuilder.append("Invalid answer option: "); @@ -117,6 +136,15 @@ else if (multipleChoiceSubmittedAnswer.getSelectedOptions().contains(answerOptio } } + /** + * Adds an explanation to the answer option if a result should be included in the export. + *

+ * The explanation contains information if the answer option was selected or not or it is invalid and if the answer option is correct or not. + * + * @param multipleChoiceSubmittedAnswer the submitted answer to a multiple choice question that should be included in the export + * @param stringBuilder the string builder user to create the txt file content + * @param answerOption the answer option for which the explanation should be added + */ private void addExplanationToAnswerOptionWithResult(MultipleChoiceSubmittedAnswer multipleChoiceSubmittedAnswer, StringBuilder stringBuilder, AnswerOption answerOption) { if (answerOption.isInvalid()) { stringBuilder.append("Invalid answer option: "); @@ -135,6 +163,13 @@ else if (!answerOption.isIsCorrect() && !multipleChoiceSubmittedAnswer.getSelect } } + /** + * Creates a txt file containing the submitted answers for a short answer question and information if the answer was correct or not if includeResults is true. + * + * @param shortAnswerSubmittedAnswer the submitted answer to a short answer question that should be included in the export + * @param includeResults true if the results should be included in the export (if the assessment due date or result publication date is over) + * @return the content for the txt file as a string + */ private String createExportForShortAnswerQuestion(ShortAnswerSubmittedAnswer shortAnswerSubmittedAnswer, boolean includeResults) { StringBuilder stringBuilder = new StringBuilder(); ShortAnswerQuestion question = (ShortAnswerQuestion) shortAnswerSubmittedAnswer.getQuizQuestion(); @@ -145,6 +180,14 @@ private String createExportForShortAnswerQuestion(ShortAnswerSubmittedAnswer sho return replaceSpotWithSubmittedAnswer(shortAnswerSubmittedAnswer, stringBuilder, includeResults); } + /** + * Replaces the spots (the gaps that indicate where an answer should be entered) in the text of a short answer question with the submitted answers. + * + * @param shortAnswerSubmittedAnswer the submitted answer to a short answer question that should be included in the export + * @param submittedAnswer the string builder user to create the txt file content + * @param includeResults true if the results should be included in the export (if the assessment due date or result publication date is over) + * @return the string containing the question text with the answers of the user + */ private String replaceSpotWithSubmittedAnswer(ShortAnswerSubmittedAnswer shortAnswerSubmittedAnswer, StringBuilder submittedAnswer, boolean includeResults) { var spotToSubmittedTextMap = buildMapFromSpotsToSubmittedAnswers(shortAnswerSubmittedAnswer); submittedAnswer.append("Your answer: ").append("\n"); @@ -160,30 +203,39 @@ private String replaceSpotWithSubmittedAnswer(ShortAnswerSubmittedAnswer shortAn return submittedAnswer.toString(); } + /** + * Adds the submitted answer to the string builder if the answer is correct or incorrect. + * + * @param submittedAnswer the string builder user to create the txt file content + * @param includeResults true if the results should be included in the export (if the assessment due date or result publication date is over) + * @param submittedText the question text of a short answer question that should be included in the export + * @param pattern the pattern used to find the spot in the question text + * @param matcher the matcher used to find the spot in the question text + * @param replacement the string builder used to create the replacement (the submitted answer text) for the spot + * @return the matcher used to find the next spot in the question text + */ private Matcher addSubmittedAnswerWithResult(StringBuilder submittedAnswer, boolean includeResults, ShortAnswerSubmittedText submittedText, Pattern pattern, Matcher matcher, StringBuilder replacement) { int start = matcher.start(); int end = matcher.end(); - if (submittedText.isIsCorrect() != null && submittedText.isIsCorrect()) { - replacement.append(submittedText.getText()); - if (includeResults) { - replacement.append(" (Correct)"); - } + replacement.append(submittedText.getText()); + if (submittedText.isIsCorrect() != null && submittedText.isIsCorrect() && includeResults) { + replacement.append(" (Correct)"); } - else if (submittedText.isIsCorrect() != null && !submittedText.isIsCorrect()) { - replacement.append(submittedText.getText()); - if (includeResults) { - replacement.append(" (Incorrect)"); - } - else { - replacement.append(submittedText.getText()); - } - submittedAnswer.replace(start, end, replacement.toString()); - matcher = pattern.matcher(submittedAnswer); + else if (submittedText.isIsCorrect() != null && !submittedText.isIsCorrect() && includeResults) { + replacement.append(" (Incorrect)"); } + submittedAnswer.replace(start, end, replacement.toString()); + matcher = pattern.matcher(submittedAnswer); return matcher; } + /** + * Builds a map from the spots (the gaps that indicate where an answer should be entered) in the text of a short answer question to the submitted answers. + * + * @param shortAnswerSubmittedAnswer the submitted answer to a short answer question that should be included in the export + * @return a map from the spots (represented as the string in text) to the submitted answers + */ private Map buildMapFromSpotsToSubmittedAnswers(ShortAnswerSubmittedAnswer shortAnswerSubmittedAnswer) { Map spotsToSubmittedAnswers = new HashMap<>(); for (var submittedText : shortAnswerSubmittedAnswer.getSubmittedTexts()) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportService.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportService.java index c23d99dc241f..a067a4eeb301 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportService.java @@ -22,10 +22,13 @@ import de.tum.in.www1.artemis.service.FileService; import de.tum.in.www1.artemis.web.rest.dto.DataExportDTO; import de.tum.in.www1.artemis.web.rest.dto.RequestDataExportDTO; +import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.InternalServerErrorException; /** * Service Implementation for managing the data export in accordance with Art. 15 GDPR. + * This service is responsible for downloading, deleting data exports and checking if a data export can be requested. + * For creating data exports, see {@link DataExportCreationService}. */ @Service public class DataExportService { @@ -109,8 +112,7 @@ public Resource downloadDataExport(DataExport dataExport) { public DataExportDTO canDownloadAnyDataExport() { var noDataExport = new DataExportDTO(null, null, null, null); var user = userRepository.getUser(); - var dataExportsFromUser = dataExportRepository.findAllDataExportsByUserId(user.getId()); - Optional latestDataExport = dataExportsFromUser.stream().max(Comparator.comparing(DataExport::getCreatedDate)); + var dataExportsFromUser = dataExportRepository.findAllDataExportsByUserIdOrderByRequestDateDesc(user.getId()); if (dataExportsFromUser.isEmpty()) { return noDataExport; } @@ -120,10 +122,21 @@ public DataExportDTO canDownloadAnyDataExport() { return new DataExportDTO(dataExport.getId(), dataExport.getDataExportState(), dataExport.getCreatedDate().atZone(ZoneId.systemDefault()), nextRequestDate); } } - return new DataExportDTO(null, latestDataExport.get().getDataExportState(), latestDataExport.get().getCreatedDate().atZone(ZoneId.systemDefault()), - retrieveNextRequestDate(latestDataExport.get())); + var latestDataExport = dataExportsFromUser.get(0); + return new DataExportDTO(null, latestDataExport.getDataExportState(), latestDataExport.getCreatedDate().atZone(ZoneId.systemDefault()), + retrieveNextRequestDate(latestDataExport)); } + /** + * Calculates the next date when the user can request a data export. + *

+ * This is the date when the last data export was requested (stored in the createdDate) + the constant DAYS_BETWEEN_DATA_EXPORTS. + * By default, DAYS_BETWEEN_DATA_EXPORTS is set to 14 days. + * This can be changed by setting the property artemis.data-export.days-between-data-exports in the application.yml file. + * + * @param dataExport the data export for which the next request date should be calculated + * @return the next date when the user can request a data export + */ @NotNull private ZonedDateTime retrieveNextRequestDate(DataExport dataExport) { return dataExport.getCreatedDate().atZone(ZoneId.systemDefault()).plusDays(DAYS_BETWEEN_DATA_EXPORTS); @@ -148,4 +161,18 @@ public void deleteDataExportAndSetDataExportState(DataExport dataExport) { dataExportRepository.save(dataExport); } + /** + * Checks if the data export can be downloaded. + *

+ * The data export can be downloaded if its state is either EMAIL_SENT or DOWNLOADED. + * + * @param dataExport the data export to check + * @throws AccessForbiddenException if the data export is not in a downloadable state + */ + public void checkDataExportCanBeDownloadedElseThrow(DataExport dataExport) { + if (!dataExport.getDataExportState().isDownloadable()) { + throw new AccessForbiddenException("Data export has either not been created or already been deleted"); + } + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportUtil.java b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportUtil.java index 8c711062d08c..b275ec861b7e 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportUtil.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dataexport/DataExportUtil.java @@ -17,12 +17,25 @@ private DataExportUtil() { // Utility class } + /** + * Creates the given directory if it does not exist yet. + * + * @param directory the directory to create + * @throws IOException if an error occurs while accessing the file system + */ static void createDirectoryIfNotExistent(Path directory) throws IOException { if (!Files.exists(directory)) { - Files.createDirectory(directory); + Files.createDirectories(directory); } } + /** + * Retrieves the path to the directory for the given course within the data export. + * + * @param workingDirectory the working directory where the data export is created + * @param course the course for which the directory should be retrieved + * @return the path to the directory for the given course + */ static Path retrieveCourseDirPath(Path workingDirectory, Course course) { return workingDirectory.resolve(COURSE_DIRECTORY_PREFIX + course.getShortName()); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java index f60e68c2a921..cd2aabaaad9f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamUserService.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripperByArea; import org.slf4j.Logger; @@ -51,7 +52,7 @@ public ExamUserService(FileService fileService, UserRepository userRepository, E */ public List parsePDF(MultipartFile file) { - try (PDDocument document = PDDocument.load(file.getBytes())) { + try (PDDocument document = Loader.loadPDF(file.getBytes())) { ImageExtractor imageExtractor = new ImageExtractor(document); imageExtractor.process(); List images = imageExtractor.getImages(); @@ -129,6 +130,6 @@ public ExamUsersNotFoundDTO saveImages(long examId, MultipartFile file) { /** * Contains the information about an exam user with image */ - private record ExamUserWithImageDTO(String studentRegistrationNumber, ImageDTO image) { + record ExamUserWithImageDTO(String studentRegistrationNumber, ImageDTO image) { } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ImageExtractor.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ImageExtractor.java index 232c98a373a2..f7f9f210a2d0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ImageExtractor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ImageExtractor.java @@ -46,12 +46,12 @@ public ImageExtractor(PDDocument document) { this.images = new ArrayList<>(); this.pdfDocument = document; - addOperator(new Concatenate()); - addOperator(new DrawObject()); - addOperator(new SetGraphicsStateParameters()); - addOperator(new Save()); - addOperator(new Restore()); - addOperator(new SetMatrix()); + addOperator(new Concatenate(this)); + addOperator(new DrawObject(this)); + addOperator(new SetGraphicsStateParameters(this)); + addOperator(new Save(this)); + addOperator(new Restore(this)); + addOperator(new SetMatrix(this)); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java index 6ecdb4db702e..42303cc9b6c6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/plagiarism/ProgrammingPlagiarismDetectionService.java @@ -330,7 +330,7 @@ private Language getJPlagProgrammingLanguage(ProgrammingExercise programmingExer public List filterStudentParticipationsForComparison(ProgrammingExercise programmingExercise, int minimumScore) { var studentParticipations = studentParticipationRepository.findAllForPlagiarism(programmingExercise.getId()); - return studentParticipations.parallelStream().filter(participation -> !participation.isTestRun()) + return studentParticipations.parallelStream().filter(participation -> !participation.isPracticeMode()) .filter(participation -> participation instanceof ProgrammingExerciseParticipation).map(participation -> (ProgrammingExerciseParticipation) participation) .filter(participation -> participation.getVcsRepositoryUrl() != null).filter(participation -> { Submission submission = participation.findLatestSubmission().orElse(null); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java index ac7bed156470..6cdadd39c01d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseExportService.java @@ -50,7 +50,8 @@ import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage; import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; -import de.tum.in.www1.artemis.domain.participation.*; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.exception.GitException; import de.tum.in.www1.artemis.repository.AuxiliaryRepositoryRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; @@ -90,7 +91,9 @@ public class ProgrammingExerciseExportService { public static final String EXPORTED_EXERCISE_PROBLEM_STATEMENT_FILE_PREFIX = "Problem-Statement"; - private static final String EMBEDDED_FILE_REGEX = "\\[.*] *\\(/api/files/markdown/.*\\)"; + private static final String EMBEDDED_FILE_MARKDOWN_SYNTAX_REGEX = "\\[.*] *\\(/api/files/markdown/.*\\)"; + + private static final String EMBEDDED_FILE_HTML_SYNTAX_REGEX = ""; private static final String API_MARKDOWN_FILE_PATH = "/api/files/markdown/"; @@ -152,6 +155,14 @@ public Path exportProgrammingExerciseInstructorMaterial(ProgrammingExercise exer return pathToZippedExercise; } + /** + * Export problem statement and embedded files for a given programming exercise. + * + * @param exercise the programming exercise that is exported + * @param exportErrors List of failures that occurred during the export + * @param exportDir the directory where the content of the export is stored + * @param pathsToBeZipped the paths that should be included in the zip file + */ private void exportProblemStatementAndEmbeddedFiles(ProgrammingExercise exercise, List exportErrors, Path exportDir, List pathsToBeZipped) { var problemStatementFileExtension = ".md"; String problemStatementFileName = EXPORTED_EXERCISE_PROBLEM_STATEMENT_FILE_PREFIX + "-" + exercise.getTitle() + problemStatementFileExtension; @@ -168,15 +179,97 @@ private void exportProblemStatementAndEmbeddedFiles(ProgrammingExercise exercise * @param outputDir the directory where the content of the export is stored * @param pathsToBeZipped the paths that should be included in the zip file */ - private void copyEmbeddedFiles(ProgrammingExercise exercise, Path outputDir, List pathsToBeZipped, List exportErrors) { - Set embeddedFiles = new HashSet<>(); + Set embeddedFilesWithMarkdownSyntax = new HashSet<>(); + Set embeddedFilesWithHtmlSyntax = new HashSet<>(); + + Matcher matcherForMarkdownSyntax = Pattern.compile(EMBEDDED_FILE_MARKDOWN_SYNTAX_REGEX).matcher(exercise.getProblemStatement()); + Matcher matcherForHtmlSyntax = Pattern.compile(EMBEDDED_FILE_HTML_SYNTAX_REGEX).matcher(exercise.getProblemStatement()); + checkForMatchesInProblemStatementAndCreateDirectoryForFiles(outputDir, pathsToBeZipped, exportErrors, embeddedFilesWithMarkdownSyntax, matcherForMarkdownSyntax); + Path embeddedFilesDir = checkForMatchesInProblemStatementAndCreateDirectoryForFiles(outputDir, pathsToBeZipped, exportErrors, embeddedFilesWithHtmlSyntax, + matcherForHtmlSyntax); + // if the returned path is null the directory could not be created + if (embeddedFilesDir == null) { + return; + } + copyFilesEmbeddedWithMarkdownSyntax(exercise, exportErrors, embeddedFilesWithMarkdownSyntax, embeddedFilesDir); + copyFilesEmbeddedWithHtmlSyntax(exercise, exportErrors, embeddedFilesWithHtmlSyntax, embeddedFilesDir); + + } + + /** + * Copies the files that are embedded with Markdown syntax to the embedded files' directory. + * + * @param exercise the programming exercise that is exported + * @param exportErrors List of failures that occurred during the export + * @param embeddedFilesWithMarkdownSyntax the files that are embedded with Markdown syntax + * @param embeddedFilesDir the directory where the embedded files are stored + */ + private void copyFilesEmbeddedWithMarkdownSyntax(ProgrammingExercise exercise, List exportErrors, Set embeddedFilesWithMarkdownSyntax, Path embeddedFilesDir) { + for (String embeddedFile : embeddedFilesWithMarkdownSyntax) { + // avoid matching other closing ] or () in the squared brackets by getting the index of the last ] + String lastPartOfMatchedString = embeddedFile.substring(embeddedFile.lastIndexOf("]") + 1); + String filePath = lastPartOfMatchedString.substring(lastPartOfMatchedString.indexOf("(") + 1, lastPartOfMatchedString.indexOf(")")); + constructFilenameAndCopyFile(exercise, exportErrors, embeddedFilesDir, filePath); + } + } + + /** + * Copies the files that are embedded with html syntax to the embedded files' directory. + * + * @param exercise the programming exercise that is exported + * @param exportErrors List of failures that occurred during the export + * @param embeddedFilesWithHtmlSyntax the files that are embedded with html syntax + * @param embeddedFilesDir the directory where the embedded files are stored + */ + private void copyFilesEmbeddedWithHtmlSyntax(ProgrammingExercise exercise, List exportErrors, Set embeddedFilesWithHtmlSyntax, Path embeddedFilesDir) { + for (String embeddedFile : embeddedFilesWithHtmlSyntax) { + int indexOfFirstQuotationMark = embeddedFile.indexOf('"'); + String filePath = embeddedFile.substring(embeddedFile.indexOf("src=") + 5, embeddedFile.indexOf('"', indexOfFirstQuotationMark + 1)); + constructFilenameAndCopyFile(exercise, exportErrors, embeddedFilesDir, filePath); + } + } - Matcher matcher = Pattern.compile(EMBEDDED_FILE_REGEX).matcher(exercise.getProblemStatement()); + /** + * Extracts the filename from the matched string and copies the file to the embedded files' directory. + * + * @param exercise the programming exercise that is exported + * @param exportErrors List of failures that occurred during the export + * @param embeddedFilesDir the directory where the embedded files are stored + * @param filePath the path of the file that should be copied + */ + private void constructFilenameAndCopyFile(ProgrammingExercise exercise, List exportErrors, Path embeddedFilesDir, String filePath) { + String fileName = filePath.replace(API_MARKDOWN_FILE_PATH, ""); + Path imageFilePath = Path.of(FilePathService.getMarkdownFilePath(), fileName); + Path imageExportPath = embeddedFilesDir.resolve(fileName); + // we need this check as it might be that the matched string is different and not filtered out above but the file is already copied + if (!Files.exists(imageExportPath)) { + try { + Files.copy(imageFilePath, imageExportPath); + } + catch (IOException e) { + exportErrors.add("Failed to copy embedded files: " + e.getMessage()); + log.warn("Could not copy embedded file {} for exercise with id {}", fileName, exercise.getId()); + } + } + } + + /** + * Checks for matches in the problem statement and creates a directory for the embedded files. + * + * @param outputDir the directory where the content of the export is stored + * @param pathsToBeZipped the paths that should be included in the zip file + * @param exportErrors List of failures that occurred during the export + * @param embeddedFiles the files that are embedded in the problem statement + * @param matcher the matcher that is used to find the embedded files + * @return the path to the embedded files directory or null if the directory could not be created + */ + private Path checkForMatchesInProblemStatementAndCreateDirectoryForFiles(Path outputDir, List pathsToBeZipped, List exportErrors, Set embeddedFiles, + Matcher matcher) { while (matcher.find()) { embeddedFiles.add(matcher.group()); } - log.debug("Found embedded files:{} ", embeddedFiles); + log.debug("Found embedded files: {} ", embeddedFiles); Path embeddedFilesDir = outputDir.resolve("files"); if (!embeddedFiles.isEmpty()) { if (!Files.exists(embeddedFilesDir)) { @@ -186,30 +279,12 @@ private void copyEmbeddedFiles(ProgrammingExercise exercise, Path outputDir, Lis catch (IOException e) { exportErrors.add("Could not create directory for embedded files: " + e.getMessage()); log.warn("Could not create directory for embedded files. Won't include embedded files: " + e.getMessage()); - return; + return null; } } pathsToBeZipped.add(embeddedFilesDir); } - for (String embeddedFile : embeddedFiles) { - // avoid matching other closing ] or () in the squared brackets by getting the index of the last ] - String lastPartOfMatchedString = embeddedFile.substring(embeddedFile.lastIndexOf("]") + 1); - String filePath = lastPartOfMatchedString.substring(lastPartOfMatchedString.indexOf("(") + 1, lastPartOfMatchedString.indexOf(")")); - String fileName = filePath.replace(API_MARKDOWN_FILE_PATH, ""); - Path imageFilePath = Path.of(FilePathService.getMarkdownFilePath(), fileName); - Path imageExportPath = embeddedFilesDir.resolve(fileName); - // we need this check as it might be that the matched string is different and not filtered out above but the file is already copied - if (!Files.exists(imageExportPath)) { - try { - Files.copy(imageFilePath, imageExportPath); - } - catch (IOException e) { - exportErrors.add("Failed to copy embedded files: " + e.getMessage()); - log.warn("Could not copy embedded file {} for exercise with id {}", fileName, exercise.getId()); - } - } - } - + return embeddedFilesDir; } /** @@ -632,7 +707,7 @@ private Path createZipForRepositoryWithParticipation(final ProgrammingExercise p return null; } - if (repositoryExportOptions.isExcludePracticeSubmissions() && participation.isTestRun()) { + if (repositoryExportOptions.isExcludePracticeSubmissions() && participation.isPracticeMode()) { log.debug("Ignoring practice participation {}", participation); return null; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java index b5433797faa6..e53acc5495bc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java @@ -272,11 +272,11 @@ private Result processNewProgrammingExerciseResult(final ProgrammingExercisePart // test run repository). // Student test exam participations will still be locked by this. SubmissionPolicy submissionPolicy = programmingExerciseRepository.findWithSubmissionPolicyById(programmingExercise.getId()).orElseThrow().getSubmissionPolicy(); - if (submissionPolicy instanceof LockRepositoryPolicy policy && !((ProgrammingExerciseStudentParticipation) participation).isTestRun()) { + if (submissionPolicy instanceof LockRepositoryPolicy policy && !((ProgrammingExerciseStudentParticipation) participation).isPracticeMode()) { submissionPolicyService.handleLockRepositoryPolicy(processedResult, (Participation) participation, policy); } - if (programmingSubmission.getLatestResult() != null && programmingSubmission.getLatestResult().isManual() && !((Participation) participation).isTestRun()) { + if (programmingSubmission.getLatestResult() != null && programmingSubmission.getLatestResult().isManual() && !((Participation) participation).isPracticeMode()) { // Note: in this case, we do not want to save the processedResult, but we only want to update the latest semi-automatic one Result updatedLatestSemiAutomaticResult = updateLatestSemiAutomaticResultWithNewAutomaticFeedback(programmingSubmission.getLatestResult().getId(), processedResult); // Adding back dropped submission diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java index 388821a2274f..f90950428499 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingSubmissionService.java @@ -272,7 +272,8 @@ private boolean isAllowedToSubmit(ProgrammingExerciseStudentParticipation partic private boolean isAllowedToSubmitForCourseExercise(ProgrammingExerciseStudentParticipation participation, ProgrammingSubmission programmingSubmission) { var dueDate = ExerciseDateService.getDueDate(participation); - if (dueDate.isEmpty() || participation.isTestRun()) { + // Without a due date or in the practice mode, the student can always submit + if (dueDate.isEmpty() || participation.isPracticeMode()) { return true; } return dueDate.get().plusSeconds(PROGRAMMING_GRACE_PERIOD_SECONDS).isAfter(programmingSubmission.getSubmissionDate()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleService.java index b856eac57890..8278387c1efe 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleService.java @@ -1,8 +1,8 @@ package de.tum.in.www1.artemis.service.scheduled; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,18 +57,19 @@ public DataExportScheduleService(DataExportRepository dataExportRepository, Data * Deleted will be all data exports that have a creation date older than seven days */ @Scheduled(cron = "${artemis.scheduling.data-export-creation-time: 0 0 4 * * *}") - public void createDataExportsAndDeleteOldOnes() { + public void createDataExportsAndDeleteOldOnes() throws InterruptedException { if (profileService.isDev()) { // do not execute this in a development environment // NOTE: if you want to test this locally, please comment it out, but do not commit the changes return; } - checkSecurityUtils(); log.info("Creating data exports and deleting old ones"); - Set successfulDataExports = new HashSet<>(); + Set successfulDataExports = Collections.synchronizedSet(new HashSet<>()); var dataExportsToBeCreated = dataExportRepository.findAllToBeCreated(); - dataExportsToBeCreated.forEach(dataExport -> createDataExport(dataExport, successfulDataExports)); + ExecutorService executor = Executors.newFixedThreadPool(10); + dataExportsToBeCreated.forEach(dataExport -> executor.execute(() -> createDataExport(dataExport, successfulDataExports))); + executor.shutdown(); var dataExportsToBeDeleted = dataExportRepository.findAllToBeDeleted(); dataExportsToBeDeleted.forEach(this::deleteDataExport); Optional admin = userService.findInternalAdminUser(); @@ -76,6 +77,12 @@ public void createDataExportsAndDeleteOldOnes() { log.warn("No internal admin user found. Cannot send email to admin about successful creation of data exports."); return; } + // This job runs at 4 am by default and the next scheduled job runs at 5 am, so we should allow 60 minutes for the creation. + // If the creation doesn't finish within 60 minutes, all pending exports will be picked up when the job runs the next time. + if (!executor.awaitTermination(60, java.util.concurrent.TimeUnit.MINUTES)) { + log.info("Not all pending data exports could be created within 60 minutes."); + executor.shutdownNow(); + } if (!successfulDataExports.isEmpty()) { mailService.sendSuccessfulDataExportsEmailToAdmin(admin.get(), successfulDataExports); } @@ -87,6 +94,7 @@ public void createDataExportsAndDeleteOldOnes() { * @param dataExport the data export to be created */ private void createDataExport(DataExport dataExport, Set successfulDataExports) { + checkSecurityUtils(); log.info("Creating data export for {}", dataExport.getUser().getLogin()); var successful = dataExportCreationService.createDataExport(dataExport); if (successful) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/DataExportResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/DataExportResource.java index be14fd560b6e..366a915199ef 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/DataExportResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/DataExportResource.java @@ -5,14 +5,12 @@ import java.time.Duration; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.Comparator; import javax.validation.constraints.NotNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import de.tum.in.www1.artemis.domain.DataExport; @@ -27,7 +25,8 @@ import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; /** - * REST controller for data exports + * REST controller for data exports. + * It contains the REST endpoints for requesting, downloading data exports and checking if a data export can be requested or downloaded. */ @RestController @RequestMapping("api/") @@ -50,9 +49,9 @@ public DataExportResource(@Value("${artemis.data-export.days-between-data-export } /** - * Request a data export for the given user + * POST /data-exports: Request a data export for the currently logged-in user. * - * @return the data export object + * @return a DTO containing the id of the data export that was created, its state and when it was requested */ @PostMapping("data-exports") @EnforceAtLeastStudent @@ -65,16 +64,19 @@ public RequestDataExportDTO requestDataExport() { /** * Checks if the user can request a new data export. + *

+ * This is the case if the user has not requested a data export yet or if the last data export was created more than DAYS_BETWEEN_DATA_EXPORTS days ago. * * @return true if the user can request a new data export, false otherwise */ private boolean canRequestDataExport() { var user = userRepository.getUser(); - var dataExports = dataExportRepository.findAllDataExportsByUserId(user.getId()); + var dataExports = dataExportRepository.findAllDataExportsByUserIdOrderByRequestDateDesc(user.getId()); if (dataExports.isEmpty()) { return true; } - var latestDataExport = dataExports.stream().max(Comparator.comparing(DataExport::getCreatedDate)).get(); + // because we order by request date desc, the first data export is the latest one + var latestDataExport = dataExports.get(0); var olderThanDaysBetweenDataExports = Duration.between(latestDataExport.getCreatedDate().atZone(ZoneId.systemDefault()), ZonedDateTime.now()) .toDays() >= DAYS_BETWEEN_DATA_EXPORTS; @@ -82,7 +84,12 @@ private boolean canRequestDataExport() { } /** - * Download the data export for the given user + * GET /data-exports/{dataExportId}: Download the data export for the given id. + *

+ * We check if the user is the owner of the data export and if the data export can be downloaded. + * If this is the case, we return a resource containing the data export zip file. + * The file name is set to the name of the zip file. + * The content disposition header is set to attachment so that the browser will download the file instead of displaying it. * * @param dataExportId the id of the data export to download * @return A resource containing the data export zip file @@ -92,20 +99,18 @@ private boolean canRequestDataExport() { public ResponseEntity downloadDataExport(@PathVariable long dataExportId) { DataExport dataExport = dataExportRepository.findByIdElseThrow(dataExportId); currentlyLoggedInUserIsOwnerOfDataExportElseThrow(dataExport); - checkDataExportCanBeDownloaded(dataExport); + dataExportService.checkDataExportCanBeDownloadedElseThrow(dataExport); Resource resource = dataExportService.downloadDataExport(dataExport); File finalZipFile = Path.of(dataExport.getFilePath()).toFile(); - return ResponseEntity.ok().contentLength(finalZipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", finalZipFile.getName()).body(resource); - } - - private void checkDataExportCanBeDownloaded(DataExport dataExport) { - if (!dataExport.getDataExportState().isDownloadable()) { - throw new AccessForbiddenException("Data export has either not been created or already been deleted"); - } + ContentDisposition contentDisposition = ContentDisposition.builder("attachment").filename(finalZipFile.getName()).build(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(contentDisposition); + return ResponseEntity.ok().contentLength(finalZipFile.length()).headers(headers).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", finalZipFile.getName()) + .body(resource); } /** - * checks if the currently logged-in user is the owner of the given data export + * Checks if the currently logged-in user is the owner of the given data export. * * @param dataExport the data export that needs to be checked * @throws AccessForbiddenException if logged-in user isn't the owner of the data export @@ -117,7 +122,7 @@ private void currentlyLoggedInUserIsOwnerOfDataExportElseThrow(@NotNull DataExpo } /** - * checks if the currently logged-in user is owner of the given data export + * Checks if the currently logged-in user is owner of the given data export. * * @param dataExport the data export that needs to be checked * @return true if the user is the owner of the data export, false otherwise @@ -132,7 +137,7 @@ private boolean currentlyLoggedInUserIsOwnerOfDataExport(DataExport dataExport) } /** - * Check if the user can request a data export + * GET /data-exports/can-request: Check if the logged-in user can request a data export. * * @return true if the user can request a data export, false otherwise */ @@ -143,9 +148,9 @@ public boolean canRequestExport() { } /** - * Check if the user can download any data export + * GET /data-exports/can-download: Check if the logged-in user can download any data export. * - * @return a data export DTO with the id of the export that can be downloaded or a DTO with a id of null if no export can be downloaded + * @return a data export DTO with the id of the export that can be downloaded or a DTO with an id of null if no export can be downloaded */ @GetMapping("data-exports/can-download") @EnforceAtLeastStudent @@ -154,7 +159,7 @@ public DataExportDTO canDownloadAnyExport() { } /** - * Check if the user can download a specific data export + * GET /data-exports/{dataExportId}/can-download: Check if the logged-in user can download the data export with the given id. * * @param dataExportId the id of the data export that should be checked * @return true if the user can download the data export, false otherwise @@ -170,8 +175,8 @@ public boolean canDownloadSpecificExport(@PathVariable long dataExportId) { * * @param dataExportId the id of the data export to check * @return true if the data export can be downloaded, false otherwise - * @throws de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException if the data export or the user could not be found - * @throws de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException if the user is not allowed to download the data export + * @throws EntityNotFoundException if the data export or the user could not be found + * @throws AccessForbiddenException if the user is not allowed to download the data export */ private boolean canDownloadSpecificDataExport(long dataExportId) throws EntityNotFoundException, AccessForbiddenException { var dataExport = dataExportRepository.findByIdElseThrow(dataExportId); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java index 97dc320c2573..f5ce2422a5dc 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java @@ -290,7 +290,7 @@ public ResponseEntity resumeParticipati // There is a second participation of that student in the exericse that is inactive/finished now Optional optionalOtherStudentParticipation = participationService.findOneByExerciseAndParticipantAnyStateAndTestRun(programmingExercise, user, - !participation.isTestRun()); + !participation.isPracticeMode()); if (optionalOtherStudentParticipation.isPresent()) { StudentParticipation otherParticipation = optionalOtherStudentParticipation.get(); if (participation.getInitializationState() == InitializationState.INACTIVE) { @@ -371,7 +371,7 @@ public ResponseEntity requestFeedback(@ private boolean isAllowedToParticipateInProgrammingExercise(ProgrammingExercise programmingExercise, @Nullable StudentParticipation participation) { if (participation != null) { // only regular participation before the due date; only practice run afterwards - return participation.isTestRun() == exerciseDateService.isAfterDueDate(participation); + return participation.isPracticeMode() == exerciseDateService.isAfterDueDate(participation); } else { return programmingExercise.getDueDate() == null || now().isBefore(programmingExercise.getDueDate()); @@ -424,7 +424,7 @@ public ResponseEntity updateParticipation(@PathVariable long exer Optional gradingScale = gradingScaleService.findGradingScaleByCourseId(participation.getExercise().getCourseViaExerciseGroupOrCourseMember().getId()); // Presentation Score is only valid for non practice participations - if (participation.isTestRun()) { + if (participation.isPracticeMode()) { throw new BadRequestAlertException("Presentation score is not allowed for practice participations", ENTITY_NAME, "presentationScoreInvalid"); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseParticipationResource.java index 19b31e24c79a..e7a545e69e6d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ProgrammingExerciseParticipationResource.java @@ -83,7 +83,7 @@ public ResponseEntity getParticipationW participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); // hide details that should not be shown to the students - resultService.filterSensitiveInformationIfNecessary(participation, participation.getResults()); + resultService.filterSensitiveInformationIfNecessary(participation, participation.getResults(), Optional.empty()); return ResponseEntity.ok(participation); } diff --git a/src/main/webapp/app/core/legal/data-export/data-export.component.html b/src/main/webapp/app/core/legal/data-export/data-export.component.html index 182a57931dd1..693f7d2fe7d6 100644 --- a/src/main/webapp/app/core/legal/data-export/data-export.component.html +++ b/src/main/webapp/app/core/legal/data-export/data-export.component.html @@ -24,7 +24,8 @@

id="download-data-export-btn" [btnSize]="ButtonSize.LARGE" [disabled]="!canDownload" - title="artemisApp.dataExport.download" + [tooltip]="'artemisApp.dataExport.download'" + [title]="'artemisApp.dataExport.download'" (onClick)="downloadDataExport()" > @@ -33,7 +34,7 @@

{{ 'artemisApp.dataExport.lastRequestDate' | artemisTranslate }} {{ dataExport?.createdDate | artemisDate: 'long-date' }}

-

+

{{ 'artemisApp.dataExport.nextRequestDate' | artemisTranslate }} {{ dataExport?.nextRequestDate | artemisDate: 'long-date' }}

diff --git a/src/main/webapp/app/core/legal/data-export/data-export.component.ts b/src/main/webapp/app/core/legal/data-export/data-export.component.ts index f1e34d48705f..37b5de87c4e7 100644 --- a/src/main/webapp/app/core/legal/data-export/data-export.component.ts +++ b/src/main/webapp/app/core/legal/data-export/data-export.component.ts @@ -4,9 +4,8 @@ import { Subject } from 'rxjs'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; import { DataExportService } from 'app/core/legal/data-export/data-export.service'; import { AccountService } from 'app/core/auth/account.service'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { HttpErrorResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; -import { downloadZipFileFromResponse } from 'app/shared/util/download.util'; import { DataExport, DataExportState } from 'app/entities/data-export.model'; import { ActivatedRoute } from '@angular/router'; import { convertDateFromServer } from 'app/utils/date.utils'; @@ -96,10 +95,7 @@ export class DataExportComponent implements OnInit { } downloadDataExport() { - this.dataExportService.downloadDataExport(this.dataExportId).subscribe((response: HttpResponse) => { - downloadZipFileFromResponse(response); - this.alertService.success('artemisApp.dataExport.downloadSuccess'); - }); + this.dataExportService.downloadDataExport(this.dataExportId); } requestExportForAnotherUser(login: string) { diff --git a/src/main/webapp/app/core/legal/data-export/data-export.service.ts b/src/main/webapp/app/core/legal/data-export/data-export.service.ts index 6f405a9034f0..f7b2ce5a8527 100644 --- a/src/main/webapp/app/core/legal/data-export/data-export.service.ts +++ b/src/main/webapp/app/core/legal/data-export/data-export.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { DataExport } from 'app/entities/data-export.model'; @@ -11,11 +11,9 @@ export class DataExportService { return this.http.post(`api/data-exports`, {}); } - downloadDataExport(dataExportId: number): Observable> { - return this.http.get(`api/data-exports/${dataExportId}`, { - observe: 'response', - responseType: 'blob', - }); + downloadDataExport(dataExportId: number) { + const url = `api/data-exports/${dataExportId}`; + window.open(url, '_blank'); } canRequestDataExport(): Observable { diff --git a/src/main/webapp/app/entities/participation/student-participation.model.ts b/src/main/webapp/app/entities/participation/student-participation.model.ts index 19fadd0e3a7d..b5920eb29974 100644 --- a/src/main/webapp/app/entities/participation/student-participation.model.ts +++ b/src/main/webapp/app/entities/participation/student-participation.model.ts @@ -11,3 +11,22 @@ export class StudentParticipation extends Participation { super(type ?? ParticipationType.STUDENT); } } + +/** + * Checks if the participation is used for practicing in a course exercise. This is the case if testRun is set to true + * @param studentParticipation the participation to check + */ +export function isPracticeMode(studentParticipation: StudentParticipation | undefined): boolean | undefined { + return studentParticipation?.testRun; +} + +/** + * Stores whether the participation is used for practicing in a course exercise. + * @param studentParticipation the participation that should store if it is used for practicing + * @param practiceMode true, if it is used for practicing + */ +export function setPracticeMode(studentParticipation: StudentParticipation | undefined, practiceMode: boolean) { + if (studentParticipation) { + studentParticipation.testRun = practiceMode; + } +} diff --git a/src/main/webapp/app/exam/participate/exam-participation.module.ts b/src/main/webapp/app/exam/participate/exam-participation.module.ts index d57cef937a87..3ba594471fd3 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.module.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.module.ts @@ -29,8 +29,8 @@ import { ArtemisHeaderExercisePageWithDetailsModule } from 'app/exercises/shared import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { FileUploadExamSubmissionComponent } from 'app/exam/participate/exercises/file-upload/file-upload-exam-submission.component'; import { ExamExerciseOverviewPageComponent } from 'app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component'; -import { ExamExerciseUpdateHighlighterComponent } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component'; import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; +import { ExamExerciseUpdateHighlighterModule } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module'; const ENTITY_STATES = [...examParticipationState]; @@ -55,6 +55,7 @@ const ENTITY_STATES = [...examParticipationState]; ArtemisParticipationSummaryModule, ArtemisMarkdownModule, SubmissionResultStatusModule, + ExamExerciseUpdateHighlighterModule, ], declarations: [ ExamParticipationComponent, @@ -67,7 +68,6 @@ const ENTITY_STATES = [...examParticipationState]; ExamNavigationBarComponent, ExamTimerComponent, ExamExerciseOverviewPageComponent, - ExamExerciseUpdateHighlighterComponent, ], }) export class ArtemisExamParticipationModule {} diff --git a/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component.ts b/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component.ts index 9938725296e7..0b607e7ec78b 100644 --- a/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component.ts +++ b/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component.ts @@ -1,7 +1,7 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Subscription } from 'rxjs'; import { ExamExerciseUpdateService } from 'app/exam/manage/exam-exercise-update.service'; -import { Exercise } from 'app/entities/exercise.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { Diff, DiffMatchPatch, DiffOperation } from 'diff-match-patch-typescript'; @Component({ @@ -9,13 +9,13 @@ import { Diff, DiffMatchPatch, DiffOperation } from 'diff-match-patch-typescript templateUrl: './exam-exercise-update-highlighter.component.html', styleUrls: ['./exam-exercise-update-highlighter.component.scss'], }) -export class ExamExerciseUpdateHighlighterComponent implements OnInit { +export class ExamExerciseUpdateHighlighterComponent implements OnInit, OnDestroy { subscriptionToLiveExamExerciseUpdates: Subscription; + themeSubscription: Subscription; previousProblemStatementUpdate: string; updatedProblemStatementWithHighlightedDifferences: string; updatedProblemStatement: string; showHighlightedDifferences = true; - @Input() exercise: Exercise; @Output() problemStatementUpdateEvent: EventEmitter = new EventEmitter(); @@ -28,6 +28,11 @@ export class ExamExerciseUpdateHighlighterComponent implements OnInit { }); } + ngOnDestroy(): void { + this.subscriptionToLiveExamExerciseUpdates?.unsubscribe(); + this.themeSubscription?.unsubscribe(); + } + /** * Switches the view between the new(updated) problem statement without the difference * with the view showing the difference between the new and old problem statement and vice versa. @@ -79,14 +84,49 @@ export class ExamExerciseUpdateHighlighterComponent implements OnInit { } this.previousProblemStatementUpdate = this.updatedProblemStatement; - + let removedDiagrams: string[] = []; + let diff: Diff[]; + if (this.exercise.type === ExerciseType.PROGRAMMING) { + const updatedProblemStatementAndRemovedDiagrams = this.removeAnyPlantUmlDiagramsInProblemStatement(this.updatedProblemStatement); + const outdatedProblemStatementAndRemovedDiagrams = this.removeAnyPlantUmlDiagramsInProblemStatement(outdatedProblemStatement); + const updatedProblemStatementWithoutDiagrams = updatedProblemStatementAndRemovedDiagrams.problemStatementWithoutPlantUmlDiagrams; + const outdatedProblemStatementWithoutDiagrams = outdatedProblemStatementAndRemovedDiagrams.problemStatementWithoutPlantUmlDiagrams; + removedDiagrams = updatedProblemStatementAndRemovedDiagrams.removedDiagrams; + diff = dmp.diff_main(outdatedProblemStatementWithoutDiagrams!, updatedProblemStatementWithoutDiagrams); + } else { + diff = dmp.diff_main(outdatedProblemStatement!, this.updatedProblemStatement); + } // finds the initial difference then cleans the text with added html & css elements - const diff = dmp.diff_main(outdatedProblemStatement!, this.updatedProblemStatement); dmp.diff_cleanupEfficiency(diff); this.updatedProblemStatementWithHighlightedDifferences = this.diffPrettyHtml(diff); + + if (this.exercise.type === ExerciseType.PROGRAMMING) { + this.addPlantUmlToProblemStatementWithDiffHighlightAgain(removedDiagrams); + } return this.updatedProblemStatementWithHighlightedDifferences; } + private addPlantUmlToProblemStatementWithDiffHighlightAgain(removedDiagrams: string[]) { + removedDiagrams.forEach((text) => { + this.updatedProblemStatementWithHighlightedDifferences = this.updatedProblemStatementWithHighlightedDifferences.replace('@startuml', '@startuml\n' + text + '\n'); + }); + } + + private removeAnyPlantUmlDiagramsInProblemStatement(problemStatement: string): { problemStatementWithoutPlantUmlDiagrams: string; removedDiagrams: string[] } { + // Regular expression to match content between @startuml and @enduml + const plantUmlSequenceRegex = /@startuml([\s\S]*?)@enduml/g; + const removedDiagrams: string[] = []; + const problemStatementWithoutPlantUmlDiagrams = problemStatement.replace(plantUmlSequenceRegex, (match, content) => { + removedDiagrams.push(content); + // we have to keep the markers, otherwise we cannot add the diagrams back later + return '@startuml\n@enduml'; + }); + return { + problemStatementWithoutPlantUmlDiagrams, + removedDiagrams, + }; + } + /** * Convert a diff array into a pretty HTML report. * Keeps markdown styling intact (not like the original method) @@ -98,17 +138,17 @@ export class ExamExerciseUpdateHighlighterComponent implements OnInit { * @param diffs Array of diff tuples. (from DiffMatchPatch) * @return the HTML representation as string with markdown intact. */ - diffPrettyHtml = function (diffs: Diff[]): string { + private diffPrettyHtml(diffs: Diff[]): string { const html: any[] = []; diffs.forEach((diff: Diff, index: number) => { const op = diffs[index][0]; // Operation (insert, delete, equal) const text = diffs[index][1]; // Text of change. switch (op) { case DiffOperation.DIFF_INSERT: - html[index] = '' + text + ''; + html[index] = '' + text + ''; break; case DiffOperation.DIFF_DELETE: - html[index] = '' + text + ''; + html[index] = '' + text + ''; break; case DiffOperation.DIFF_EQUAL: html[index] = text; @@ -116,5 +156,5 @@ export class ExamExerciseUpdateHighlighterComponent implements OnInit { } }); return html.join(''); - }; + } } diff --git a/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts b/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts new file mode 100644 index 000000000000..30e5255744b1 --- /dev/null +++ b/src/main/webapp/app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ExamExerciseUpdateHighlighterComponent } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component'; + +@NgModule({ + declarations: [ExamExerciseUpdateHighlighterComponent], + imports: [ArtemisSharedCommonModule], + exports: [ExamExerciseUpdateHighlighterComponent], +}) +export class ExamExerciseUpdateHighlighterModule {} diff --git a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-management.module.ts b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-management.module.ts index 0b704a7bc039..2e0795a8afb2 100644 --- a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-management.module.ts +++ b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-management.module.ts @@ -20,6 +20,7 @@ import { NonProgrammingExerciseDetailCommonActionsModule } from 'app/exercises/s import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercise-categories.module'; import { ExerciseTitleChannelNameModule } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.module'; +import { ExerciseUpdateNotificationModule } from 'app/exercises/shared/exercise-update-notification/exercise-update-notification.module'; @NgModule({ imports: [ @@ -41,6 +42,7 @@ import { ExerciseTitleChannelNameModule } from 'app/exercises/shared/exercise-ti ArtemisSharedComponentModule, ExerciseCategoriesModule, ExerciseTitleChannelNameModule, + ExerciseUpdateNotificationModule, ], declarations: [FileUploadExerciseComponent, FileUploadExerciseDetailComponent, FileUploadExerciseUpdateComponent], exports: [FileUploadExerciseComponent], diff --git a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.html b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.html index e4740153ec82..71b190057fb9 100644 --- a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.html +++ b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.html @@ -197,10 +197,7 @@

Assessment Instructions -
- - -
+
-
- - -
+
- + @@ -40,7 +40,7 @@ [buttonIcon]="faFolderOpen" class="open-code-editor" [jhiFeatureToggle]="FeatureToggle.ProgrammingExercises" - [buttonLabel]="'artemisApp.exerciseActions.' + (activeParticipation.testRun ? 'openPracticeCodeEditor' : 'openGradedCodeEditor') | artemisTranslate" + [buttonLabel]="'artemisApp.exerciseActions.' + (isPracticeMode ? 'openPracticeCodeEditor' : 'openGradedCodeEditor') | artemisTranslate" [buttonLoading]="loading" [smallButton]="smallButtons" [hideLabelMobile]="false" diff --git a/src/main/webapp/app/shared/components/open-code-editor-button/open-code-editor-button.component.ts b/src/main/webapp/app/shared/components/open-code-editor-button/open-code-editor-button.component.ts index 039ae8ddf45b..4871d89ba095 100644 --- a/src/main/webapp/app/shared/components/open-code-editor-button/open-code-editor-button.component.ts +++ b/src/main/webapp/app/shared/components/open-code-editor-button/open-code-editor-button.component.ts @@ -25,6 +25,7 @@ export class OpenCodeEditorButtonComponent implements OnChanges { courseAndExerciseNavigationUrl: string; activeParticipation: ProgrammingExerciseStudentParticipation; + isPracticeMode: boolean | undefined; // Icons faFolderOpen = faFolderOpen; @@ -38,6 +39,7 @@ export class OpenCodeEditorButtonComponent implements OnChanges { } switchPracticeMode() { - this.activeParticipation = this.participationService.getSpecificStudentParticipation(this.participations!, !this.activeParticipation.testRun)!; + this.isPracticeMode = !this.isPracticeMode; + this.activeParticipation = this.participationService.getSpecificStudentParticipation(this.participations!, this.isPracticeMode)!; } } diff --git a/src/main/webapp/i18n/de/dataExport.json b/src/main/webapp/i18n/de/dataExport.json index 6af830799251..3e9b2ed36c56 100644 --- a/src/main/webapp/i18n/de/dataExport.json +++ b/src/main/webapp/i18n/de/dataExport.json @@ -9,7 +9,6 @@ "download": "Datenexport herunterladen", "requestSuccess": "Datenexport erfolgreich angefordert", "requestForUserSuccess": "Datenexport erfolgreich angefordert für Nutzer {{ login }}", - "downloadSuccess": "Datenexport erfolgreich heruntergeladen", "typeLoginToConfirm": "Bitte gib deine Login-Kennung ein, um den Datenexport zu bestätigen", "confirmationQuestion": "Willst du wirklich den Datenexport anfordern?", "confirmationHeader": "Datenexport anfordern", diff --git a/src/main/webapp/i18n/en/dataExport.json b/src/main/webapp/i18n/en/dataExport.json index 30a96557501a..b596791ebcf1 100644 --- a/src/main/webapp/i18n/en/dataExport.json +++ b/src/main/webapp/i18n/en/dataExport.json @@ -9,7 +9,6 @@ "download": "Download data export", "requestSuccess": "Successfully requested data export", "requestForUserSuccess": "Successfully requested data export for {{ login }}", - "downloadSuccess": "Successfully downloaded data export", "typeLoginToConfirm": "Please enter your login to confirm the data export request", "typeUserLoginToConfirm": "Please enter the login of the user to confirm the data export request", "confirmationQuestion": "Do you really want to request a data export?", diff --git a/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java index 1348aad63fa2..7bb99c70f786 100644 --- a/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/FileIntegrationTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.stream.Collectors; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; @@ -422,7 +423,7 @@ private PDDocument retrieveMergeResult(Lecture lecture) throws Exception { byte[] receivedFile = request.get("/api/files/attachments/lecture/" + lecture.getId() + "/merge-pdf", HttpStatus.OK, byte[].class); assertThat(receivedFile).isNotEmpty(); - return PDDocument.load(receivedFile); + return Loader.loadPDF(receivedFile); } private Lecture createLectureWithLectureUnits() throws Exception { diff --git a/src/test/java/de/tum/in/www1/artemis/assessment/GradingScaleUtilService.java b/src/test/java/de/tum/in/www1/artemis/assessment/GradingScaleUtilService.java index 813b99b2d235..27fb902e41f5 100644 --- a/src/test/java/de/tum/in/www1/artemis/assessment/GradingScaleUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/assessment/GradingScaleUtilService.java @@ -8,12 +8,15 @@ import javax.validation.constraints.NotNull; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.ResourceUtils; import com.opencsv.CSVReader; import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.repository.GradingScaleRepository; /** * Service responsible for initializing the database with specific testdata related to grading for use in integration tests. @@ -21,6 +24,9 @@ @Service public class GradingScaleUtilService { + @Autowired + private GradingScaleRepository gradingScaleRepository; + @NotNull public Set generateGradeStepSet(GradingScale gradingScale, boolean valid) { GradeStep gradeStep1 = new GradeStep(); @@ -64,6 +70,24 @@ public GradingScale generateGradingScale(int gradeStepCount, double[] intervals, return gradingScale; } + /** + * Generates a grading scale with the given parameters and saves it to the database. + * + * @param gradeStepCount The number of grade steps to generate. + * @param intervals The intervals to use for the grade steps. The length of this array must be gradeStepCount + 1. + * @param lowerBoundInclusivity Whether the lower bound of the first grade step should be inclusive. + * @param firstPassingIndex The index of the first passing grade step. + * @param gradeNames The names of the grade steps. + * @param exam The exam to which the grading scale belongs. + * @return The generated and saved grading scale. + */ + public GradingScale generateAndSaveGradingScale(int gradeStepCount, double[] intervals, boolean lowerBoundInclusivity, int firstPassingIndex, Optional gradeNames, + Exam exam) { + GradingScale gradingScale = generateGradingScale(gradeStepCount, intervals, lowerBoundInclusivity, firstPassingIndex, gradeNames); + gradingScale.setExam(exam); + return gradingScaleRepository.save(gradingScale); + } + public GradingScale generateGradingScale(int gradeStepCount, double[] intervals, boolean lowerBoundInclusivity, int firstPassingIndex, Optional gradeNames) { if (gradeStepCount != intervals.length - 1 || firstPassingIndex >= gradeStepCount || firstPassingIndex < 0) { fail("Invalid grading scale parameters"); diff --git a/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java index e071bf93380e..3dfa4d907083 100644 --- a/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/assessment/ResultServiceIntegrationTest.java @@ -238,7 +238,7 @@ void setProgrammingExerciseResultRated(boolean shouldBeRated, ZonedDateTime buil @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testTestRunsNonRated() { - programmingExerciseStudentParticipation.setTestRun(true); + programmingExerciseStudentParticipation.setPracticeMode(true); programmingExerciseStudentParticipation = programmingExerciseStudentParticipationRepository.save(programmingExerciseStudentParticipation); var submission = (ProgrammingSubmission) new ProgrammingSubmission().commitHash("abc").type(SubmissionType.MANUAL).submitted(true); diff --git a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java index bd77cb71ecaa..ed857f5a52ee 100644 --- a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java @@ -946,7 +946,7 @@ public void testGetCoursesForDashboardPracticeRepositories() throws Exception { programmingExerciseUtilService.addProgrammingSubmissionToResultAndParticipation(gradedResult, gradedParticipation, "asdf"); StudentParticipation practiceParticipation = ParticipationFactory.generateProgrammingExerciseStudentParticipation(InitializationState.INITIALIZED, programmingExercise, student1); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); participationRepository.save(practiceParticipation); Result practiceResult = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, ZonedDateTime.now().minusHours(1), practiceParticipation); practiceResult.setRated(false); diff --git a/src/test/java/de/tum/in/www1/artemis/course/CourseUtilService.java b/src/test/java/de/tum/in/www1/artemis/course/CourseUtilService.java index 79a63cc363a6..cc68fa3725bf 100644 --- a/src/test/java/de/tum/in/www1/artemis/course/CourseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/course/CourseUtilService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.assessment.ComplaintUtilService; +import de.tum.in.www1.artemis.assessment.GradingScaleUtilService; import de.tum.in.www1.artemis.competency.CompetencyUtilService; import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.domain.enumeration.*; @@ -138,6 +139,9 @@ public class CourseUtilService { @Autowired private ComplaintUtilService complaintUtilService; + @Autowired + private GradingScaleUtilService gradingScaleUtilService; + public Course createCourse() { return createCourse(null); } @@ -286,7 +290,7 @@ public List createCoursesWithExercisesAndLectures(String prefix, boolean StudentParticipation participation3 = ParticipationFactory.generateStudentParticipation(InitializationState.UNINITIALIZED, modelingExercise, user); StudentParticipation participation4 = ParticipationFactory.generateProgrammingExerciseStudentParticipation(InitializationState.FINISHED, programmingExercise, user); StudentParticipation participation5 = ParticipationFactory.generateProgrammingExerciseStudentParticipation(InitializationState.INITIALIZED, programmingExercise, user); - participation5.setTestRun(true); + participation5.setPracticeMode(true); Submission modelingSubmission1 = ParticipationFactory.generateModelingSubmission("model1", true); Submission modelingSubmission2 = ParticipationFactory.generateModelingSubmission("model2", true); @@ -878,11 +882,12 @@ public void updateCourseGroups(String userPrefix, Course course, String suffix) courseRepo.save(course); } - public Course createCourseWithCustomStudentUserGroupWithExamAndExerciseGroupAndExercises(User user, String studentGroupName, String shortName, boolean withProgrammingExercise, - boolean withAllQuizQuestionTypes) { + public Course createCourseWithCustomStudentUserGroupWithExamAndExerciseGroupAndExercisesAndGradingScale(User user, String studentGroupName, String shortName, + boolean withProgrammingExercise, boolean withAllQuizQuestionTypes) { Course course = createCourseWithCustomStudentGroupName(studentGroupName, shortName); Exam exam = examUtilService.addExam(course, user, ZonedDateTime.now().minusMinutes(10), ZonedDateTime.now().minusMinutes(5), ZonedDateTime.now().minusMinutes(2), ZonedDateTime.now().minusMinutes(1)); + gradingScaleUtilService.generateAndSaveGradingScale(2, new double[] { 0, 50, 100 }, true, 1, Optional.empty(), exam); course.addExam(exam); examUtilService.addExerciseGroupsAndExercisesToExam(exam, withProgrammingExercise, withAllQuizQuestionTypes); return courseRepo.save(course); diff --git a/src/test/java/de/tum/in/www1/artemis/dataexport/DataExportResourceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/dataexport/DataExportResourceIntegrationTest.java index f770be20d28d..23be5b9f4bba 100644 --- a/src/test/java/de/tum/in/www1/artemis/dataexport/DataExportResourceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/dataexport/DataExportResourceIntegrationTest.java @@ -156,17 +156,28 @@ void testCanDownload_noDataExportInCorrectState_dataExportIdNull(DataExportState @ParameterizedTest @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - @EnumSource(value = DataExportState.class, names = { "IN_CREATION", "DOWNLOADED" }) - void testCanDownload_dataExportInCorrectState_dataExportIdReturned() throws Exception { + @EnumSource(value = DataExportState.class, names = { "EMAIL_SENT", "DOWNLOADED" }) + void testCanDownload_dataExportInCorrectState_dataExportIdReturned(DataExportState state) throws Exception { dataExportRepository.deleteAll(); DataExport dataExport = new DataExport(); - dataExport.setDataExportState(DataExportState.EMAIL_SENT); + dataExport.setDataExportState(state); dataExport.setUser(userUtilService.getUserByLogin(TEST_PREFIX + "student1")); dataExport = dataExportRepository.save(dataExport); var dataExportToDownload = request.get("/api/data-exports/can-download", HttpStatus.OK, DataExportDTO.class); assertThat(dataExportToDownload.id()).isEqualTo(dataExport.getId()); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testCanDownload_multipleDataExportsInCorrectState_returnsLatest() throws Exception { + dataExportRepository.deleteAll(); + initDataExport(DataExportState.DOWNLOADED); + var expectedDataExport = initDataExport(DataExportState.EMAIL_SENT); + var dataExportToDownload = request.get("/api/data-exports/can-download", HttpStatus.OK, DataExportDTO.class); + assertThat(dataExportToDownload.id()).isEqualTo(expectedDataExport.getId()); + assertThat(dataExportToDownload.dataExportState()).isEqualTo(DataExportState.EMAIL_SENT); + } + @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testCanDownload_noDataExport_dataExportIdNull() throws Exception { diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamFactory.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamFactory.java index aa1ca83f67c8..35ec60736223 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamFactory.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamFactory.java @@ -3,11 +3,12 @@ import static java.time.ZonedDateTime.now; import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.Set; import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.exam.Exam; -import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; -import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.domain.exam.*; +import de.tum.in.www1.artemis.web.rest.dto.*; /** * Factory for creating Exams and related objects. @@ -200,4 +201,28 @@ public static Exam generateExamWithExerciseGroup(Course course, boolean mandator return exam; } + + /** + * creates exam session DTOs + * + * @param session1 firts exam session + * @param session2 second exam session + * @return set of exam session DTOs + */ + public static Set createExpectedExamSessionDTOs(ExamSession session1, ExamSession session2) { + var expectedDTOs = new HashSet(); + var firstStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session1.getStudentExam().getId(), + new ExamWithIdAndCourseDTO(session1.getStudentExam().getExam().getId(), new CourseWithIdDTO(session1.getStudentExam().getExam().getCourse().getId())), + new UserWithIdAndLoginDTO(session1.getStudentExam().getUser().getId(), session1.getStudentExam().getUser().getLogin())); + var secondStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session2.getStudentExam().getId(), + new ExamWithIdAndCourseDTO(session2.getStudentExam().getExam().getId(), new CourseWithIdDTO(session2.getStudentExam().getExam().getCourse().getId())), + new UserWithIdAndLoginDTO(session2.getStudentExam().getUser().getId(), session2.getStudentExam().getUser().getLogin())); + var firstExamSessionDTO = new ExamSessionDTO(session1.getId(), session1.getBrowserFingerprintHash(), session1.getIpAddress(), session1.getSuspiciousReasons(), + session1.getCreatedDate(), firstStudentExamDTO); + var secondExamSessionDTO = new ExamSessionDTO(session2.getId(), session2.getBrowserFingerprintHash(), session2.getIpAddress(), session2.getSuspiciousReasons(), + session2.getCreatedDate(), secondStudentExamDTO); + expectedDTOs.add(firstExamSessionDTO); + expectedDTOs.add(secondExamSessionDTO); + return expectedDTOs; + } } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index b325756679bd..f1016817f6aa 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -5,95 +5,60 @@ import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.*; import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.util.LinkedMultiValueMap; -import com.fasterxml.jackson.databind.ObjectMapper; - import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; -import de.tum.in.www1.artemis.assessment.GradingScaleUtilService; -import de.tum.in.www1.artemis.bonus.BonusFactory; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.enumeration.*; +import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; import de.tum.in.www1.artemis.domain.exam.*; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; -import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; import de.tum.in.www1.artemis.domain.modeling.ModelingSubmission; -import de.tum.in.www1.artemis.domain.participation.*; -import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismCase; -import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismVerdict; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.quiz.QuizExercise; import de.tum.in.www1.artemis.exercise.ExerciseUtilService; -import de.tum.in.www1.artemis.exercise.modelingexercise.ModelingExerciseFactory; import de.tum.in.www1.artemis.exercise.modelingexercise.ModelingExerciseUtilService; -import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseFactory; -import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseTestService; -import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseFactory; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseFactory; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; -import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; -import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; -import de.tum.in.www1.artemis.security.SecurityUtils; -import de.tum.in.www1.artemis.service.QuizSubmissionService; -import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlRepositoryPermission; import de.tum.in.www1.artemis.service.dto.StudentDTO; -import de.tum.in.www1.artemis.service.exam.*; -import de.tum.in.www1.artemis.service.ldap.LdapUserDto; +import de.tum.in.www1.artemis.service.exam.ExamAccessService; +import de.tum.in.www1.artemis.service.exam.ExamDateService; +import de.tum.in.www1.artemis.service.exam.ExamService; import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; import de.tum.in.www1.artemis.service.user.PasswordService; -import de.tum.in.www1.artemis.team.TeamUtilService; import de.tum.in.www1.artemis.user.UserFactory; import de.tum.in.www1.artemis.user.UserUtilService; -import de.tum.in.www1.artemis.util.*; +import de.tum.in.www1.artemis.util.PageableSearchUtilService; +import de.tum.in.www1.artemis.util.ZipFileTestUtilService; import de.tum.in.www1.artemis.web.rest.dto.*; -import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { private static final String TEST_PREFIX = "examintegration"; - public static final String STUDENT_111 = TEST_PREFIX + "student111"; - - private final Logger log = LoggerFactory.getLogger(getClass()); - @Autowired private QuizExerciseRepository quizExerciseRepository; - @Autowired - private QuizSubmissionRepository quizSubmissionRepository; - - @Autowired - private QuizSubmissionService quizSubmissionService; - @Autowired private CourseRepository courseRepo; @@ -106,45 +71,21 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe @Autowired private ExamRepository examRepository; - @Autowired - private ExamUserRepository examUserRepository; - @Autowired private ExamService examService; - @Autowired - private StudentExamService studentExamService; - @Autowired private ExamDateService examDateService; - @Autowired - private ExamRegistrationService examRegistrationService; - - @Autowired - private ExerciseGroupRepository exerciseGroupRepository; - @Autowired private StudentExamRepository studentExamRepository; - @Autowired - private ProgrammingExerciseRepository programmingExerciseRepository; - @Autowired private StudentParticipationRepository studentParticipationRepository; @Autowired private SubmissionRepository submissionRepository; - @Autowired - private ResultRepository resultRepository; - - @Autowired - private ParticipationTestRepository participationTestRepository; - - @Autowired - private GradingScaleRepository gradingScaleRepository; - @Autowired private PasswordService passwordService; @@ -154,24 +95,6 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe @Autowired private ExamAccessService examAccessService; - @Autowired - private TeamRepository teamRepository; - - @Autowired - private BonusRepository bonusRepository; - - @Autowired - private PlagiarismCaseRepository plagiarismCaseRepository; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private ParticipantScoreRepository participantScoreRepository; - - @Autowired - private ProgrammingExerciseTestService programmingExerciseTestService; - @Autowired private ChannelRepository channelRepository; @@ -187,24 +110,12 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe @Autowired private TextExerciseUtilService textExerciseUtilService; - @Autowired - private ProgrammingExerciseUtilService programmingExerciseUtilService; - @Autowired private ModelingExerciseUtilService modelingExerciseUtilService; @Autowired private ExerciseUtilService exerciseUtilService; - @Autowired - private ParticipationUtilService participationUtilService; - - @Autowired - private TeamUtilService teamUtilService; - - @Autowired - private GradingScaleUtilService gradingScaleUtilService; - @Autowired private PageableSearchUtilService pageableSearchUtilService; @@ -218,13 +129,9 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe private Exam exam2; - private Exam testExam1; - - private static final int NUMBER_OF_STUDENTS = 3; + private static final int NUMBER_OF_STUDENTS = 2; - private static final int NUMBER_OF_TUTORS = 2; - - private final List studentRepos = new ArrayList<>(); + private static final int NUMBER_OF_TUTORS = 1; private User student1; @@ -256,8 +163,6 @@ void initTestCase() { examUtilService.addExamChannel(exam1, "exam1 channel"); exam2 = examUtilService.addExamWithExerciseGroup(course1, true); examUtilService.addExamChannel(exam2, "exam2 channel"); - testExam1 = examUtilService.addTestExam(course1); - examUtilService.addStudentExamForTestExam(testExam1, student1); bitbucketRequestMockProvider.enableMockingOfRequests(); @@ -265,210 +170,9 @@ void initTestCase() { participantScoreScheduleService.activate(); } - @AfterEach - void tearDown() throws Exception { - bitbucketRequestMockProvider.reset(); - bambooRequestMockProvider.reset(); - if (programmingExerciseTestService.exerciseRepo != null) { - programmingExerciseTestService.tearDown(); - } - - for (var repo : studentRepos) { - repo.resetLocalRepo(); - } - - ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 500; - participantScoreScheduleService.shutdown(); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRegisterUserInExam_addedToCourseStudentsGroup() throws Exception { - User student42 = userUtilService.getUserByLogin(TEST_PREFIX + "student42"); - jiraRequestMockProvider.enableMockingOfRequests(); - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - bitbucketRequestMockProvider.mockUpdateUserDetails(student42.getLogin(), student42.getEmail(), student42.getName()); - bitbucketRequestMockProvider.mockAddUserToGroups(); - - Set studentsInCourseBefore = userRepo.findAllInGroupWithAuthorities(course1.getStudentGroupName()); - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/students/" + TEST_PREFIX + "student42", null, HttpStatus.OK, null); - Set studentsInCourseAfter = userRepo.findAllInGroupWithAuthorities(course1.getStudentGroupName()); - studentsInCourseBefore.add(student42); - assertThat(studentsInCourseBefore).containsExactlyInAnyOrderElementsOf(studentsInCourseAfter); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAddStudentToExam_testExam() throws Exception { - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students/" + TEST_PREFIX + "student42", null, HttpStatus.BAD_REQUEST, - null); - } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRemoveStudentToExam_testExam() throws Exception { - request.delete("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students/" + TEST_PREFIX + "student42", HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRegisterUsersInExam() throws Exception { - jiraRequestMockProvider.enableMockingOfRequests(); - - var savedExam = examUtilService.addExam(course1); - - List registrationNumbers = Arrays.asList("1111111", "1111112", "1111113"); - List students = userUtilService.setRegistrationNumberOfStudents(registrationNumbers, TEST_PREFIX); - - User student1 = students.get(0); - User student2 = students.get(1); - User student3 = students.get(2); - - var registrationNumber3WithTypo = "1111113" + "0"; - var registrationNumber4WithTypo = "1111115" + "1"; - var registrationNumber99 = "1111199"; - var registrationNumber111 = "1111100"; - var emptyRegistrationNumber = ""; - - // mock the ldap service - doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(registrationNumber3WithTypo); - doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(emptyRegistrationNumber); - doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(registrationNumber4WithTypo); - - var ldapUser111Dto = new LdapUserDto().registrationNumber(registrationNumber111).firstName(STUDENT_111).lastName(STUDENT_111).username(STUDENT_111) - .email(STUDENT_111 + "@tum.de"); - doReturn(Optional.of(ldapUser111Dto)).when(ldapUserService).findByRegistrationNumber(registrationNumber111); - - // first mocked call is expected to add student 99 to the course student group - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - // second mocked call expected to create student 111 - jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser111Dto.getUsername(), ldapUser111Dto.getFirstName() + " " + ldapUser111Dto.getLastName(), - ldapUser111Dto.getEmail()); - // the last mocked call is expected to add student 111 to the course student group - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - - User student99 = userUtilService.createAndSaveUser("student99"); // not registered for the course - userUtilService.setRegistrationNumberOfUserAndSave("student99", registrationNumber99); - - bitbucketRequestMockProvider.mockUpdateUserDetails(student99.getLogin(), student99.getEmail(), student99.getName()); - bitbucketRequestMockProvider.mockAddUserToGroups(); - student99 = userRepo.findOneWithGroupsAndAuthoritiesByLogin("student99").orElseThrow(); - assertThat(student99.getGroups()).doesNotContain(course1.getStudentGroupName()); - - // Note: student111 is not yet a user of Artemis and should be retrieved from the LDAP - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/" + TEST_PREFIX + "student1", null, HttpStatus.OK, null); - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/nonExistingStudent", null, HttpStatus.NOT_FOUND, null); - - Exam storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); - ExamUser examUserStudent1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()).orElseThrow(); - assertThat(storedExam.getExamUsers()).containsExactly(examUserStudent1); - - request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK); - request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/nonExistingStudent", HttpStatus.NOT_FOUND); - storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); - assertThat(storedExam.getExamUsers()).isEmpty(); - - var studentDto1 = UserFactory.generateStudentDTOWithRegistrationNumber(student1.getRegistrationNumber()); - var studentDto2 = UserFactory.generateStudentDTOWithRegistrationNumber(student2.getRegistrationNumber()); - var studentDto3 = new StudentDTO(student3.getLogin(), null, null, registrationNumber3WithTypo, null); // explicit typo, should be a registration failure later - var studentDto4 = UserFactory.generateStudentDTOWithRegistrationNumber(registrationNumber4WithTypo); // explicit typo, should fall back to login name later - var studentDto10 = UserFactory.generateStudentDTOWithRegistrationNumber(null); // completely empty - - var studentDto99 = new StudentDTO(student99.getLogin(), null, null, registrationNumber99, null); - var studentDto111 = new StudentDTO(null, null, null, registrationNumber111, null); - - // Add a student with login but empty registration number - var studentsToRegister = List.of(studentDto1, studentDto2, studentDto3, studentDto4, studentDto99, studentDto111, studentDto10); - - // now we register all these students for the exam. - List registrationFailures = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students", - studentsToRegister, StudentDTO.class, HttpStatus.OK); - // all students get registered if they can be found in the LDAP - assertThat(registrationFailures).containsExactlyInAnyOrder(studentDto4, studentDto10); - - // TODO check audit events stored properly - - storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); - - // now a new user student101 should exist - var student111 = userUtilService.getUserByLogin(STUDENT_111); - - var examUser1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()).orElseThrow(); - var examUser2 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student2.getId()).orElseThrow(); - var examUser3 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student3.getId()).orElseThrow(); - var examUser99 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student99.getId()).orElseThrow(); - var examUser111 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student111.getId()).orElseThrow(); - - assertThat(storedExam.getExamUsers()).containsExactlyInAnyOrder(examUser1, examUser2, examUser3, examUser99, examUser111); - - for (var examUser : storedExam.getExamUsers()) { - // all registered users must have access to the course - var user = userRepo.findOneWithGroupsAndAuthoritiesByLogin(examUser.getUser().getLogin()).orElseThrow(); - assertThat(user.getGroups()).contains(course1.getStudentGroupName()); - } - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRegisterLDAPUsersInExam() throws Exception { - jiraRequestMockProvider.enableMockingOfRequests(); - var savedExam = examUtilService.addExam(course1); - String student100 = TEST_PREFIX + "student100"; - String student200 = TEST_PREFIX + "student200"; - String student300 = TEST_PREFIX + "student300"; - - // setup mocks - var ldapUser1Dto = new LdapUserDto().firstName(student100).lastName(student100).username(student100).registrationNumber("100000").email(student100 + "@tum.de"); - doReturn(Optional.of(ldapUser1Dto)).when(ldapUserService).findByUsername(student100); - jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser1Dto.getUsername(), ldapUser1Dto.getFirstName() + " " + ldapUser1Dto.getLastName(), null); - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - - var ldapUser2Dto = new LdapUserDto().firstName(student200).lastName(student200).username(student200).registrationNumber("200000").email(student200 + "@tum.de"); - doReturn(Optional.of(ldapUser2Dto)).when(ldapUserService).findByEmail(student200 + "@tum.de"); - jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser2Dto.getUsername(), ldapUser2Dto.getFirstName() + " " + ldapUser2Dto.getLastName(), null); - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - - var ldapUser3Dto = new LdapUserDto().firstName(student300).lastName(student300).username(student300).registrationNumber("3000000").email(student300 + "@tum.de"); - doReturn(Optional.of(ldapUser3Dto)).when(ldapUserService).findByRegistrationNumber("3000000"); - jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser3Dto.getUsername(), ldapUser3Dto.getFirstName() + " " + ldapUser3Dto.getLastName(), null); - jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); - - // user with login - StudentDTO dto1 = new StudentDTO(student100, student100, student100, null, null); - // user with email - StudentDTO dto2 = new StudentDTO(null, student200, student200, null, student200 + "@tum.de"); - // user with registration number - StudentDTO dto3 = new StudentDTO(null, student300, student300, "3000000", null); - // user without anything - StudentDTO dto4 = new StudentDTO(null, null, null, null, null); - - List registrationFailures = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students", - List.of(dto1, dto2, dto3, dto4), StudentDTO.class, HttpStatus.OK); - assertThat(registrationFailures).containsExactly(dto4); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAddStudentsToExam_testExam() throws Exception { - userUtilService.setRegistrationNumberOfUserAndSave(TEST_PREFIX + "student1", "1111111"); - - StudentDTO studentDto1 = UserFactory.generateStudentDTOWithRegistrationNumber("1111111"); - List studentDTOS = List.of(studentDto1); - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students", studentDTOS, StudentDTO.class, HttpStatus.FORBIDDEN); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") + @WithMockUser(username = TEST_PREFIX + "instructor10", roles = "INSTRUCTOR") void testGetAllActiveExams() throws Exception { - jiraRequestMockProvider.enableMockingOfRequests(); - jiraRequestMockProvider.mockCreateGroup(course10.getInstructorGroupName()); - jiraRequestMockProvider.mockAddUserToGroup(course10.getInstructorGroupName(), false); - - // switch to instructor10 - SecurityContextHolder.getContext().setAuthentication(SecurityUtils.makeAuthorizationObject(TEST_PREFIX + "instructor10")); // add additional active exam var exam3 = examUtilService.addExam(course10, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), ZonedDateTime.now().plusDays(3)); @@ -480,192 +184,6 @@ void testGetAllActiveExams() throws Exception { assertThat(activeExams).containsExactly(exam3); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRemoveAllStudentsFromExam_testExam() throws Exception { - request.delete("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students", HttpStatus.BAD_REQUEST); - } - - // TODO IMPORTANT test more complex exam configurations (mixed exercise type, more variants and more registered students) - @Nested - class ExamStartTest { - - private Set registeredUsers; - - private final List createdStudentExams = new ArrayList<>(); - - @BeforeEach - void init() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - - // registering users - User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); - registeredUsers = Set.of(student1, student2); - exam2.setExamUsers(Set.of(new ExamUser())); - // setting dates - exam2.setStartDate(now().plusHours(2)); - exam2.setEndDate(now().plusHours(3)); - exam2.setVisibleDate(now().plusHours(1)); - } - - @AfterEach - void cleanup() { - // Cleanup of Bidirectional Relationships - for (StudentExam studentExam : createdStudentExams) { - exam2.removeStudentExam(studentExam); - } - examRepository.save(exam2); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testStartExercisesWithTextExercise() throws Exception { - // creating exercise - ExerciseGroup exerciseGroup = exam2.getExerciseGroups().get(0); - - TextExercise textExercise = TextExerciseFactory.generateTextExerciseForExam(exerciseGroup); - exerciseGroup.addExercise(textExercise); - exerciseGroupRepository.save(exerciseGroup); - textExercise = exerciseRepo.save(textExercise); - - createStudentExams(textExercise); - - List studentParticipations = invokePrepareExerciseStart(); - - for (Participation participation : studentParticipations) { - assertThat(participation.getExercise()).isEqualTo(textExercise); - assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); - assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam2.getExerciseGroups().get(0)); - assertThat(participation.getSubmissions()).hasSize(1); - var textSubmission = (TextSubmission) participation.getSubmissions().iterator().next(); - assertThat(textSubmission.getText()).isNull(); - } - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testStartExercisesWithModelingExercise() throws Exception { - // creating exercise - ModelingExercise modelingExercise = ModelingExerciseFactory.generateModelingExerciseForExam(DiagramType.ClassDiagram, exam2.getExerciseGroups().get(0)); - exam2.getExerciseGroups().get(0).addExercise(modelingExercise); - exerciseGroupRepository.save(exam2.getExerciseGroups().get(0)); - modelingExercise = exerciseRepo.save(modelingExercise); - - createStudentExams(modelingExercise); - - List studentParticipations = invokePrepareExerciseStart(); - - for (Participation participation : studentParticipations) { - assertThat(participation.getExercise()).isEqualTo(modelingExercise); - assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); - assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam2.getExerciseGroups().get(0)); - assertThat(participation.getSubmissions()).hasSize(1); - var modelingSubmission = (ModelingSubmission) participation.getSubmissions().iterator().next(); - assertThat(modelingSubmission.getModel()).isNull(); - assertThat(modelingSubmission.getExplanationText()).isNull(); - } - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testStartExerciseWithProgrammingExercise() throws Exception { - bitbucketRequestMockProvider.enableMockingOfRequests(true); - bambooRequestMockProvider.enableMockingOfRequests(true); - - ProgrammingExercise programmingExercise = createProgrammingExercise(); - - participationUtilService.mockCreationOfExerciseParticipation(programmingExercise, versionControlService, continuousIntegrationService); - - createStudentExams(programmingExercise); - - var studentParticipations = invokePrepareExerciseStart(); - - for (Participation participation : studentParticipations) { - assertThat(participation.getExercise()).isEqualTo(programmingExercise); - assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); - assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam2.getExerciseGroups().get(0)); - // No initial submissions should be created for programming exercises - assertThat(participation.getSubmissions()).isEmpty(); - assertThat(((ProgrammingExerciseParticipation) participation).isLocked()).isTrue(); - verify(versionControlService, never()).configureRepository(eq(programmingExercise), (ProgrammingExerciseStudentParticipation) eq(participation), eq(true)); - } - } - - private static class ExamStartDateSource implements ArgumentsProvider { - - @Override - public Stream provideArguments(ExtensionContext context) { - return Stream.of(Arguments.of(ZonedDateTime.now().minusHours(1)), // after exam start - Arguments.arguments(ZonedDateTime.now().plusMinutes(3)) // before exam start but after pe unlock date - ); - } - } - - @ParameterizedTest(name = "{displayName} [{index}]") - @ArgumentsSource(ExamStartDateSource.class) - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testStartExerciseWithProgrammingExercise_participationUnlocked(ZonedDateTime startDate) throws Exception { - exam2.setVisibleDate(ZonedDateTime.now().minusHours(2)); - exam2.setStartDate(startDate); - examRepository.save(exam2); - - bitbucketRequestMockProvider.enableMockingOfRequests(true); - bambooRequestMockProvider.enableMockingOfRequests(true); - - ProgrammingExercise programmingExercise = createProgrammingExercise(); - - participationUtilService.mockCreationOfExerciseParticipation(programmingExercise, versionControlService, continuousIntegrationService); - - createStudentExams(programmingExercise); - - var studentParticipations = invokePrepareExerciseStart(); - - for (Participation participation : studentParticipations) { - assertThat(participation.getExercise()).isEqualTo(programmingExercise); - assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); - assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam2.getExerciseGroups().get(0)); - // No initial submissions should be created for programming exercises - assertThat(participation.getSubmissions()).isEmpty(); - ProgrammingExerciseStudentParticipation studentParticipation = (ProgrammingExerciseStudentParticipation) participation; - // The participation should not get locked if it gets created after the exam already started - assertThat(studentParticipation.isLocked()).isFalse(); - verify(versionControlService).addMemberToRepository(studentParticipation.getVcsRepositoryUrl(), studentParticipation.getStudent().orElseThrow(), - VersionControlRepositoryPermission.REPO_WRITE); - } - } - - private void createStudentExams(Exercise exercise) { - // creating student exams - for (User user : registeredUsers) { - StudentExam studentExam = new StudentExam(); - studentExam.addExercise(exercise); - studentExam.setUser(user); - exam2.addStudentExam(studentExam); - createdStudentExams.add(studentExamRepository.save(studentExam)); - } - - exam2 = examRepository.save(exam2); - } - - private ProgrammingExercise createProgrammingExercise() { - ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exam2.getExerciseGroups().get(0)); - programmingExercise = exerciseRepo.save(programmingExercise); - programmingExercise = programmingExerciseUtilService.addTemplateParticipationForProgrammingExercise(programmingExercise); - exam2.getExerciseGroups().get(0).addExercise(programmingExercise); - exerciseGroupRepository.save(exam2.getExerciseGroups().get(0)); - return programmingExercise; - } - - private List invokePrepareExerciseStart() throws Exception { - // invoke start exercises - int noGeneratedParticipations = prepareExerciseStart(exam2); - verify(gitService, times(getNumberOfProgrammingExercises(exam2))).combineAllCommitsOfRepositoryIntoOne(any()); - assertThat(noGeneratedParticipations).isEqualTo(exam2.getStudentExams().size()); - return participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam2.getId()); - } - - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGenerateStudentExams() throws Exception { @@ -684,49 +202,6 @@ void testGenerateStudentExams() throws Exception { assertThat(studentExam.getExam()).isEqualTo(exam); // TODO: check exercise configuration, each mandatory exercise group has to appear, one optional exercise should appear } - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGenerateStudentExamsCleanupOldParticipations() throws Exception { - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, NUMBER_OF_STUDENTS); - - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, - HttpStatus.OK); - - List studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); - assertThat(studentParticipations).isEmpty(); - - // invoke start exercises - studentExamService.startExercises(exam.getId()).join(); - - studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); - assertThat(studentParticipations).hasSize(12); - - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, - HttpStatus.OK); - - studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); - assertThat(studentParticipations).isEmpty(); - - // invoke start exercises - studentExamService.startExercises(exam.getId()).join(); - - studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); - assertThat(studentParticipations).hasSize(12); - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGenerateStudentExams_testExam() throws Exception { - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, - HttpStatus.BAD_REQUEST); } @Test @@ -778,14 +253,14 @@ void testGenerateStudentExamsTooManyMandatoryExerciseGroups_badRequest() throws @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGenerateMissingStudentExams() throws Exception { - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 2); + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 1); // Generate student exams List studentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, HttpStatus.OK); assertThat(studentExams).hasSize(exam.getExamUsers().size()); - // Register two new students - examUtilService.registerUsersForExamAndSaveExam(exam, TEST_PREFIX, 3, 3); + // Register one new students + examUtilService.registerUsersForExamAndSaveExam(exam, TEST_PREFIX, 2, 2); // Generate individual exams for the two missing students List missingStudentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-missing-student-exams", @@ -803,122 +278,6 @@ void testGenerateMissingStudentExams() throws Exception { studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); assertThat(studentExamsDB).hasSize(exam.getExamUsers().size()); - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGenerateMissingStudentExams_testExam() throws Exception { - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/generate-missing-student-exams", Optional.empty(), StudentExam.class, - HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testEvaluateQuizExercises_testExam() throws Exception { - request.post("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/student-exams/evaluate-quiz-exercises", Optional.empty(), HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - void testRemovingAllStudents() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); - - // Generate student exams - List studentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", - Optional.empty(), StudentExam.class, HttpStatus.OK); - assertThat(studentExams).hasSize(3); - assertThat(exam.getExamUsers()).hasSize(3); - - int numberOfGeneratedParticipations = prepareExerciseStart(exam); - assertThat(numberOfGeneratedParticipations).isEqualTo(12); - - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - // Fetch student exams - List studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExamsDB).hasSize(3); - List participationList = new ArrayList<>(); - Exercise[] exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); - for (Exercise value : exercises) { - participationList.addAll(studentParticipationRepository.findByExerciseId(value.getId())); - } - assertThat(participationList).hasSize(12); - - // TODO there should be some participation but no submissions unfortunately - // remove all students - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students", HttpStatus.OK); - - // Get the exam with all registered users - var params = new LinkedMultiValueMap(); - params.add("withStudents", "true"); - Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); - assertThat(storedExam.getExamUsers()).isEmpty(); - - // Fetch student exams - studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExamsDB).isEmpty(); - - // Fetch participations - exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); - participationList = new ArrayList<>(); - for (Exercise exercise : exercises) { - participationList.addAll(studentParticipationRepository.findByExerciseId(exercise.getId())); - } - assertThat(participationList).hasSize(12); - - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - void testRemovingAllStudentsAndParticipations() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); - - // Generate student exams - List studentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", - Optional.empty(), StudentExam.class, HttpStatus.OK); - assertThat(studentExams).hasSize(3); - assertThat(exam.getExamUsers()).hasSize(3); - - int numberOfGeneratedParticipations = prepareExerciseStart(exam); - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - assertThat(numberOfGeneratedParticipations).isEqualTo(12); - // Fetch student exams - List studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExamsDB).hasSize(3); - List participationList = new ArrayList<>(); - Exercise[] exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); - for (Exercise value : exercises) { - participationList.addAll(studentParticipationRepository.findByExerciseId(value.getId())); - } - assertThat(participationList).hasSize(12); - - // TODO there should be some participation but no submissions unfortunately - // remove all students - var paramsParticipations = new LinkedMultiValueMap(); - paramsParticipations.add("withParticipationsAndSubmission", "true"); - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students", HttpStatus.OK, paramsParticipations); - - // Get the exam with all registered users - var params = new LinkedMultiValueMap(); - params.add("withStudents", "true"); - Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); - assertThat(storedExam.getExamUsers()).isEmpty(); - - // Fetch student exams - studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExamsDB).isEmpty(); - - // Fetch participations - exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); - participationList = new ArrayList<>(); - for (Exercise exercise : exercises) { - participationList.addAll(studentParticipationRepository.findByExerciseId(exercise.getId())); - } - assertThat(participationList).isEmpty(); } @Test @@ -1023,92 +382,6 @@ private List createExamsWithInvalidDates(Course course) { return List.of(examA, examB, examC, examD, examE, examF, examG); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor() throws Exception { - // Test the creation of a test exam - Exam examA = ExamFactory.generateTestExam(course1); - URI examUri = request.post("/api/courses/" + course1.getId() + "/exams", examA, HttpStatus.CREATED); - Exam savedExam = request.get(String.valueOf(examUri), HttpStatus.OK, Exam.class); - - verify(examAccessService).checkCourseAccessForInstructorElseThrow(course1.getId()); - Channel channelFromDB = channelRepository.findChannelByExamId(savedExam.getId()); - assertThat(channelFromDB).isNotNull(); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor_withVisibleDateEqualsStartDate() throws Exception { - // Test the creation of a test exam, where visibleDate equals StartDate - Exam examB = ExamFactory.generateTestExam(course1); - examB.setVisibleDate(examB.getStartDate()); - request.post("/api/courses/" + course1.getId() + "/exams", examB, HttpStatus.CREATED); - - verify(examAccessService).checkCourseAccessForInstructorElseThrow(course1.getId()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor_badRequestWithWorkingTimeGreaterThanWorkingWindow() throws Exception { - // Test for bad request, where workingTime is greater than difference between StartDate and EndDate - Exam examC = ExamFactory.generateTestExam(course1); - examC.setWorkingTime(5000); - request.post("/api/courses/" + course1.getId() + "/exams", examC, HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor_badRequestWithWorkingTimeSetToZero() throws Exception { - // Test for bad request, if the working time is 0 - Exam examD = ExamFactory.generateTestExam(course1); - examD.setWorkingTime(0); - request.post("/api/courses/" + course1.getId() + "/exams", examD, HttpStatus.BAD_REQUEST); - - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor_testExam_CorrectionRoundViolation() throws Exception { - Exam exam = ExamFactory.generateTestExam(course1); - exam.setNumberOfCorrectionRoundsInExam(1); - request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testCreateTestExam_asInstructor_realExam_CorrectionRoundViolation() throws Exception { - Exam exam = ExamFactory.generateExam(course1); - exam.setNumberOfCorrectionRoundsInExam(0); - request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); - - exam.setNumberOfCorrectionRoundsInExam(3); - request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); - - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateTestExam_asInstructor_withExamModeChanged() throws Exception { - // The Exam-Mode should not be changeable with a PUT / update operation, a CONFLICT should be returned instead - // Case 1: test exam should be updated to real exam - Exam examA = ExamFactory.generateTestExam(course1); - Exam createdExamA = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams", examA, Exam.class, HttpStatus.CREATED); - createdExamA.setNumberOfCorrectionRoundsInExam(1); - createdExamA.setTestExam(false); - request.putWithResponseBody("/api/courses/" + course1.getId() + "/exams", createdExamA, Exam.class, HttpStatus.CONFLICT); - - // Case 2: real exam should be updated to test exam - Exam examB = ExamFactory.generateTestExam(course1); - examB.setNumberOfCorrectionRoundsInExam(1); - examB.setTestExam(false); - examB.setChannelName("examB"); - Exam createdExamB = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams", examB, Exam.class, HttpStatus.CREATED); - createdExamB.setTestExam(true); - createdExamB.setNumberOfCorrectionRoundsInExam(0); - request.putWithResponseBody("/api/courses/" + course1.getId() + "/exams", createdExamB, Exam.class, HttpStatus.CONFLICT); - - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testUpdateExam_asInstructor() throws Exception { @@ -1146,47 +419,6 @@ void testUpdateExam_asInstructor() throws Exception { verify(instanceMessageSendService, never()).sendProgrammingExerciseSchedule(any()); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateExam_reschedule_visibleAndStartDateChanged() throws Exception { - // Add a programming exercise to the exam and change the dates in order to invoke a rescheduling - var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); - var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); - - ZonedDateTime visibleDate = examWithProgrammingEx.getVisibleDate(); - ZonedDateTime startDate = examWithProgrammingEx.getStartDate(); - ZonedDateTime endDate = examWithProgrammingEx.getEndDate(); - examUtilService.setVisibleStartAndEndDateOfExam(examWithProgrammingEx, visibleDate.plusSeconds(1), startDate.plusSeconds(1), endDate); - - request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); - verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateExam_reschedule_visibleDateChanged() throws Exception { - var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); - var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); - examWithProgrammingEx.setVisibleDate(examWithProgrammingEx.getVisibleDate().plusSeconds(1)); - request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); - verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateExam_reschedule_startDateChanged() throws Exception { - var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); - var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); - - ZonedDateTime visibleDate = examWithProgrammingEx.getVisibleDate(); - ZonedDateTime startDate = examWithProgrammingEx.getStartDate(); - ZonedDateTime endDate = examWithProgrammingEx.getEndDate(); - examUtilService.setVisibleStartAndEndDateOfExam(examWithProgrammingEx, visibleDate, startDate.plusSeconds(1), endDate); - - request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); - verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testUpdateExam_rescheduleModeling_endDateChanged() throws Exception { @@ -1237,7 +469,6 @@ void testUpdateExam_exampleSolutionPublicationDateChanged() throws Exception { Exam fetchedExam = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examWithModelingEx.getId()); Exercise exercise = fetchedExam.getExerciseGroups().get(0).getExercises().stream().findFirst().orElseThrow(); assertThat(exercise.isExampleSolutionPublished()).isTrue(); - } @Test @@ -1285,7 +516,6 @@ void testGetExam_asInstructor_WithTestRunQuizExerciseSubmissions() throws Except @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamsForCourse_asInstructor() throws Exception { - var exams = request.getList("/api/courses/" + course1.getId() + "/exams", HttpStatus.OK, Exam.class); verify(examAccessService).checkCourseAccessForTeachingAssistantElseThrow(course1.getId()); @@ -1407,82 +637,6 @@ void testResetExamWithQuizExercise_asInstructor() throws Exception { assertThat(quizExercise.getDueDate()).isNull(); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteStudent() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - // Create an exam with registered students - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); - var student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); - - // Remove student1 from the exam - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK); - - // Get the exam with all registered users - var params = new LinkedMultiValueMap(); - params.add("withStudents", "true"); - Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); - - // Ensure that student1 was removed from the exam - var examUser = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()); - assertThat(examUser).isEmpty(); - assertThat(storedExam.getExamUsers()).hasSize(2); - - // Create individual student exams - List generatedStudentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", - Optional.empty(), StudentExam.class, HttpStatus.OK); - assertThat(generatedStudentExams).hasSize(storedExam.getExamUsers().size()); - - // Start the exam to create participations - prepareExerciseStart(exam); - - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - // Get the student exam of student2 - Optional optionalStudent1Exam = generatedStudentExams.stream().filter(studentExam -> studentExam.getUser().equals(student2)).findFirst(); - assertThat(optionalStudent1Exam.orElseThrow()).isNotNull(); - var studentExam2 = optionalStudent1Exam.get(); - - // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - // Remove student2 from the exam - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student2", HttpStatus.OK); - - // Get the exam with all registered users - params = new LinkedMultiValueMap<>(); - params.add("withStudents", "true"); - storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); - - // Ensure that student2 was removed from the exam - var examUser2 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student2.getId()); - assertThat(examUser2).isEmpty(); - assertThat(storedExam.getExamUsers()).hasSize(1); - - // Ensure that the student exam of student2 was deleted - List studentExams = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExams).hasSameSizeAs(storedExam.getExamUsers()).doesNotContain(studentExam2); - - // Ensure that the participations were not deleted - List participationsStudent2 = studentParticipationRepository - .findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student2.getId(), studentExam2.getExercises()); - assertThat(participationsStudent2).hasSize(studentExam2.getExercises().size()); - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteStudentForTestExam_badRequest() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - // Create an exam with registered students - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 1); - exam.setTestExam(true); - examRepository.save(exam); - - // Remove student1 from the exam - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.BAD_REQUEST); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamWithOptions() throws Exception { @@ -1524,60 +678,6 @@ void testGetExamWithOptions() throws Exception { }); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteStudentWithParticipationsAndSubmissions() throws Exception { - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - // Create an exam with registered students - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); - - // Create individual student exams - List generatedStudentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", - Optional.empty(), StudentExam.class, HttpStatus.OK); - - // Get the student exam of student1 - Optional optionalStudent1Exam = generatedStudentExams.stream().filter(studentExam -> studentExam.getUser().equals(student1)).findFirst(); - assertThat(optionalStudent1Exam.orElseThrow()).isNotNull(); - var studentExam1 = optionalStudent1Exam.get(); - - // Start the exam to create participations - prepareExerciseStart(exam); - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - List participationsStudent1 = studentParticipationRepository - .findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student1.getId(), studentExam1.getExercises()); - assertThat(participationsStudent1).hasSize(studentExam1.getExercises().size()); - - // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - - // Remove student1 from the exam and his participations - var params = new LinkedMultiValueMap(); - params.add("withParticipationsAndSubmission", "true"); - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK, params); - - // Get the exam with all registered users - params = new LinkedMultiValueMap<>(); - params.add("withStudents", "true"); - Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); - - // Ensure that student1 was removed from the exam - var examUser1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()); - assertThat(examUser1).isEmpty(); - assertThat(storedExam.getExamUsers()).hasSize(2); - - // Ensure that the student exam of student1 was deleted - List studentExams = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); - assertThat(studentExams).hasSameSizeAs(storedExam.getExamUsers()).doesNotContain(studentExam1); - - // Ensure that the participations of student1 were deleted - participationsStudent1 = studentParticipationRepository.findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student1.getId(), - studentExam1.getExercises()); - assertThat(participationsStudent1).isEmpty(); - - // Make sure delete also works if so many objects have been created before - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testGetExamForTestRunDashboard_forbidden() throws Exception { @@ -1587,242 +687,65 @@ void testGetExamForTestRunDashboard_forbidden() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamForTestRunDashboard_badRequest() throws Exception { - request.get("/api/courses/" + course2.getId() + "/exams/" + exam1.getId() + "/exam-for-test-run-assessment-dashboard", HttpStatus.BAD_REQUEST, Exam.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteExamWithOneTestRuns() throws Exception { - var exam = examUtilService.addExam(course1); - exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - request.delete("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteExamWithMultipleTestRuns() throws Exception { - bitbucketRequestMockProvider.enableMockingOfRequests(true); - bambooRequestMockProvider.enableMockingOfRequests(true); - - var exam = examUtilService.addExam(course1); - exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, true, true); - mockDeleteProgrammingExercise(exerciseUtilService.getFirstExerciseWithType(exam, ProgrammingExercise.class), Set.of(instructor)); - - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - assertThat(studentExamRepository.findAllTestRunsByExamId(exam.getId())).hasSize(3); - request.delete("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteCourseWithMultipleTestRuns() throws Exception { - var exam = examUtilService.addExam(course1); - exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - assertThat(studentExamRepository.findAllTestRunsByExamId(exam.getId())).hasSize(3); - request.delete("/api/courses/" + exam.getCourse().getId(), HttpStatus.OK); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGetExamForTestRunDashboard_ok() throws Exception { - var exam = examUtilService.addExam(course1); - exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); - examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); - exam = request.get("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId() + "/exam-for-test-run-assessment-dashboard", HttpStatus.OK, Exam.class); - assertThat(exam.getExerciseGroups().stream().flatMap(exerciseGroup -> exerciseGroup.getExercises().stream()).toList()).isNotEmpty(); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testDeleteStudentThatDoesNotExist() throws Exception { - Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 1); - request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/nonExistingStudent", HttpStatus.NOT_FOUND); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGetStudentExamForStart() throws Exception { - Exam exam = examUtilService.addActiveExamWithRegisteredUser(course1, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); - exam.setVisibleDate(ZonedDateTime.now().minusHours(1).minusMinutes(5)); - StudentExam response = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/start", HttpStatus.OK, StudentExam.class); - assertThat(response.getExam()).isEqualTo(exam); - verify(examAccessService).getExamInCourseElseThrow(course1.getId(), exam.getId()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testAddAllRegisteredUsersToExam() throws Exception { - Exam exam = examUtilService.addExam(course1); - Channel examChannel = examUtilService.addExamChannel(exam, "testchannel"); - int numberOfStudentsInCourse = userRepo.findAllInGroup(course1.getStudentGroupName()).size(); - - User student99 = userUtilService.createAndSaveUser(TEST_PREFIX + "student99"); // not registered for the course - student99.setGroups(Collections.singleton("tumuser")); - userUtilService.setRegistrationNumberOfUserAndSave(student99, "1234"); - assertThat(student99.getGroups()).contains(course1.getStudentGroupName()); - - var examUser99 = examUserRepository.findByExamIdAndUserId(exam.getId(), student99.getId()); - assertThat(examUser99).isEmpty(); - - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/register-course-students", null, HttpStatus.OK, null); - - exam = examRepository.findWithExamUsersById(exam.getId()).orElseThrow(); - examUser99 = examUserRepository.findByExamIdAndUserId(exam.getId(), student99.getId()); - - // the course students + our custom student99 - assertThat(exam.getExamUsers()).hasSize(numberOfStudentsInCourse + 1); - assertThat(exam.getExamUsers()).contains(examUser99.orElseThrow()); - verify(examAccessService).checkCourseAndExamAccessForInstructorElseThrow(course1.getId(), exam.getId()); - - Channel channelFromDB = channelRepository.findChannelByExamId(exam.getId()); - assertThat(channelFromDB).isNotNull(); - assertThat(channelFromDB.getExam()).isEqualTo(exam); - assertThat(channelFromDB.getName()).isEqualTo(examChannel.getName()); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRegisterCourseStudents_testExam() throws Exception { - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/register-course-students", null, HttpStatus.BAD_REQUEST, null); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateOrderOfExerciseGroups() throws Exception { - Exam exam = ExamFactory.generateExam(course1); - ExerciseGroup exerciseGroup1 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "first"); - ExerciseGroup exerciseGroup2 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "second"); - ExerciseGroup exerciseGroup3 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "third"); - examRepository.save(exam); - - TextExercise exercise1_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup1); - TextExercise exercise1_2 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup1); - TextExercise exercise2_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup2); - TextExercise exercise3_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); - TextExercise exercise3_2 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); - TextExercise exercise3_3 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); - - List orderedExerciseGroups = new ArrayList<>(List.of(exerciseGroup2, exerciseGroup3, exerciseGroup1)); - // Should save new order - request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.OK); - verify(examAccessService).checkCourseAndExamAccessForEditorElseThrow(course1.getId(), exam.getId()); - - List savedExerciseGroups = examRepository.findWithExerciseGroupsById(exam.getId()).orElseThrow().getExerciseGroups(); - assertThat(savedExerciseGroups.get(0).getTitle()).isEqualTo("second"); - assertThat(savedExerciseGroups.get(1).getTitle()).isEqualTo("third"); - assertThat(savedExerciseGroups.get(2).getTitle()).isEqualTo("first"); - - // Exercises should be preserved - Exam savedExam = examRepository.findWithExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); - ExerciseGroup savedExerciseGroup1 = savedExam.getExerciseGroups().get(2); - ExerciseGroup savedExerciseGroup2 = savedExam.getExerciseGroups().get(0); - ExerciseGroup savedExerciseGroup3 = savedExam.getExerciseGroups().get(1); - assertThat(savedExerciseGroup1.getExercises()).containsExactlyInAnyOrder(exercise1_1, exercise1_2); - assertThat(savedExerciseGroup2.getExercises()).containsExactlyInAnyOrder(exercise2_1); - assertThat(savedExerciseGroup3.getExercises()).containsExactlyInAnyOrder(exercise3_1, exercise3_2, exercise3_3); - - // Should fail with too many exercise groups - orderedExerciseGroups.add(exerciseGroup1); - request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); - - // Should fail with too few exercise groups - orderedExerciseGroups.remove(3); - orderedExerciseGroups.remove(2); - request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); - - // Should fail with different exercise group - orderedExerciseGroups = Arrays.asList(exerciseGroup2, exerciseGroup3, ExamFactory.generateExerciseGroup(true, exam)); - request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void lockAllRepositories_noInstructor() throws Exception { - request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/student-exams/lock-all-repositories", Optional.empty(), Integer.class, - HttpStatus.FORBIDDEN); + request.get("/api/courses/" + course2.getId() + "/exams/" + exam1.getId() + "/exam-for-test-run-assessment-dashboard", HttpStatus.BAD_REQUEST, Exam.class); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void lockAllRepositories() throws Exception { - Exam exam = examUtilService.addExamWithExerciseGroup(course1, true); - - Exam examWithExerciseGroups = examRepository.findWithExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); - ExerciseGroup exerciseGroup1 = examWithExerciseGroups.getExerciseGroups().get(0); - - ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); - programmingExerciseRepository.save(programmingExercise); - - ProgrammingExercise programmingExercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); - programmingExerciseRepository.save(programmingExercise2); + void testDeleteExamWithOneTestRuns() throws Exception { + var exam = examUtilService.addExam(course1); + exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + request.delete("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId(), HttpStatus.OK); + } - Integer numOfLockedExercises = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams/lock-all-repositories", - Optional.empty(), Integer.class, HttpStatus.OK); + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteExamWithMultipleTestRuns() throws Exception { + bitbucketRequestMockProvider.enableMockingOfRequests(true); + bambooRequestMockProvider.enableMockingOfRequests(true); - assertThat(numOfLockedExercises).isEqualTo(2); + var exam = examUtilService.addExam(course1); + exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, true, true); + mockDeleteProgrammingExercise(exerciseUtilService.getFirstExerciseWithType(exam, ProgrammingExercise.class), Set.of(instructor)); - verify(programmingExerciseScheduleService).lockAllStudentRepositories(programmingExercise); - verify(programmingExerciseScheduleService).lockAllStudentRepositories(programmingExercise2); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + assertThat(studentExamRepository.findAllTestRunsByExamId(exam.getId())).hasSize(3); + request.delete("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId(), HttpStatus.OK); } @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void unlockAllRepositories_preAuthNoInstructor() throws Exception { - request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/student-exams/unlock-all-repositories", Optional.empty(), Integer.class, - HttpStatus.FORBIDDEN); + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteCourseWithMultipleTestRuns() throws Exception { + var exam = examUtilService.addExam(course1); + exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + assertThat(studentExamRepository.findAllTestRunsByExamId(exam.getId())).hasSize(3); + request.delete("/api/courses/" + exam.getCourse().getId(), HttpStatus.OK); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void unlockAllRepositories() throws Exception { - bitbucketRequestMockProvider.enableMockingOfRequests(true); - assertThat(studentExamRepository.findStudentExam(new ProgrammingExercise(), null)).isEmpty(); - - Exam exam = examUtilService.addExamWithExerciseGroup(course1, true); - ExerciseGroup exerciseGroup1 = exam.getExerciseGroups().get(0); - - ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); - programmingExerciseRepository.save(programmingExercise); - - ProgrammingExercise programmingExercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); - programmingExerciseRepository.save(programmingExercise2); - - User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); - var studentExam1 = examUtilService.addStudentExamWithUser(exam, student1, 10); - studentExam1.setExercises(List.of(programmingExercise, programmingExercise2)); - var studentExam2 = examUtilService.addStudentExamWithUser(exam, student2, 0); - studentExam2.setExercises(List.of(programmingExercise, programmingExercise2)); - studentExamRepository.saveAll(Set.of(studentExam1, studentExam2)); - - var participationExSt1 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); - var participationExSt2 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student2"); - - var participationEx2St1 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise2, TEST_PREFIX + "student1"); - var participationEx2St2 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise2, TEST_PREFIX + "student2"); - - assertThat(studentExamRepository.findStudentExam(programmingExercise, participationExSt1)).contains(studentExam1); - assertThat(studentExamRepository.findStudentExam(programmingExercise, participationExSt2)).contains(studentExam2); - assertThat(studentExamRepository.findStudentExam(programmingExercise2, participationEx2St1)).contains(studentExam1); - assertThat(studentExamRepository.findStudentExam(programmingExercise2, participationEx2St2)).contains(studentExam2); - - mockConfigureRepository(programmingExercise, TEST_PREFIX + "student1", Set.of(student1), true); - mockConfigureRepository(programmingExercise, TEST_PREFIX + "student2", Set.of(student2), true); - mockConfigureRepository(programmingExercise2, TEST_PREFIX + "student1", Set.of(student1), true); - mockConfigureRepository(programmingExercise2, TEST_PREFIX + "student2", Set.of(student2), true); - - Integer numOfUnlockedExercises = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams/unlock-all-repositories", - Optional.empty(), Integer.class, HttpStatus.OK); - - assertThat(numOfUnlockedExercises).isEqualTo(2); + void testGetExamForTestRunDashboard_ok() throws Exception { + var exam = examUtilService.addExam(course1); + exam = examUtilService.addTextModelingProgrammingExercisesToExam(exam, false, false); + examUtilService.setupTestRunForExamWithExerciseGroupsForInstructor(exam, instructor, exam.getExerciseGroups()); + exam = request.get("/api/courses/" + exam.getCourse().getId() + "/exams/" + exam.getId() + "/exam-for-test-run-assessment-dashboard", HttpStatus.OK, Exam.class); + assertThat(exam.getExerciseGroups().stream().flatMap(exerciseGroup -> exerciseGroup.getExercises().stream()).toList()).isNotEmpty(); + } - verify(programmingExerciseScheduleService).unlockAllStudentRepositories(programmingExercise); - verify(programmingExerciseScheduleService).unlockAllStudentRepositories(programmingExercise2); + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForStart() throws Exception { + Exam exam = examUtilService.addActiveExamWithRegisteredUser(course1, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + exam.setVisibleDate(ZonedDateTime.now().minusHours(1).minusMinutes(5)); + StudentExam response = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/start", HttpStatus.OK, StudentExam.class); + assertThat(response.getExam()).isEqualTo(exam); + verify(examAccessService).getExamInCourseElseThrow(course1.getId(), exam.getId()); } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @@ -1894,441 +817,6 @@ void testGetExamScore_tutor_forbidden() throws Exception { request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/scores", HttpStatus.FORBIDDEN, ExamScoresDTO.class); } - private int getNumberOfProgrammingExercises(Exam exam) { - exam = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(exam.getId()); - int count = 0; - for (var exerciseGroup : exam.getExerciseGroups()) { - for (var exercise : exerciseGroup.getExercises()) { - if (exercise instanceof ProgrammingExercise) { - count++; - } - } - } - return count; - } - - private void configureCourseAsBonusWithIndividualAndTeamResults(Course course, GradingScale bonusToGradingScale) { - ZonedDateTime pastTimestamp = ZonedDateTime.now().minusDays(5); - TextExercise textExercise = textExerciseUtilService.createIndividualTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); - Long individualTextExerciseId = textExercise.getId(); - textExerciseUtilService.createIndividualTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); - - Exercise teamExercise = textExerciseUtilService.createTeamTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); - User tutor1 = userRepo.findOneByLogin(TEST_PREFIX + "tutor1").orElseThrow(); - Long teamTextExerciseId = teamExercise.getId(); - Long team1Id = teamUtilService.createTeam(Set.of(student1), tutor1, teamExercise, TEST_PREFIX + "team1").getId(); - User student2 = userRepo.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); - User student3 = userRepo.findOneByLogin(TEST_PREFIX + "student3").orElseThrow(); - User tutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); - Long team2Id = teamUtilService.createTeam(Set.of(student2, student3), tutor2, teamExercise, TEST_PREFIX + "team2").getId(); - - participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student1, 10.0, 10.0, 50, true); - - Team team1 = teamRepository.findById(team1Id).orElseThrow(); - var result = participationUtilService.createParticipationSubmissionAndResult(teamTextExerciseId, team1, 10.0, 10.0, 40, true); - // Creating a second results for team1 to test handling multiple results. - participationUtilService.createSubmissionAndResult((StudentParticipation) result.getParticipation(), 50, true); - - var student2Result = participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student2, 10.0, 10.0, 50, true); - - var student3Result = participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student3, 10.0, 10.0, 30, true); - - Team team2 = teamRepository.findById(team2Id).orElseThrow(); - participationUtilService.createParticipationSubmissionAndResult(teamTextExerciseId, team2, 10.0, 10.0, 80, true); - - // Adding plagiarism cases - var bonusPlagiarismCase = new PlagiarismCase(); - bonusPlagiarismCase.setStudent(student3); - bonusPlagiarismCase.setExercise(student3Result.getParticipation().getExercise()); - bonusPlagiarismCase.setVerdict(PlagiarismVerdict.PLAGIARISM); - plagiarismCaseRepository.save(bonusPlagiarismCase); - - var bonusPlagiarismCase2 = new PlagiarismCase(); - bonusPlagiarismCase2.setStudent(student2); - bonusPlagiarismCase2.setExercise(student2Result.getParticipation().getExercise()); - bonusPlagiarismCase2.setVerdict(PlagiarismVerdict.POINT_DEDUCTION); - bonusPlagiarismCase2.setVerdictPointDeduction(50); - plagiarismCaseRepository.save(bonusPlagiarismCase2); - - BonusStrategy bonusStrategy = BonusStrategy.GRADES_CONTINUOUS; - bonusToGradingScale.setBonusStrategy(bonusStrategy); - gradingScaleRepository.save(bonusToGradingScale); - - GradingScale sourceGradingScale = gradingScaleUtilService.generateGradingScaleWithStickyStep(new double[] { 60, 40, 50 }, Optional.of(new String[] { "0", "0.3", "0.6" }), - true, 1); - sourceGradingScale.setGradeType(GradeType.BONUS); - sourceGradingScale.setCourse(course); - gradingScaleRepository.save(sourceGradingScale); - - var bonus = BonusFactory.generateBonus(bonusStrategy, -1.0, sourceGradingScale.getId(), bonusToGradingScale.getId()); - bonusRepository.save(bonus); - - course.setMaxPoints(100); - course.setPresentationScore(null); - courseRepo.save(course); - - } - - @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") - @CsvSource({ "false, false", "true, false", "false, true", "true, true" }) - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGetExamScore(boolean withCourseBonus, boolean withSecondCorrectionAndStarted) throws Exception { - programmingExerciseTestService.setup(this, versionControlService, continuousIntegrationService); - bitbucketRequestMockProvider.enableMockingOfRequests(true); - bambooRequestMockProvider.enableMockingOfRequests(true); - - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - - var visibleDate = now().minusMinutes(5); - var startDate = now().plusMinutes(5); - var endDate = now().plusMinutes(20); - - // register users. Instructors are ignored from scores as they are exclusive for test run exercises - Set registeredStudents = getRegisteredStudentsForExam(); - - var studentExams = programmingExerciseTestService.prepareStudentExamsForConduction(TEST_PREFIX, visibleDate, startDate, endDate, registeredStudents, studentRepos); - Exam exam = examRepository.findByIdWithExamUsersExerciseGroupsAndExercisesElseThrow(studentExams.get(0).getExam().getId()); - Course course = exam.getCourse(); - - Integer noGeneratedParticipations = registeredStudents.size() * exam.getExerciseGroups().size(); - - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - - // instructor exam checklist checks - ExamChecklistDTO examChecklistDTO = examService.getStatsForChecklist(exam, true); - assertThat(examChecklistDTO).isNotNull(); - assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isEqualTo(exam.getExamUsers().size()); - assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isTrue(); - assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isZero(); - - // check that an adapted version is computed for tutors - userUtilService.changeUser(TEST_PREFIX + "tutor1"); - - examChecklistDTO = examService.getStatsForChecklist(exam, false); - assertThat(examChecklistDTO).isNotNull(); - assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isNull(); - assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isFalse(); - assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isZero(); - - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - - // set start and submitted date as results are created below - studentExams.forEach(studentExam -> { - studentExam.setStartedAndStartDate(now().minusMinutes(2)); - studentExam.setSubmitted(true); - studentExam.setSubmissionDate(now().minusMinutes(1)); - }); - studentExamRepository.saveAll(studentExams); - - // Fetch the created participations and assign them to the exercises - int participationCounter = 0; - List exercisesInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getExercises).flatMap(Collection::stream).toList(); - for (var exercise : exercisesInExam) { - List participations = studentParticipationRepository.findByExerciseIdAndTestRunWithEagerLegalSubmissionsResult(exercise.getId(), false); - exercise.setStudentParticipations(new HashSet<>(participations)); - participationCounter += exercise.getStudentParticipations().size(); - } - assertThat(noGeneratedParticipations).isEqualTo(participationCounter); - - if (withSecondCorrectionAndStarted) { - exercisesInExam.forEach(exercise -> exercise.setSecondCorrectionEnabled(true)); - exerciseRepo.saveAll(exercisesInExam); - } - - // Scores used for all exercise results - double correctionResultScore = 60D; - double resultScore = 75D; - - // Assign results to participations and submissions - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - Submission submission; - // Programming exercises don't have a submission yet - if (exercise instanceof ProgrammingExercise) { - assertThat(participation.getSubmissions()).isEmpty(); - submission = new ProgrammingSubmission(); - submission.setParticipation(participation); - submission = submissionRepository.save(submission); - } - else { - // There should only be one submission for text, quiz, modeling and file upload - assertThat(participation.getSubmissions()).hasSize(1); - submission = participation.getSubmissions().iterator().next(); - } - - // make sure to create submitted answers - if (exercise instanceof QuizExercise quizExercise) { - var quizQuestions = quizExerciseRepository.findByIdWithQuestionsElseThrow(exercise.getId()).getQuizQuestions(); - for (var quizQuestion : quizQuestions) { - var submittedAnswer = QuizExerciseFactory.generateSubmittedAnswerFor(quizQuestion, true); - var quizSubmission = quizSubmissionRepository.findWithEagerSubmittedAnswersById(submission.getId()); - quizSubmission.addSubmittedAnswers(submittedAnswer); - quizSubmissionService.saveSubmissionForExamMode(quizExercise, quizSubmission, participation.getStudent().orElseThrow()); - } - } - - // Create results - if (withSecondCorrectionAndStarted) { - var firstResult = new Result().score(correctionResultScore).rated(true).completionDate(now().minusMinutes(5)); - firstResult.setParticipation(participation); - firstResult.setAssessor(instructor); - firstResult = resultRepository.save(firstResult); - firstResult.setSubmission(submission); - submission.addResult(firstResult); - } - - var finalResult = new Result().score(resultScore).rated(true).completionDate(now().minusMinutes(5)); - finalResult.setParticipation(participation); - finalResult.setAssessor(instructor); - finalResult = resultRepository.save(finalResult); - finalResult.setSubmission(submission); - submission.addResult(finalResult); - - submission.submitted(true); - submission.setSubmissionDate(now().minusMinutes(6)); - submissionRepository.save(submission); - } - } - // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - final var exerciseWithNoUsers = TextExerciseFactory.generateTextExerciseForExam(exam.getExerciseGroups().get(0)); - exerciseRepo.save(exerciseWithNoUsers); - - GradingScale gradingScale = gradingScaleUtilService.generateGradingScaleWithStickyStep(new double[] { 60, 25, 15, 50 }, - Optional.of(new String[] { "5.0", "3.0", "1.0", "1.0" }), true, 1); - gradingScale.setExam(exam); - gradingScale = gradingScaleRepository.save(gradingScale); - - waitForParticipantScores(); - - if (withCourseBonus) { - configureCourseAsBonusWithIndividualAndTeamResults(course, gradingScale); - } - - await().timeout(Duration.ofMinutes(1)).until(() -> { - for (Exercise exercise : exercisesInExam) { - if (participantScoreRepository.findAllByExercise(exercise).size() != exercise.getStudentParticipations().size()) { - return false; - } - } - return true; - }); - - var examScores = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/scores", HttpStatus.OK, ExamScoresDTO.class); - - // Compare generated results to data in ExamScoresDTO - // Compare top-level DTO properties - assertThat(examScores.maxPoints()).isEqualTo(exam.getExamMaxPoints()); - - assertThat(examScores.hasSecondCorrectionAndStarted()).isEqualTo(withSecondCorrectionAndStarted); - - // For calculation assume that all exercises within an exerciseGroups have the same max points - double calculatedAverageScore = 0.0; - for (var exerciseGroup : exam.getExerciseGroups()) { - var exercise = exerciseGroup.getExercises().stream().findAny().orElseThrow(); - if (exercise.getIncludedInOverallScore().equals(IncludedInOverallScore.NOT_INCLUDED)) { - continue; - } - calculatedAverageScore += Math.round(exercise.getMaxPoints() * resultScore / 100.00 * 10) / 10.0; - } - - assertThat(examScores.averagePointsAchieved()).isEqualTo(calculatedAverageScore); - assertThat(examScores.title()).isEqualTo(exam.getTitle()); - assertThat(examScores.examId()).isEqualTo(exam.getId()); - - // Ensure that all exerciseGroups of the exam are present in the DTO - Set exerciseGroupIdsInDTO = examScores.exerciseGroups().stream().map(ExamScoresDTO.ExerciseGroup::id).collect(Collectors.toSet()); - Set exerciseGroupIdsInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getId).collect(Collectors.toSet()); - assertThat(exerciseGroupIdsInExam).isEqualTo(exerciseGroupIdsInDTO); - - // Compare exerciseGroups in DTO to exam exerciseGroups - // Tolerated absolute difference for floating-point number comparisons - double EPSILON = 0000.1; - for (var exerciseGroupDTO : examScores.exerciseGroups()) { - // Find the original exerciseGroup of the exam using the id in ExerciseGroupId - ExerciseGroup originalExerciseGroup = exam.getExerciseGroups().stream().filter(exerciseGroup -> exerciseGroup.getId().equals(exerciseGroupDTO.id())).findFirst() - .orElseThrow(); - - // Assume that all exercises in a group have the same max score - Double groupMaxScoreFromExam = originalExerciseGroup.getExercises().stream().findAny().orElseThrow().getMaxPoints(); - assertThat(exerciseGroupDTO.maxPoints()).isEqualTo(originalExerciseGroup.getExercises().stream().findAny().orElseThrow().getMaxPoints()); - assertThat(groupMaxScoreFromExam).isEqualTo(exerciseGroupDTO.maxPoints(), withPrecision(EPSILON)); - - // EPSILON - // Compare exercise information - long noOfExerciseGroupParticipations = 0; - for (var originalExercise : originalExerciseGroup.getExercises()) { - // Find the corresponding ExerciseInfo object - var exerciseDTO = exerciseGroupDTO.containedExercises().stream().filter(exerciseInfo -> exerciseInfo.exerciseId().equals(originalExercise.getId())).findFirst() - .orElseThrow(); - // Check the exercise title - assertThat(originalExercise.getTitle()).isEqualTo(exerciseDTO.title()); - // Check the max points of the exercise - assertThat(originalExercise.getMaxPoints()).isEqualTo(exerciseDTO.maxPoints()); - // Check the number of exercise participants and update the group participant counter - var noOfExerciseParticipations = originalExercise.getStudentParticipations().size(); - noOfExerciseGroupParticipations += noOfExerciseParticipations; - assertThat(Long.valueOf(originalExercise.getStudentParticipations().size())).isEqualTo(exerciseDTO.numberOfParticipants()); - } - assertThat(noOfExerciseGroupParticipations).isEqualTo(exerciseGroupDTO.numberOfParticipants()); - } - - // Ensure that all registered students have a StudentResult - Set studentIdsWithStudentResults = examScores.studentResults().stream().map(ExamScoresDTO.StudentResult::userId).collect(Collectors.toSet()); - Set registeredUsers = exam.getRegisteredUsers(); - Set registeredUsersIds = registeredUsers.stream().map(User::getId).collect(Collectors.toSet()); - assertThat(studentIdsWithStudentResults).isEqualTo(registeredUsersIds); - - // Compare StudentResult with the generated results - for (var studentResult : examScores.studentResults()) { - // Find the original user using the id in StudentResult - User originalUser = userRepo.findByIdElseThrow(studentResult.userId()); - StudentExam studentExamOfUser = studentExams.stream().filter(studentExam -> studentExam.getUser().equals(originalUser)).findFirst().orElseThrow(); - - assertThat(studentResult.name()).isEqualTo(originalUser.getName()); - assertThat(studentResult.email()).isEqualTo(originalUser.getEmail()); - assertThat(studentResult.login()).isEqualTo(originalUser.getLogin()); - assertThat(studentResult.registrationNumber()).isEqualTo(originalUser.getRegistrationNumber()); - - // Calculate overall points achieved - - var calculatedOverallPoints = calculateOverallPoints(resultScore, studentExamOfUser); - - assertThat(studentResult.overallPointsAchieved()).isEqualTo(calculatedOverallPoints, withPrecision(EPSILON)); - - double expectedPointsAchievedInFirstCorrection = withSecondCorrectionAndStarted ? calculateOverallPoints(correctionResultScore, studentExamOfUser) : 0.0; - assertThat(studentResult.overallPointsAchievedInFirstCorrection()).isEqualTo(expectedPointsAchievedInFirstCorrection, withPrecision(EPSILON)); - - // Calculate overall score achieved - var calculatedOverallScore = calculatedOverallPoints / examScores.maxPoints() * 100; - assertThat(studentResult.overallScoreAchieved()).isEqualTo(calculatedOverallScore, withPrecision(EPSILON)); - - assertThat(studentResult.overallGrade()).isNotNull(); - assertThat(studentResult.hasPassed()).isNotNull(); - assertThat(studentResult.mostSeverePlagiarismVerdict()).isNull(); - if (withCourseBonus) { - String studentLogin = studentResult.login(); - assertThat(studentResult.gradeWithBonus().bonusStrategy()).isEqualTo(BonusStrategy.GRADES_CONTINUOUS); - switch (studentLogin) { - case TEST_PREFIX + "student1" -> { - assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isNull(); - assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isEqualTo(10.0); - assertThat(studentResult.gradeWithBonus().bonusGrade()).isEqualTo("0.0"); - assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); - } - case TEST_PREFIX + "student2" -> { - assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isEqualTo(PlagiarismVerdict.POINT_DEDUCTION); - assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isEqualTo(10.5); // 10.5 = 8 + 5 * 50% plagiarism point deduction. - assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); - } - case TEST_PREFIX + "student3" -> { - assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isEqualTo(PlagiarismVerdict.PLAGIARISM); - assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isZero(); - assertThat(studentResult.gradeWithBonus().bonusGrade()).isEqualTo(GradingScale.DEFAULT_PLAGIARISM_GRADE); - assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); - } - default -> { - } - } - } - else { - assertThat(studentResult.gradeWithBonus()).isNull(); - } - - // Ensure that the exercise ids of the student exam are the same as the exercise ids in the students exercise results - Set exerciseIdsOfStudentResult = studentResult.exerciseGroupIdToExerciseResult().values().stream().map(ExamScoresDTO.ExerciseResult::exerciseId) - .collect(Collectors.toSet()); - Set exerciseIdsInStudentExam = studentExamOfUser.getExercises().stream().map(DomainObject::getId).collect(Collectors.toSet()); - assertThat(exerciseIdsOfStudentResult).isEqualTo(exerciseIdsInStudentExam); - for (Map.Entry entry : studentResult.exerciseGroupIdToExerciseResult().entrySet()) { - var exerciseResult = entry.getValue(); - - // Find the original exercise using the id in ExerciseResult - Exercise originalExercise = studentExamOfUser.getExercises().stream().filter(exercise -> exercise.getId().equals(exerciseResult.exerciseId())).findFirst() - .orElseThrow(); - - // Check that the key is associated with the exerciseGroup which actually contains the exercise in the exerciseResult - assertThat(originalExercise.getExerciseGroup().getId()).isEqualTo(entry.getKey()); - - assertThat(exerciseResult.title()).isEqualTo(originalExercise.getTitle()); - assertThat(exerciseResult.maxScore()).isEqualTo(originalExercise.getMaxPoints()); - assertThat(exerciseResult.achievedScore()).isEqualTo(resultScore); - if (originalExercise instanceof QuizExercise) { - assertThat(exerciseResult.hasNonEmptySubmission()).isTrue(); - } - else { - assertThat(exerciseResult.hasNonEmptySubmission()).isFalse(); - } - // TODO: create a test where hasNonEmptySubmission() is false for a quiz - assertThat(exerciseResult.achievedPoints()).isEqualTo(originalExercise.getMaxPoints() * resultScore / 100, withPrecision(EPSILON)); - } - } - - // change back to instructor user - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - - var expectedTotalExamAssessmentsFinishedByCorrectionRound = new Long[] { noGeneratedParticipations.longValue(), noGeneratedParticipations.longValue() }; - if (!withSecondCorrectionAndStarted) { - // The second correction has not started in this case. - expectedTotalExamAssessmentsFinishedByCorrectionRound[1] = 0L; - } - - // check if stats are set correctly for the instructor - examChecklistDTO = examService.getStatsForChecklist(exam, true); - assertThat(examChecklistDTO).isNotNull(); - var size = examScores.studentResults().size(); - assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isEqualTo(size); - assertThat(examChecklistDTO.getNumberOfExamsSubmitted()).isEqualTo(size); - assertThat(examChecklistDTO.getNumberOfExamsStarted()).isEqualTo(size); - assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isTrue(); - assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isEqualTo(size * 6L); - assertThat(examChecklistDTO.getNumberOfTestRuns()).isZero(); - assertThat(examChecklistDTO.getNumberOfTotalExamAssessmentsFinishedByCorrectionRound()).hasSize(2).containsExactly(expectedTotalExamAssessmentsFinishedByCorrectionRound); - - // change to a tutor - userUtilService.changeUser(TEST_PREFIX + "tutor1"); - - // check that a modified version is returned - // check if stats are set correctly for the instructor - examChecklistDTO = examService.getStatsForChecklist(exam, false); - assertThat(examChecklistDTO).isNotNull(); - assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isNull(); - assertThat(examChecklistDTO.getNumberOfExamsSubmitted()).isNull(); - assertThat(examChecklistDTO.getNumberOfExamsStarted()).isNull(); - assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isFalse(); - assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isEqualTo(size * 6L); - assertThat(examChecklistDTO.getNumberOfTestRuns()).isNull(); - assertThat(examChecklistDTO.getNumberOfTotalExamAssessmentsFinishedByCorrectionRound()).hasSize(2).containsExactly(expectedTotalExamAssessmentsFinishedByCorrectionRound); - - bambooRequestMockProvider.reset(); - - final ProgrammingExercise programmingExercise = (ProgrammingExercise) exam.getExerciseGroups().get(6).getExercises().iterator().next(); - - var usersOfExam = exam.getRegisteredUsers(); - mockDeleteProgrammingExercise(programmingExercise, usersOfExam); - - await().until(() -> participantScoreScheduleService.isIdle()); - - // change back to instructor user - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - // Make sure delete also works if so many objects have been created before - waitForParticipantScores(); - request.delete("/api/courses/" + course.getId() + "/exams/" + exam.getId(), HttpStatus.OK); - assertThat(examRepository.findById(exam.getId())).isEmpty(); - } - - private void waitForParticipantScores() { - participantScoreScheduleService.executeScheduledTasks(); - await().until(() -> participantScoreScheduleService.isIdle()); - } - - private double calculateOverallPoints(Double correctionResultScore, StudentExam studentExamOfUser) { - return studentExamOfUser.getExercises().stream().filter(exercise -> !exercise.getIncludedInOverallScore().equals(IncludedInOverallScore.NOT_INCLUDED)) - .map(Exercise::getMaxPoints).reduce(0.0, (total, maxScore) -> (Math.round((total + maxScore * correctionResultScore / 100) * 10) / 10.0)); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamStatistics() throws Exception { @@ -2471,27 +959,6 @@ void testIsExamOver_GracePeriod() { assertThat(isOver).isFalse(); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testIsUserRegisteredForExam() { - var examUser = new ExamUser(); - examUser.setExam(exam1); - examUser.setUser(student1); - examUser = examUserRepository.save(examUser); - exam1.addExamUser(examUser); - final var exam = examRepository.save(exam1); - final var isUserRegistered = examRegistrationService.isUserRegisteredForExam(exam.getId(), student1.getId()); - final var isCurrentUserRegistered = examRegistrationService.isCurrentUserRegisteredForExam(exam.getId()); - assertThat(isUserRegistered).isTrue(); - assertThat(isCurrentUserRegistered).isFalse(); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testRegisterInstructorToExam() throws Exception { - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/students/" + TEST_PREFIX + "instructor1", null, HttpStatus.FORBIDDEN, null); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testArchiveCourseWithExam() throws Exception { @@ -2635,322 +1102,6 @@ private void assertSubmissionFilename(List expectedFilenames, Submission s assertThat(expectedFilenames).contains(Path.of(filename)); } - @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") - @ValueSource(ints = { 0, 1, 2 }) - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGetStatsForExamAssessmentDashboard(int numberOfCorrectionRounds) throws Exception { - log.debug("testGetStatsForExamAssessmentDashboard: step 1 done"); - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - - User examTutor1 = userRepo.findOneByLogin(TEST_PREFIX + "tutor1").orElseThrow(); - User examTutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); - - var examVisibleDate = now().minusMinutes(5); - var examStartDate = now().plusMinutes(5); - var examEndDate = now().plusMinutes(20); - Course course = courseUtilService.addEmptyCourse(); - Exam exam = examUtilService.addExam(course, examVisibleDate, examStartDate, examEndDate); - exam.setNumberOfCorrectionRoundsInExam(numberOfCorrectionRounds); - exam = examRepository.save(exam); - exam = examUtilService.addExerciseGroupsAndExercisesToExam(exam, false); - - log.debug("testGetStatsForExamAssessmentDashboard: step 2 done"); - - var stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - assertThat(stats.getNumberOfSubmissions()).isInstanceOf(DueDateStat.class); - assertThat(stats.getTutorLeaderboardEntries()).isInstanceOf(List.class); - if (numberOfCorrectionRounds != 0) { - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isInstanceOf(DueDateStat[].class); - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); - } - else { - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isNull(); - } - assertThat(stats.getNumberOfAssessmentLocks()).isZero(); - assertThat(stats.getNumberOfSubmissions().inTime()).isZero(); - if (numberOfCorrectionRounds > 0) { - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); - } - else { - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isNull(); - } - assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); - - if (numberOfCorrectionRounds == 0) { - // We do not need any more assertions, as numberOfCorrectionRounds is only 0 for test exams (no manual assessment) - return; - } - - var lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); - assertThat(lockedSubmissions).isEmpty(); - - log.debug("testGetStatsForExamAssessmentDashboard: step 3 done"); - - // register users. Instructors are ignored from scores as they are exclusive for test run exercises - Set registeredStudents = getRegisteredStudentsForExam(); - for (var student : registeredStudents) { - var registeredExamUser = new ExamUser(); - registeredExamUser.setExam(exam); - registeredExamUser.setUser(student); - exam.addExamUser(registeredExamUser); - } - exam.setNumberOfExercisesInExam(exam.getExerciseGroups().size()); - exam.setRandomizeExerciseOrder(false); - exam = examRepository.save(exam); - exam = examRepository.findWithExamUsersAndExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); - - log.debug("testGetStatsForExamAssessmentDashboard: step 4 done"); - - // generate individual student exams - List studentExams = request.postListWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), - StudentExam.class, HttpStatus.OK); - int noGeneratedParticipations = ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course); - verify(gitService, times(getNumberOfProgrammingExercises(exam))).combineAllCommitsOfRepositoryIntoOne(any()); - // set start and submitted date as results are created below - studentExams.forEach(studentExam -> { - studentExam.setStartedAndStartDate(now().minusMinutes(2)); - studentExam.setSubmitted(true); - studentExam.setSubmissionDate(now().minusMinutes(1)); - }); - studentExamRepository.saveAll(studentExams); - - log.debug("testGetStatsForExamAssessmentDashboard: step 5 done"); - - // Fetch the created participations and assign them to the exercises - int participationCounter = 0; - List exercisesInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getExercises).flatMap(Collection::stream).toList(); - for (var exercise : exercisesInExam) { - List participations = studentParticipationRepository.findByExerciseIdAndTestRunWithEagerLegalSubmissionsResult(exercise.getId(), false); - exercise.setStudentParticipations(new HashSet<>(participations)); - participationCounter += exercise.getStudentParticipations().size(); - } - assertThat(noGeneratedParticipations).isEqualTo(participationCounter); - - log.debug("testGetStatsForExamAssessmentDashboard: step 6 done"); - - // Assign submissions to the participations - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - assertThat(participation.getSubmissions()).hasSize(1); - Submission submission = participation.getSubmissions().iterator().next(); - submission.submitted(true); - submission.setSubmissionDate(now().minusMinutes(6)); - submissionRepository.save(submission); - } - } - - log.debug("testGetStatsForExamAssessmentDashboard: step 7 done"); - - // check the stats again - check the count of submitted submissions - stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - assertThat(stats.getNumberOfAssessmentLocks()).isZero(); - // 85 = (17 users * 5 exercises); quiz submissions are not counted - assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); - assertThat(stats.getNumberOfComplaints()).isZero(); - assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); - - // Score used for all exercise results - Double resultScore = 75.0; - - log.debug("testGetStatsForExamAssessmentDashboard: step 7 done"); - - // Lock all submissions - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - Submission submission; - assertThat(participation.getSubmissions()).hasSize(1); - submission = participation.getSubmissions().iterator().next(); - // Create results - var result = new Result().score(resultScore); - if (exercise instanceof QuizExercise) { - result.completionDate(now().minusMinutes(4)); - result.setRated(true); - } - result.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); - result.setParticipation(participation); - result.setAssessor(examTutor1); - result = resultRepository.save(result); - result.setSubmission(submission); - submission.addResult(result); - submissionRepository.save(submission); - } - } - log.debug("testGetStatsForExamAssessmentDashboard: step 8 done"); - - // check the stats again - userUtilService.changeUser(TEST_PREFIX + "tutor1"); - stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - - assertThat(stats.getNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); - // (studentExams.size() users * 5 exercises); quiz submissions are not counted - assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); - // the studentExams.size() quiz submissions are already assessed - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size()); - assertThat(stats.getNumberOfComplaints()).isZero(); - assertThat(stats.getTotalNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); - - log.debug("testGetStatsForExamAssessmentDashboard: step 9 done"); - - // test the query needed for assessment information - userUtilService.changeUser(TEST_PREFIX + "tutor2"); - exam.getExerciseGroups().forEach(group -> { - var locks = group.getExercises().stream().map( - exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[0] - .inTime()) - .reduce(Long::sum).orElseThrow(); - if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) - assertThat(locks).isEqualTo(studentExams.size()); - }); - - log.debug("testGetStatsForExamAssessmentDashboard: step 10 done"); - - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); - assertThat(lockedSubmissions).hasSize(studentExams.size() * 5); - - log.debug("testGetStatsForExamAssessmentDashboard: step 11 done"); - - // Finish assessment of all submissions - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - Submission submission; - assertThat(participation.getSubmissions()).hasSize(1); - submission = participation.getSubmissions().iterator().next(); - var result = submission.getLatestResult().completionDate(now().minusMinutes(5)); - result.setRated(true); - resultRepository.save(result); - } - } - - log.debug("testGetStatsForExamAssessmentDashboard: step 12 done"); - - // check the stats again - stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - assertThat(stats.getNumberOfAssessmentLocks()).isZero(); - // 75 = (15 users * 5 exercises); quiz submissions are not counted - assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); - // 75 + the 19 quiz submissions - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 5L + studentExams.size()); - assertThat(stats.getNumberOfComplaints()).isZero(); - assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); - - log.debug("testGetStatsForExamAssessmentDashboard: step 13 done"); - - lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); - assertThat(lockedSubmissions).isEmpty(); - if (numberOfCorrectionRounds == 2) { - lockAndAssessForSecondCorrection(exam, course, studentExams, exercisesInExam, numberOfCorrectionRounds); - } - - log.debug("testGetStatsForExamAssessmentDashboard: step 14 done"); - } - - private void lockAndAssessForSecondCorrection(Exam exam, Course course, List studentExams, List exercisesInExam, int numberOfCorrectionRounds) - throws Exception { - // Lock all submissions - User examInstructor = userRepo.findOneByLogin(TEST_PREFIX + "instructor1").orElseThrow(); - User examTutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); - - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - assertThat(participation.getSubmissions()).hasSize(1); - Submission submission = participation.getSubmissions().iterator().next(); - // Create results - var result = new Result().score(50D).rated(true); - if (exercise instanceof QuizExercise) { - result.completionDate(now().minusMinutes(3)); - } - result.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); - result.setParticipation(participation); - result.setAssessor(examInstructor); - result = resultRepository.save(result); - result.setSubmission(submission); - submission.addResult(result); - submissionRepository.save(submission); - } - } - // check the stats again - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - var stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - assertThat(stats.getNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); - // 75 = (15 users * 5 exercises); quiz submissions are not counted - assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); - // the 15 quiz submissions are already assessed - and all are assessed in the first correctionRound - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 6L); - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[1].inTime()).isEqualTo(studentExams.size()); - assertThat(stats.getNumberOfComplaints()).isZero(); - assertThat(stats.getTotalNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); - - // test the query needed for assessment information - userUtilService.changeUser(TEST_PREFIX + "tutor2"); - exam.getExerciseGroups().forEach(group -> { - var locksRound1 = group.getExercises().stream().map( - exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[0] - .inTime()) - .reduce(Long::sum).orElseThrow(); - if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) { - assertThat(locksRound1).isZero(); - } - - var locksRound2 = group.getExercises().stream().map( - exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[1] - .inTime()) - .reduce(Long::sum).orElseThrow(); - if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) { - assertThat(locksRound2).isEqualTo(studentExams.size()); - } - }); - - userUtilService.changeUser(TEST_PREFIX + "instructor1"); - var lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); - assertThat(lockedSubmissions).hasSize(studentExams.size() * 5); - - // Finish assessment of all submissions - for (var exercise : exercisesInExam) { - for (var participation : exercise.getStudentParticipations()) { - Submission submission; - assertThat(participation.getSubmissions()).hasSize(1); - submission = participation.getSubmissions().iterator().next(); - var result = submission.getLatestResult().completionDate(now().minusMinutes(5)); - result.setRated(true); - resultRepository.save(result); - } - } - - // check the stats again - stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); - assertThat(stats.getNumberOfAssessmentLocks()).isZero(); - // 75 = (15 users * 5 exercises); quiz submissions are not counted - assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); - // 75 + the 15 quiz submissions - assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 6L); - assertThat(stats.getNumberOfComplaints()).isZero(); - assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); - - lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); - assertThat(lockedSubmissions).isEmpty(); - - } - - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testGenerateStudentExamsTemplateCombine() throws Exception { - Exam examWithProgramming = examUtilService.addExerciseGroupsAndExercisesToExam(exam1, true); - doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); - - // invoke generate student exams - request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + examWithProgramming.getId() + "/generate-student-exams", Optional.empty(), - StudentExam.class, HttpStatus.OK); - - verify(gitService, never()).combineAllCommitsOfRepositoryIntoOne(any()); - - // invoke prepare exercise start - prepareExerciseStart(exam1); - - verify(gitService, times(getNumberOfProgrammingExercises(exam1))).combineAllCommitsOfRepositoryIntoOne(any()); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamTitleAsInstructor() throws Exception { @@ -2990,79 +1141,6 @@ void testGetExamTitleForNonExistingExam() throws Exception { request.get("/api/exams/123124123123/title", HttpStatus.NOT_FOUND, String.class); } - // ExamRegistration Service - checkRegistrationOrRegisterStudentToTestExam - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testCheckRegistrationOrRegisterStudentToTestExam_noTestExam() { - assertThatThrownBy( - () -> examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, exam1.getId(), userUtilService.getUserByLogin(TEST_PREFIX + "student1"))) - .isInstanceOf(BadRequestAlertException.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") - void testCheckRegistrationOrRegisterStudentToTestExam_studentNotPartOfCourse() { - assertThatThrownBy( - () -> examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, exam1.getId(), userUtilService.getUserByLogin(TEST_PREFIX + "student42"))) - .isInstanceOf(BadRequestAlertException.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testCheckRegistrationOrRegisterStudentToTestExam_successfulRegistration() { - Exam testExam = ExamFactory.generateTestExam(course1); - testExam = examRepository.save(testExam); - var examUser = new ExamUser(); - examUser.setExam(testExam); - examUser.setUser(student1); - examUser = examUserRepository.save(examUser); - testExam.addExamUser(examUser); - testExam = examRepository.save(testExam); - examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, testExam.getId(), student1); - Exam testExamReloaded = examRepository.findByIdWithExamUsersElseThrow(testExam.getId()); - assertThat(testExamReloaded.getExamUsers()).contains(examUser); - } - - // ExamResource - getStudentExamForTestExamForStart - @Test - @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") - void testGetStudentExamForTestExamForStart_notRegisteredInCourse() throws Exception { - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, String.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGetStudentExamForTestExamForStart_notVisible() throws Exception { - testExam1.setVisibleDate(now().plusMinutes(60)); - testExam1 = examRepository.save(testExam1); - - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, StudentExam.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGetStudentExamForTestExamForStart_ExamDoesNotBelongToCourse() throws Exception { - Exam testExam = examUtilService.addTestExam(course2); - - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.CONFLICT, StudentExam.class); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGetStudentExamForTestExamForStart_fetchExam_successful() throws Exception { - var testExam = examUtilService.addTestExam(course2); - testExam = examRepository.save(testExam); - var examUser = new ExamUser(); - examUser.setExam(testExam); - examUser.setUser(student1); - examUser = examUserRepository.save(examUser); - testExam.addExamUser(examUser); - examRepository.save(testExam); - var studentExam5 = examUtilService.addStudentExamForTestExam(testExam, student1); - StudentExam studentExamReceived = request.get("/api/courses/" + course2.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.OK, StudentExam.class); - assertThat(studentExamReceived).isEqualTo(studentExam5); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamForImportWithExercises_successful() throws Exception { @@ -3257,23 +1335,6 @@ void testImportExamWithExercises_correctionRoundConflict() throws Exception { request.postWithoutLocation("/api/courses/" + course1.getId() + "/exam-import", examC, HttpStatus.BAD_REQUEST, null); } - @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") - @CsvSource({ "A,A,B,C", "A,B,C,C", "A,A,B,B" }) - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testImportExamWithExercises_programmingExerciseSameShortNameOrTitle(String shortName1, String shortName2, String title1, String title2) throws Exception { - Exam exam = ExamFactory.generateExamWithExerciseGroup(course1, true); - ExerciseGroup exerciseGroup = exam.getExerciseGroups().get(0); - ProgrammingExercise exercise1 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup); - ProgrammingExercise exercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup); - - exercise1.setShortName(shortName1); - exercise2.setShortName(shortName2); - exercise1.setTitle(title1); - exercise2.setTitle(title2); - - request.postWithoutLocation("/api/courses/" + course1.getId() + "/exam-import", exam, HttpStatus.BAD_REQUEST, null); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testImportExamWithExercises_successfulWithoutExercises() throws Exception { @@ -3340,25 +1401,6 @@ void testImportExamWithExercises_successfulWithImportToOtherCourse() throws Exce } } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testImportExamWithExercises_preCheckFailed() throws Exception { - Exam exam = ExamFactory.generateExam(course1); - ExerciseGroup programmingGroup = ExamFactory.generateExerciseGroup(false, exam); - exam = examRepository.save(exam); - exam.setId(null); - ProgrammingExercise programming = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(programmingGroup, ProgrammingLanguage.JAVA); - programmingGroup.addExercise(programming); - exerciseRepo.save(programming); - - doReturn(true).when(versionControlService).checkIfProjectExists(any(), any()); - doReturn(null).when(continuousIntegrationService).checkIfProjectExists(any(), any()); - - request.getMvc().perform(post("/api/courses/" + course1.getId() + "/exam-import").contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(exam))) - .andExpect(status().isBadRequest()) - .andExpect(result -> assertThat(result.getResolvedException()).hasMessage("Exam contains programming exercise(s) with invalid short name.")); - } - @Test @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") void testGetExercisesWithPotentialPlagiarismAsTutor_forbidden() throws Exception { @@ -3434,40 +1476,6 @@ void testGetSuspiciousSessionsAsInstructor() throws Exception { var suspiciousSessions = suspiciousSessionTuples.stream().findFirst().get(); assertThat(suspiciousSessions.examSessions()).hasSize(2); assertThat(suspiciousSessions.examSessions()).usingRecursiveFieldByFieldElementComparatorIgnoringFields("createdDate") - .containsExactlyInAnyOrderElementsOf(createExpectedDTOs(firstExamSessionStudent1, secondExamSessionStudent1)); - } - - private Set createExpectedDTOs(ExamSession session1, ExamSession session2) { - var expectedDTOs = new HashSet(); - var firstStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session1.getStudentExam().getId(), - new ExamWithIdAndCourseDTO(session1.getStudentExam().getExam().getId(), new CourseWithIdDTO(session1.getStudentExam().getExam().getCourse().getId())), - new UserWithIdAndLoginDTO(session1.getStudentExam().getUser().getId(), session1.getStudentExam().getUser().getLogin())); - var secondStudentExamDTO = new StudentExamWithIdAndExamAndUserDTO(session2.getStudentExam().getId(), - new ExamWithIdAndCourseDTO(session2.getStudentExam().getExam().getId(), new CourseWithIdDTO(session2.getStudentExam().getExam().getCourse().getId())), - new UserWithIdAndLoginDTO(session2.getStudentExam().getUser().getId(), session2.getStudentExam().getUser().getLogin())); - var firstExamSessionDTO = new ExamSessionDTO(session1.getId(), session1.getBrowserFingerprintHash(), session1.getIpAddress(), session1.getSuspiciousReasons(), - session1.getCreatedDate(), firstStudentExamDTO); - var secondExamSessionDTO = new ExamSessionDTO(session2.getId(), session2.getBrowserFingerprintHash(), session2.getIpAddress(), session2.getSuspiciousReasons(), - session2.getCreatedDate(), secondStudentExamDTO); - expectedDTOs.add(firstExamSessionDTO); - expectedDTOs.add(secondExamSessionDTO); - return expectedDTOs; - - } - - private int prepareExerciseStart(Exam exam) throws Exception { - return ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); - } - - private Set getRegisteredStudentsForExam() { - var registeredStudents = new HashSet(); - for (int i = 1; i <= NUMBER_OF_STUDENTS; i++) { - registeredStudents.add(userUtilService.getUserByLogin(TEST_PREFIX + "student" + i)); - } - for (int i = 1; i <= NUMBER_OF_TUTORS; i++) { - registeredStudents.add(userUtilService.getUserByLogin(TEST_PREFIX + "tutor" + i)); - } - - return registeredStudents; + .containsExactlyInAnyOrderElementsOf(ExamFactory.createExpectedExamSessionDTOs(firstExamSessionStudent1, secondExamSessionStudent1)); } } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamParticipationIntegrationTest.java new file mode 100644 index 000000000000..c4df42113fb0 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamParticipationIntegrationTest.java @@ -0,0 +1,1169 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.withPrecision; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.assessment.GradingScaleUtilService; +import de.tum.in.www1.artemis.bonus.BonusFactory; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; +import de.tum.in.www1.artemis.domain.enumeration.IncludedInOverallScore; +import de.tum.in.www1.artemis.domain.exam.*; +import de.tum.in.www1.artemis.domain.participation.Participation; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismCase; +import de.tum.in.www1.artemis.domain.plagiarism.PlagiarismVerdict; +import de.tum.in.www1.artemis.domain.quiz.QuizExercise; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseTestService; +import de.tum.in.www1.artemis.exercise.quizexercise.QuizExerciseFactory; +import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseFactory; +import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; +import de.tum.in.www1.artemis.participation.ParticipationUtilService; +import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.repository.plagiarism.PlagiarismCaseRepository; +import de.tum.in.www1.artemis.service.QuizSubmissionService; +import de.tum.in.www1.artemis.service.exam.ExamService; +import de.tum.in.www1.artemis.service.exam.StudentExamService; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.team.TeamUtilService; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.ExamPrepareExercisesTestUtil; +import de.tum.in.www1.artemis.util.LocalRepository; +import de.tum.in.www1.artemis.web.rest.dto.*; + +class ExamParticipationIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "examparticipationtest"; + + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Autowired + private QuizExerciseRepository quizExerciseRepository; + + @Autowired + private QuizSubmissionRepository quizSubmissionRepository; + + @Autowired + private QuizSubmissionService quizSubmissionService; + + @Autowired + private CourseRepository courseRepo; + + @Autowired + private ExerciseRepository exerciseRepo; + + @Autowired + private UserRepository userRepo; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ExamUserRepository examUserRepository; + + @Autowired + private ExamService examService; + + @Autowired + private StudentExamService studentExamService; + + @Autowired + private StudentExamRepository studentExamRepository; + + @Autowired + private StudentParticipationRepository studentParticipationRepository; + + @Autowired + private SubmissionRepository submissionRepository; + + @Autowired + private ResultRepository resultRepository; + + @Autowired + private ParticipationTestRepository participationTestRepository; + + @Autowired + private GradingScaleRepository gradingScaleRepository; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private BonusRepository bonusRepository; + + @Autowired + private PlagiarismCaseRepository plagiarismCaseRepository; + + @Autowired + private ParticipantScoreRepository participantScoreRepository; + + @Autowired + private ProgrammingExerciseTestService programmingExerciseTestService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + private TextExerciseUtilService textExerciseUtilService; + + @Autowired + private ParticipationUtilService participationUtilService; + + @Autowired + private TeamUtilService teamUtilService; + + @Autowired + private GradingScaleUtilService gradingScaleUtilService; + + private Course course1; + + private static final int NUMBER_OF_STUDENTS = 3; + + private static final int NUMBER_OF_TUTORS = 2; + + private final List studentRepos = new ArrayList<>(); + + private User student1; + + private User instructor; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, NUMBER_OF_TUTORS, 0, 1); + + course1 = courseUtilService.addEmptyCourse(); + student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + instructor = userUtilService.getUserByLogin(TEST_PREFIX + "instructor1"); + + bitbucketRequestMockProvider.enableMockingOfRequests(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; + participantScoreScheduleService.activate(); + } + + @AfterEach + void tearDown() throws Exception { + bitbucketRequestMockProvider.reset(); + bambooRequestMockProvider.reset(); + if (programmingExerciseTestService.exerciseRepo != null) { + programmingExerciseTestService.tearDown(); + } + + for (var repo : studentRepos) { + repo.resetLocalRepo(); + } + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 500; + participantScoreScheduleService.shutdown(); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + void testRemovingAllStudents_AfterParticipatingInExam() throws Exception { + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); + + // Generate student exams + List studentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", + Optional.empty(), StudentExam.class, HttpStatus.OK); + assertThat(studentExams).hasSize(3); + assertThat(exam.getExamUsers()).hasSize(3); + + int numberOfGeneratedParticipations = ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); + assertThat(numberOfGeneratedParticipations).isEqualTo(12); + + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + // Fetch student exams + List studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExamsDB).hasSize(3); + List participationList = new ArrayList<>(); + Exercise[] exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); + for (Exercise value : exercises) { + participationList.addAll(studentParticipationRepository.findByExerciseId(value.getId())); + } + assertThat(participationList).hasSize(12); + + // TODO there should be some participation but no submissions unfortunately + // remove all students + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students", HttpStatus.OK); + + // Get the exam with all registered users + var params = new LinkedMultiValueMap(); + params.add("withStudents", "true"); + Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); + assertThat(storedExam.getExamUsers()).isEmpty(); + + // Fetch student exams + studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExamsDB).isEmpty(); + + // Fetch participations + exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); + participationList = new ArrayList<>(); + for (Exercise exercise : exercises) { + participationList.addAll(studentParticipationRepository.findByExerciseId(exercise.getId())); + } + assertThat(participationList).hasSize(12); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + void testRemovingAllStudentsAndParticipations() throws Exception { + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); + + // Generate student exams + List studentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", + Optional.empty(), StudentExam.class, HttpStatus.OK); + assertThat(studentExams).hasSize(3); + assertThat(exam.getExamUsers()).hasSize(3); + + int numberOfGeneratedParticipations = ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + assertThat(numberOfGeneratedParticipations).isEqualTo(12); + // Fetch student exams + List studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExamsDB).hasSize(3); + List participationList = new ArrayList<>(); + Exercise[] exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); + for (Exercise value : exercises) { + participationList.addAll(studentParticipationRepository.findByExerciseId(value.getId())); + } + assertThat(participationList).hasSize(12); + + // TODO there should be some participation but no submissions unfortunately + // remove all students + var paramsParticipations = new LinkedMultiValueMap(); + paramsParticipations.add("withParticipationsAndSubmission", "true"); + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students", HttpStatus.OK, paramsParticipations); + + // Get the exam with all registered users + var params = new LinkedMultiValueMap(); + params.add("withStudents", "true"); + Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); + assertThat(storedExam.getExamUsers()).isEmpty(); + + // Fetch student exams + studentExamsDB = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExamsDB).isEmpty(); + + // Fetch participations + exercises = examRepository.findAllExercisesByExamId(exam.getId()).toArray(new Exercise[0]); + participationList = new ArrayList<>(); + for (Exercise exercise : exercises) { + participationList.addAll(studentParticipationRepository.findByExerciseId(exercise.getId())); + } + assertThat(participationList).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteStudent_AfterParticipatingInExam() throws Exception { + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + // Create an exam with registered students + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); + var student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); + + // Remove student1 from the exam + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK); + + // Get the exam with all registered users + var params = new LinkedMultiValueMap(); + params.add("withStudents", "true"); + Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); + + // Ensure that student1 was removed from the exam + var examUser = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()); + assertThat(examUser).isEmpty(); + assertThat(storedExam.getExamUsers()).hasSize(2); + + // Create individual student exams + List generatedStudentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", + Optional.empty(), StudentExam.class, HttpStatus.OK); + assertThat(generatedStudentExams).hasSize(storedExam.getExamUsers().size()); + + // Start the exam to create participations + ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); + + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + // Get the student exam of student2 + Optional optionalStudent1Exam = generatedStudentExams.stream().filter(studentExam -> studentExam.getUser().equals(student2)).findFirst(); + assertThat(optionalStudent1Exam.orElseThrow()).isNotNull(); + var studentExam2 = optionalStudent1Exam.get(); + + // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + // Remove student2 from the exam + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student2", HttpStatus.OK); + + // Get the exam with all registered users + params = new LinkedMultiValueMap<>(); + params.add("withStudents", "true"); + storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); + + // Ensure that student2 was removed from the exam + var examUser2 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student2.getId()); + assertThat(examUser2).isEmpty(); + assertThat(storedExam.getExamUsers()).hasSize(1); + + // Ensure that the student exam of student2 was deleted + List studentExams = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExams).hasSameSizeAs(storedExam.getExamUsers()).doesNotContain(studentExam2); + + // Ensure that the participations were not deleted + List participationsStudent2 = studentParticipationRepository + .findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student2.getId(), studentExam2.getExercises()); + assertThat(participationsStudent2).hasSize(studentExam2.getExercises().size()); + + // Make sure delete also works if so many objects have been created before + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGenerateStudentExamsCleanupOldParticipations() throws Exception { + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, NUMBER_OF_STUDENTS); + + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, + HttpStatus.OK); + + List studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); + assertThat(studentParticipations).isEmpty(); + + // invoke start exercises + studentExamService.startExercises(exam.getId()).join(); + + studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); + assertThat(studentParticipations).hasSize(12); + + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, + HttpStatus.OK); + + studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); + assertThat(studentParticipations).isEmpty(); + + // invoke start exercises + studentExamService.startExercises(exam.getId()).join(); + + studentParticipations = participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); + assertThat(studentParticipations).hasSize(12); + + // Make sure delete also works if so many objects have been created before + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteStudentWithParticipationsAndSubmissions() throws Exception { + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + // Create an exam with registered students + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 3); + + // Create individual student exams + List generatedStudentExams = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/generate-student-exams", + Optional.empty(), StudentExam.class, HttpStatus.OK); + + // Get the student exam of student1 + Optional optionalStudent1Exam = generatedStudentExams.stream().filter(studentExam -> studentExam.getUser().equals(student1)).findFirst(); + assertThat(optionalStudent1Exam.orElseThrow()).isNotNull(); + var studentExam1 = optionalStudent1Exam.get(); + + // Start the exam to create participations + ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + List participationsStudent1 = studentParticipationRepository + .findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student1.getId(), studentExam1.getExercises()); + assertThat(participationsStudent1).hasSize(studentExam1.getExercises().size()); + + // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + + // Remove student1 from the exam and his participations + var params = new LinkedMultiValueMap(); + params.add("withParticipationsAndSubmission", "true"); + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK, params); + + // Get the exam with all registered users + params = new LinkedMultiValueMap<>(); + params.add("withStudents", "true"); + Exam storedExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK, Exam.class, params); + + // Ensure that student1 was removed from the exam + var examUser1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()); + assertThat(examUser1).isEmpty(); + assertThat(storedExam.getExamUsers()).hasSize(2); + + // Ensure that the student exam of student1 was deleted + List studentExams = request.getList("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams", HttpStatus.OK, StudentExam.class); + assertThat(studentExams).hasSameSizeAs(storedExam.getExamUsers()).doesNotContain(studentExam1); + + // Ensure that the participations of student1 were deleted + participationsStudent1 = studentParticipationRepository.findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(student1.getId(), + studentExam1.getExercises()); + assertThat(participationsStudent1).isEmpty(); + + // Make sure delete also works if so many objects have been created before + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId(), HttpStatus.OK); + } + + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @ValueSource(ints = { 0, 1, 2 }) + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetStatsForExamAssessmentDashboard(int numberOfCorrectionRounds) throws Exception { + log.debug("testGetStatsForExamAssessmentDashboard: step 1 done"); + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + + User examTutor1 = userRepo.findOneByLogin(TEST_PREFIX + "tutor1").orElseThrow(); + User examTutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); + + var examVisibleDate = ZonedDateTime.now().minusMinutes(5); + var examStartDate = ZonedDateTime.now().plusMinutes(5); + var examEndDate = ZonedDateTime.now().plusMinutes(20); + Course course = courseUtilService.addEmptyCourse(); + Exam exam = examUtilService.addExam(course, examVisibleDate, examStartDate, examEndDate); + exam.setNumberOfCorrectionRoundsInExam(numberOfCorrectionRounds); + exam = examRepository.save(exam); + exam = examUtilService.addExerciseGroupsAndExercisesToExam(exam, false); + + log.debug("testGetStatsForExamAssessmentDashboard: step 2 done"); + + var stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + assertThat(stats.getNumberOfSubmissions()).isInstanceOf(DueDateStat.class); + assertThat(stats.getTutorLeaderboardEntries()).isInstanceOf(List.class); + if (numberOfCorrectionRounds != 0) { + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isInstanceOf(DueDateStat[].class); + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); + } + else { + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isNull(); + } + assertThat(stats.getNumberOfAssessmentLocks()).isZero(); + assertThat(stats.getNumberOfSubmissions().inTime()).isZero(); + if (numberOfCorrectionRounds > 0) { + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); + } + else { + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()).isNull(); + } + assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); + + if (numberOfCorrectionRounds == 0) { + // We do not need any more assertions, as numberOfCorrectionRounds is only 0 for test exams (no manual assessment) + return; + } + + var lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); + assertThat(lockedSubmissions).isEmpty(); + + log.debug("testGetStatsForExamAssessmentDashboard: step 3 done"); + + // register users. Instructors are ignored from scores as they are exclusive for test run exercises + Set registeredStudents = getRegisteredStudentsForExam(); + for (var student : registeredStudents) { + var registeredExamUser = new ExamUser(); + registeredExamUser.setExam(exam); + registeredExamUser.setUser(student); + exam.addExamUser(registeredExamUser); + } + exam.setNumberOfExercisesInExam(exam.getExerciseGroups().size()); + exam.setRandomizeExerciseOrder(false); + exam = examRepository.save(exam); + exam = examRepository.findWithExamUsersAndExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); + + log.debug("testGetStatsForExamAssessmentDashboard: step 4 done"); + + // generate individual student exams + List studentExams = request.postListWithResponseBody("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/generate-student-exams", Optional.empty(), + StudentExam.class, HttpStatus.OK); + int noGeneratedParticipations = ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course); + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + // set start and submitted date as results are created below + studentExams.forEach(studentExam -> { + studentExam.setStartedAndStartDate(ZonedDateTime.now().minusMinutes(2)); + studentExam.setSubmitted(true); + studentExam.setSubmissionDate(ZonedDateTime.now().minusMinutes(1)); + }); + studentExamRepository.saveAll(studentExams); + + log.debug("testGetStatsForExamAssessmentDashboard: step 5 done"); + + // Fetch the created participations and assign them to the exercises + int participationCounter = 0; + List exercisesInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getExercises).flatMap(Collection::stream).toList(); + for (var exercise : exercisesInExam) { + List participations = studentParticipationRepository.findByExerciseIdAndTestRunWithEagerLegalSubmissionsResult(exercise.getId(), false); + exercise.setStudentParticipations(new HashSet<>(participations)); + participationCounter += exercise.getStudentParticipations().size(); + } + assertThat(noGeneratedParticipations).isEqualTo(participationCounter); + + log.debug("testGetStatsForExamAssessmentDashboard: step 6 done"); + + // Assign submissions to the participations + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + assertThat(participation.getSubmissions()).hasSize(1); + Submission submission = participation.getSubmissions().iterator().next(); + submission.submitted(true); + submission.setSubmissionDate(ZonedDateTime.now().minusMinutes(6)); + submissionRepository.save(submission); + } + } + + log.debug("testGetStatsForExamAssessmentDashboard: step 7 done"); + + // check the stats again - check the count of submitted submissions + stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + assertThat(stats.getNumberOfAssessmentLocks()).isZero(); + // 85 = (17 users * 5 exercises); quiz submissions are not counted + assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isZero(); + assertThat(stats.getNumberOfComplaints()).isZero(); + assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); + + // Score used for all exercise results + Double resultScore = 75.0; + + log.debug("testGetStatsForExamAssessmentDashboard: step 7 done"); + + // Lock all submissions + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + Submission submission; + assertThat(participation.getSubmissions()).hasSize(1); + submission = participation.getSubmissions().iterator().next(); + // Create results + var result = new Result().score(resultScore); + if (exercise instanceof QuizExercise) { + result.completionDate(ZonedDateTime.now().minusMinutes(4)); + result.setRated(true); + } + result.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); + result.setParticipation(participation); + result.setAssessor(examTutor1); + result = resultRepository.save(result); + result.setSubmission(submission); + submission.addResult(result); + submissionRepository.save(submission); + } + } + log.debug("testGetStatsForExamAssessmentDashboard: step 8 done"); + + // check the stats again + userUtilService.changeUser(TEST_PREFIX + "tutor1"); + stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + + assertThat(stats.getNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); + // (studentExams.size() users * 5 exercises); quiz submissions are not counted + assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); + // the studentExams.size() quiz submissions are already assessed + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size()); + assertThat(stats.getNumberOfComplaints()).isZero(); + assertThat(stats.getTotalNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); + + log.debug("testGetStatsForExamAssessmentDashboard: step 9 done"); + + // test the query needed for assessment information + userUtilService.changeUser(TEST_PREFIX + "tutor2"); + exam.getExerciseGroups().forEach(group -> { + var locks = group.getExercises().stream().map( + exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[0] + .inTime()) + .reduce(Long::sum).orElseThrow(); + if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) { + assertThat(locks).isEqualTo(studentExams.size()); + } + }); + + log.debug("testGetStatsForExamAssessmentDashboard: step 10 done"); + + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); + assertThat(lockedSubmissions).hasSize(studentExams.size() * 5); + + log.debug("testGetStatsForExamAssessmentDashboard: step 11 done"); + + // Finish assessment of all submissions + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + Submission submission; + assertThat(participation.getSubmissions()).hasSize(1); + submission = participation.getSubmissions().iterator().next(); + var result = submission.getLatestResult().completionDate(ZonedDateTime.now().minusMinutes(5)); + result.setRated(true); + resultRepository.save(result); + } + } + + log.debug("testGetStatsForExamAssessmentDashboard: step 12 done"); + + // check the stats again + stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + assertThat(stats.getNumberOfAssessmentLocks()).isZero(); + // 75 = (15 users * 5 exercises); quiz submissions are not counted + assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); + // 75 + the 19 quiz submissions + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 5L + studentExams.size()); + assertThat(stats.getNumberOfComplaints()).isZero(); + assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); + + log.debug("testGetStatsForExamAssessmentDashboard: step 13 done"); + + lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); + assertThat(lockedSubmissions).isEmpty(); + if (numberOfCorrectionRounds == 2) { + lockAndAssessForSecondCorrection(exam, course, studentExams, exercisesInExam, numberOfCorrectionRounds); + } + + log.debug("testGetStatsForExamAssessmentDashboard: step 14 done"); + } + + private void lockAndAssessForSecondCorrection(Exam exam, Course course, List studentExams, List exercisesInExam, int numberOfCorrectionRounds) + throws Exception { + // Lock all submissions + User examInstructor = userRepo.findOneByLogin(TEST_PREFIX + "instructor1").orElseThrow(); + User examTutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); + + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + assertThat(participation.getSubmissions()).hasSize(1); + Submission submission = participation.getSubmissions().iterator().next(); + // Create results + var result = new Result().score(50D).rated(true); + if (exercise instanceof QuizExercise) { + result.completionDate(ZonedDateTime.now().minusMinutes(3)); + } + result.setAssessmentType(AssessmentType.SEMI_AUTOMATIC); + result.setParticipation(participation); + result.setAssessor(examInstructor); + result = resultRepository.save(result); + result.setSubmission(submission); + submission.addResult(result); + submissionRepository.save(submission); + } + } + // check the stats again + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + var stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + assertThat(stats.getNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); + // 75 = (15 users * 5 exercises); quiz submissions are not counted + assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); + // the 15 quiz submissions are already assessed - and all are assessed in the first correctionRound + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 6L); + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[1].inTime()).isEqualTo(studentExams.size()); + assertThat(stats.getNumberOfComplaints()).isZero(); + assertThat(stats.getTotalNumberOfAssessmentLocks()).isEqualTo(studentExams.size() * 5L); + + // test the query needed for assessment information + userUtilService.changeUser(TEST_PREFIX + "tutor2"); + exam.getExerciseGroups().forEach(group -> { + var locksRound1 = group.getExercises().stream().map( + exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[0] + .inTime()) + .reduce(Long::sum).orElseThrow(); + if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) { + assertThat(locksRound1).isZero(); + } + + var locksRound2 = group.getExercises().stream().map( + exercise -> resultRepository.countNumberOfLockedAssessmentsByOtherTutorsForExamExerciseForCorrectionRounds(exercise, numberOfCorrectionRounds, examTutor2)[1] + .inTime()) + .reduce(Long::sum).orElseThrow(); + if (group.getExercises().stream().anyMatch(exercise -> !(exercise instanceof QuizExercise))) { + assertThat(locksRound2).isEqualTo(studentExams.size()); + } + }); + + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + var lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); + assertThat(lockedSubmissions).hasSize(studentExams.size() * 5); + + // Finish assessment of all submissions + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + Submission submission; + assertThat(participation.getSubmissions()).hasSize(1); + submission = participation.getSubmissions().iterator().next(); + var result = submission.getLatestResult().completionDate(ZonedDateTime.now().minusMinutes(5)); + result.setRated(true); + resultRepository.save(result); + } + } + + // check the stats again + stats = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/stats-for-exam-assessment-dashboard", HttpStatus.OK, StatsForDashboardDTO.class); + assertThat(stats.getNumberOfAssessmentLocks()).isZero(); + // 75 = (15 users * 5 exercises); quiz submissions are not counted + assertThat(stats.getNumberOfSubmissions().inTime()).isEqualTo(studentExams.size() * 5L); + // 75 + the 15 quiz submissions + assertThat(stats.getNumberOfAssessmentsOfCorrectionRounds()[0].inTime()).isEqualTo(studentExams.size() * 6L); + assertThat(stats.getNumberOfComplaints()).isZero(); + assertThat(stats.getTotalNumberOfAssessmentLocks()).isZero(); + + lockedSubmissions = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/lockedSubmissions", HttpStatus.OK, List.class); + assertThat(lockedSubmissions).isEmpty(); + } + + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @CsvSource({ "false, false", "true, false", "false, true", "true, true" }) + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetExamScore(boolean withCourseBonus, boolean withSecondCorrectionAndStarted) throws Exception { + programmingExerciseTestService.setup(this, versionControlService, continuousIntegrationService); + bitbucketRequestMockProvider.enableMockingOfRequests(true); + bambooRequestMockProvider.enableMockingOfRequests(true); + + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + + var visibleDate = ZonedDateTime.now().minusMinutes(5); + var startDate = ZonedDateTime.now().plusMinutes(5); + var endDate = ZonedDateTime.now().plusMinutes(20); + + // register users. Instructors are ignored from scores as they are exclusive for test run exercises + Set registeredStudents = getRegisteredStudentsForExam(); + + var studentExams = programmingExerciseTestService.prepareStudentExamsForConduction(TEST_PREFIX, visibleDate, startDate, endDate, registeredStudents, studentRepos); + Exam exam = examRepository.findByIdWithExamUsersExerciseGroupsAndExercisesElseThrow(studentExams.get(0).getExam().getId()); + Course course = exam.getCourse(); + + Integer noGeneratedParticipations = registeredStudents.size() * exam.getExerciseGroups().size(); + + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + + // instructor exam checklist checks + ExamChecklistDTO examChecklistDTO = examService.getStatsForChecklist(exam, true); + assertThat(examChecklistDTO).isNotNull(); + assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isEqualTo(exam.getExamUsers().size()); + assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isTrue(); + assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isZero(); + + // check that an adapted version is computed for tutors + userUtilService.changeUser(TEST_PREFIX + "tutor1"); + + examChecklistDTO = examService.getStatsForChecklist(exam, false); + assertThat(examChecklistDTO).isNotNull(); + assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isNull(); + assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isFalse(); + assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isZero(); + + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + + // set start and submitted date as results are created below + studentExams.forEach(studentExam -> { + studentExam.setStartedAndStartDate(ZonedDateTime.now().minusMinutes(2)); + studentExam.setSubmitted(true); + studentExam.setSubmissionDate(ZonedDateTime.now().minusMinutes(1)); + }); + studentExamRepository.saveAll(studentExams); + + // Fetch the created participations and assign them to the exercises + int participationCounter = 0; + List exercisesInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getExercises).flatMap(Collection::stream).toList(); + for (var exercise : exercisesInExam) { + List participations = studentParticipationRepository.findByExerciseIdAndTestRunWithEagerLegalSubmissionsResult(exercise.getId(), false); + exercise.setStudentParticipations(new HashSet<>(participations)); + participationCounter += exercise.getStudentParticipations().size(); + } + assertThat(noGeneratedParticipations).isEqualTo(participationCounter); + + if (withSecondCorrectionAndStarted) { + exercisesInExam.forEach(exercise -> exercise.setSecondCorrectionEnabled(true)); + exerciseRepo.saveAll(exercisesInExam); + } + + // Scores used for all exercise results + double correctionResultScore = 60D; + double resultScore = 75D; + + // Assign results to participations and submissions + for (var exercise : exercisesInExam) { + for (var participation : exercise.getStudentParticipations()) { + Submission submission; + // Programming exercises don't have a submission yet + if (exercise instanceof ProgrammingExercise) { + assertThat(participation.getSubmissions()).isEmpty(); + submission = new ProgrammingSubmission(); + submission.setParticipation(participation); + submission = submissionRepository.save(submission); + } + else { + // There should only be one submission for text, quiz, modeling and file upload + assertThat(participation.getSubmissions()).hasSize(1); + submission = participation.getSubmissions().iterator().next(); + } + + // make sure to create submitted answers + if (exercise instanceof QuizExercise quizExercise) { + var quizQuestions = quizExerciseRepository.findByIdWithQuestionsElseThrow(exercise.getId()).getQuizQuestions(); + for (var quizQuestion : quizQuestions) { + var submittedAnswer = QuizExerciseFactory.generateSubmittedAnswerFor(quizQuestion, true); + var quizSubmission = quizSubmissionRepository.findWithEagerSubmittedAnswersById(submission.getId()); + quizSubmission.addSubmittedAnswers(submittedAnswer); + quizSubmissionService.saveSubmissionForExamMode(quizExercise, quizSubmission, participation.getStudent().orElseThrow()); + } + } + + // Create results + if (withSecondCorrectionAndStarted) { + var firstResult = new Result().score(correctionResultScore).rated(true).completionDate(ZonedDateTime.now().minusMinutes(5)); + firstResult.setParticipation(participation); + firstResult.setAssessor(instructor); + firstResult = resultRepository.save(firstResult); + firstResult.setSubmission(submission); + submission.addResult(firstResult); + } + + var finalResult = new Result().score(resultScore).rated(true).completionDate(ZonedDateTime.now().minusMinutes(5)); + finalResult.setParticipation(participation); + finalResult.setAssessor(instructor); + finalResult = resultRepository.save(finalResult); + finalResult.setSubmission(submission); + submission.addResult(finalResult); + + submission.submitted(true); + submission.setSubmissionDate(ZonedDateTime.now().minusMinutes(6)); + submissionRepository.save(submission); + } + } + // explicitly set the user again to prevent issues in the following server call due to the use of SecurityUtils.setAuthorizationObject(); + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + final var exerciseWithNoUsers = TextExerciseFactory.generateTextExerciseForExam(exam.getExerciseGroups().get(0)); + exerciseRepo.save(exerciseWithNoUsers); + + GradingScale gradingScale = gradingScaleUtilService.generateGradingScaleWithStickyStep(new double[] { 60, 25, 15, 50 }, + Optional.of(new String[] { "5.0", "3.0", "1.0", "1.0" }), true, 1); + gradingScale.setExam(exam); + gradingScale = gradingScaleRepository.save(gradingScale); + + waitForParticipantScores(); + + if (withCourseBonus) { + configureCourseAsBonusWithIndividualAndTeamResults(course, gradingScale); + } + + await().timeout(Duration.ofMinutes(1)).until(() -> { + for (Exercise exercise : exercisesInExam) { + if (participantScoreRepository.findAllByExercise(exercise).size() != exercise.getStudentParticipations().size()) { + return false; + } + } + return true; + }); + + var examScores = request.get("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/scores", HttpStatus.OK, ExamScoresDTO.class); + + // Compare generated results to data in ExamScoresDTO + // Compare top-level DTO properties + assertThat(examScores.maxPoints()).isEqualTo(exam.getExamMaxPoints()); + + assertThat(examScores.hasSecondCorrectionAndStarted()).isEqualTo(withSecondCorrectionAndStarted); + + // For calculation assume that all exercises within an exerciseGroups have the same max points + double calculatedAverageScore = 0.0; + for (var exerciseGroup : exam.getExerciseGroups()) { + var exercise = exerciseGroup.getExercises().stream().findAny().orElseThrow(); + if (exercise.getIncludedInOverallScore().equals(IncludedInOverallScore.NOT_INCLUDED)) { + continue; + } + calculatedAverageScore += Math.round(exercise.getMaxPoints() * resultScore / 100.00 * 10) / 10.0; + } + + assertThat(examScores.averagePointsAchieved()).isEqualTo(calculatedAverageScore); + assertThat(examScores.title()).isEqualTo(exam.getTitle()); + assertThat(examScores.examId()).isEqualTo(exam.getId()); + + // Ensure that all exerciseGroups of the exam are present in the DTO + Set exerciseGroupIdsInDTO = examScores.exerciseGroups().stream().map(ExamScoresDTO.ExerciseGroup::id).collect(Collectors.toSet()); + Set exerciseGroupIdsInExam = exam.getExerciseGroups().stream().map(ExerciseGroup::getId).collect(Collectors.toSet()); + assertThat(exerciseGroupIdsInExam).isEqualTo(exerciseGroupIdsInDTO); + + // Compare exerciseGroups in DTO to exam exerciseGroups + // Tolerated absolute difference for floating-point number comparisons + double epsilon = 0000.1; + for (var exerciseGroupDTO : examScores.exerciseGroups()) { + // Find the original exerciseGroup of the exam using the id in ExerciseGroupId + ExerciseGroup originalExerciseGroup = exam.getExerciseGroups().stream().filter(exerciseGroup -> exerciseGroup.getId().equals(exerciseGroupDTO.id())).findFirst() + .orElseThrow(); + + // Assume that all exercises in a group have the same max score + Double groupMaxScoreFromExam = originalExerciseGroup.getExercises().stream().findAny().orElseThrow().getMaxPoints(); + assertThat(exerciseGroupDTO.maxPoints()).isEqualTo(originalExerciseGroup.getExercises().stream().findAny().orElseThrow().getMaxPoints()); + assertThat(groupMaxScoreFromExam).isEqualTo(exerciseGroupDTO.maxPoints(), withPrecision(epsilon)); + + // epsilon + // Compare exercise information + long noOfExerciseGroupParticipations = 0; + for (var originalExercise : originalExerciseGroup.getExercises()) { + // Find the corresponding ExerciseInfo object + var exerciseDTO = exerciseGroupDTO.containedExercises().stream().filter(exerciseInfo -> exerciseInfo.exerciseId().equals(originalExercise.getId())).findFirst() + .orElseThrow(); + // Check the exercise title + assertThat(originalExercise.getTitle()).isEqualTo(exerciseDTO.title()); + // Check the max points of the exercise + assertThat(originalExercise.getMaxPoints()).isEqualTo(exerciseDTO.maxPoints()); + // Check the number of exercise participants and update the group participant counter + var noOfExerciseParticipations = originalExercise.getStudentParticipations().size(); + noOfExerciseGroupParticipations += noOfExerciseParticipations; + assertThat(Long.valueOf(originalExercise.getStudentParticipations().size())).isEqualTo(exerciseDTO.numberOfParticipants()); + } + assertThat(noOfExerciseGroupParticipations).isEqualTo(exerciseGroupDTO.numberOfParticipants()); + } + + // Ensure that all registered students have a StudentResult + Set studentIdsWithStudentResults = examScores.studentResults().stream().map(ExamScoresDTO.StudentResult::userId).collect(Collectors.toSet()); + Set registeredUsers = exam.getRegisteredUsers(); + Set registeredUsersIds = registeredUsers.stream().map(User::getId).collect(Collectors.toSet()); + assertThat(studentIdsWithStudentResults).isEqualTo(registeredUsersIds); + + // Compare StudentResult with the generated results + for (var studentResult : examScores.studentResults()) { + // Find the original user using the id in StudentResult + User originalUser = userRepo.findByIdElseThrow(studentResult.userId()); + StudentExam studentExamOfUser = studentExams.stream().filter(studentExam -> studentExam.getUser().equals(originalUser)).findFirst().orElseThrow(); + + assertThat(studentResult.name()).isEqualTo(originalUser.getName()); + assertThat(studentResult.email()).isEqualTo(originalUser.getEmail()); + assertThat(studentResult.login()).isEqualTo(originalUser.getLogin()); + assertThat(studentResult.registrationNumber()).isEqualTo(originalUser.getRegistrationNumber()); + + // Calculate overall points achieved + + var calculatedOverallPoints = calculateOverallPoints(resultScore, studentExamOfUser); + + assertThat(studentResult.overallPointsAchieved()).isEqualTo(calculatedOverallPoints, withPrecision(epsilon)); + + double expectedPointsAchievedInFirstCorrection = withSecondCorrectionAndStarted ? calculateOverallPoints(correctionResultScore, studentExamOfUser) : 0.0; + assertThat(studentResult.overallPointsAchievedInFirstCorrection()).isEqualTo(expectedPointsAchievedInFirstCorrection, withPrecision(epsilon)); + + // Calculate overall score achieved + var calculatedOverallScore = calculatedOverallPoints / examScores.maxPoints() * 100; + assertThat(studentResult.overallScoreAchieved()).isEqualTo(calculatedOverallScore, withPrecision(epsilon)); + + assertThat(studentResult.overallGrade()).isNotNull(); + assertThat(studentResult.hasPassed()).isNotNull(); + assertThat(studentResult.mostSeverePlagiarismVerdict()).isNull(); + if (withCourseBonus) { + String studentLogin = studentResult.login(); + assertThat(studentResult.gradeWithBonus().bonusStrategy()).isEqualTo(BonusStrategy.GRADES_CONTINUOUS); + switch (studentLogin) { + case TEST_PREFIX + "student1" -> { + assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isNull(); + assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isEqualTo(10.0); + assertThat(studentResult.gradeWithBonus().bonusGrade()).isEqualTo("0.0"); + assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); + } + case TEST_PREFIX + "student2" -> { + assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isEqualTo(PlagiarismVerdict.POINT_DEDUCTION); + assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isEqualTo(10.5); // 10.5 = 8 + 5 * 50% plagiarism point deduction. + assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); + } + case TEST_PREFIX + "student3" -> { + assertThat(studentResult.gradeWithBonus().mostSeverePlagiarismVerdict()).isEqualTo(PlagiarismVerdict.PLAGIARISM); + assertThat(studentResult.gradeWithBonus().studentPointsOfBonusSource()).isZero(); + assertThat(studentResult.gradeWithBonus().bonusGrade()).isEqualTo(GradingScale.DEFAULT_PLAGIARISM_GRADE); + assertThat(studentResult.gradeWithBonus().finalGrade()).isEqualTo("1.0"); + } + default -> { + } + } + } + else { + assertThat(studentResult.gradeWithBonus()).isNull(); + } + + // Ensure that the exercise ids of the student exam are the same as the exercise ids in the students exercise results + Set exerciseIdsOfStudentResult = studentResult.exerciseGroupIdToExerciseResult().values().stream().map(ExamScoresDTO.ExerciseResult::exerciseId) + .collect(Collectors.toSet()); + Set exerciseIdsInStudentExam = studentExamOfUser.getExercises().stream().map(DomainObject::getId).collect(Collectors.toSet()); + assertThat(exerciseIdsOfStudentResult).isEqualTo(exerciseIdsInStudentExam); + for (Map.Entry entry : studentResult.exerciseGroupIdToExerciseResult().entrySet()) { + var exerciseResult = entry.getValue(); + + // Find the original exercise using the id in ExerciseResult + Exercise originalExercise = studentExamOfUser.getExercises().stream().filter(exercise -> exercise.getId().equals(exerciseResult.exerciseId())).findFirst() + .orElseThrow(); + + // Check that the key is associated with the exerciseGroup which actually contains the exercise in the exerciseResult + assertThat(originalExercise.getExerciseGroup().getId()).isEqualTo(entry.getKey()); + + assertThat(exerciseResult.title()).isEqualTo(originalExercise.getTitle()); + assertThat(exerciseResult.maxScore()).isEqualTo(originalExercise.getMaxPoints()); + assertThat(exerciseResult.achievedScore()).isEqualTo(resultScore); + if (originalExercise instanceof QuizExercise) { + assertThat(exerciseResult.hasNonEmptySubmission()).isTrue(); + } + else { + assertThat(exerciseResult.hasNonEmptySubmission()).isFalse(); + } + // TODO: create a test where hasNonEmptySubmission() is false for a quiz + assertThat(exerciseResult.achievedPoints()).isEqualTo(originalExercise.getMaxPoints() * resultScore / 100, withPrecision(epsilon)); + } + } + + // change back to instructor user + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + + var expectedTotalExamAssessmentsFinishedByCorrectionRound = new Long[] { noGeneratedParticipations.longValue(), noGeneratedParticipations.longValue() }; + if (!withSecondCorrectionAndStarted) { + // The second correction has not started in this case. + expectedTotalExamAssessmentsFinishedByCorrectionRound[1] = 0L; + } + + // check if stats are set correctly for the instructor + examChecklistDTO = examService.getStatsForChecklist(exam, true); + assertThat(examChecklistDTO).isNotNull(); + var size = examScores.studentResults().size(); + assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isEqualTo(size); + assertThat(examChecklistDTO.getNumberOfExamsSubmitted()).isEqualTo(size); + assertThat(examChecklistDTO.getNumberOfExamsStarted()).isEqualTo(size); + assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isTrue(); + assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isEqualTo(size * 6L); + assertThat(examChecklistDTO.getNumberOfTestRuns()).isZero(); + assertThat(examChecklistDTO.getNumberOfTotalExamAssessmentsFinishedByCorrectionRound()).hasSize(2).containsExactly(expectedTotalExamAssessmentsFinishedByCorrectionRound); + + // change to a tutor + userUtilService.changeUser(TEST_PREFIX + "tutor1"); + + // check that a modified version is returned + // check if stats are set correctly for the instructor + examChecklistDTO = examService.getStatsForChecklist(exam, false); + assertThat(examChecklistDTO).isNotNull(); + assertThat(examChecklistDTO.getNumberOfGeneratedStudentExams()).isNull(); + assertThat(examChecklistDTO.getNumberOfExamsSubmitted()).isNull(); + assertThat(examChecklistDTO.getNumberOfExamsStarted()).isNull(); + assertThat(examChecklistDTO.getAllExamExercisesAllStudentsPrepared()).isFalse(); + assertThat(examChecklistDTO.getNumberOfTotalParticipationsForAssessment()).isEqualTo(size * 6L); + assertThat(examChecklistDTO.getNumberOfTestRuns()).isNull(); + assertThat(examChecklistDTO.getNumberOfTotalExamAssessmentsFinishedByCorrectionRound()).hasSize(2).containsExactly(expectedTotalExamAssessmentsFinishedByCorrectionRound); + + bambooRequestMockProvider.reset(); + + final ProgrammingExercise programmingExercise = (ProgrammingExercise) exam.getExerciseGroups().get(6).getExercises().iterator().next(); + + var usersOfExam = exam.getRegisteredUsers(); + mockDeleteProgrammingExercise(programmingExercise, usersOfExam); + + await().until(() -> participantScoreScheduleService.isIdle()); + + // change back to instructor user + userUtilService.changeUser(TEST_PREFIX + "instructor1"); + // Make sure delete also works if so many objects have been created before + waitForParticipantScores(); + request.delete("/api/courses/" + course.getId() + "/exams/" + exam.getId(), HttpStatus.OK); + assertThat(examRepository.findById(exam.getId())).isEmpty(); + } + + private void configureCourseAsBonusWithIndividualAndTeamResults(Course course, GradingScale bonusToGradingScale) { + ZonedDateTime pastTimestamp = ZonedDateTime.now().minusDays(5); + TextExercise textExercise = textExerciseUtilService.createIndividualTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); + Long individualTextExerciseId = textExercise.getId(); + textExerciseUtilService.createIndividualTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); + + Exercise teamExercise = textExerciseUtilService.createTeamTextExercise(course, pastTimestamp, pastTimestamp, pastTimestamp); + User tutor1 = userRepo.findOneByLogin(TEST_PREFIX + "tutor1").orElseThrow(); + Long teamTextExerciseId = teamExercise.getId(); + Long team1Id = teamUtilService.createTeam(Set.of(student1), tutor1, teamExercise, TEST_PREFIX + "team1").getId(); + User student2 = userRepo.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + User student3 = userRepo.findOneByLogin(TEST_PREFIX + "student3").orElseThrow(); + User tutor2 = userRepo.findOneByLogin(TEST_PREFIX + "tutor2").orElseThrow(); + Long team2Id = teamUtilService.createTeam(Set.of(student2, student3), tutor2, teamExercise, TEST_PREFIX + "team2").getId(); + + participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student1, 10.0, 10.0, 50, true); + + Team team1 = teamRepository.findById(team1Id).orElseThrow(); + var result = participationUtilService.createParticipationSubmissionAndResult(teamTextExerciseId, team1, 10.0, 10.0, 40, true); + // Creating a second results for team1 to test handling multiple results. + participationUtilService.createSubmissionAndResult((StudentParticipation) result.getParticipation(), 50, true); + + var student2Result = participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student2, 10.0, 10.0, 50, true); + + var student3Result = participationUtilService.createParticipationSubmissionAndResult(individualTextExerciseId, student3, 10.0, 10.0, 30, true); + + Team team2 = teamRepository.findById(team2Id).orElseThrow(); + participationUtilService.createParticipationSubmissionAndResult(teamTextExerciseId, team2, 10.0, 10.0, 80, true); + + // Adding plagiarism cases + var bonusPlagiarismCase = new PlagiarismCase(); + bonusPlagiarismCase.setStudent(student3); + bonusPlagiarismCase.setExercise(student3Result.getParticipation().getExercise()); + bonusPlagiarismCase.setVerdict(PlagiarismVerdict.PLAGIARISM); + plagiarismCaseRepository.save(bonusPlagiarismCase); + + var bonusPlagiarismCase2 = new PlagiarismCase(); + bonusPlagiarismCase2.setStudent(student2); + bonusPlagiarismCase2.setExercise(student2Result.getParticipation().getExercise()); + bonusPlagiarismCase2.setVerdict(PlagiarismVerdict.POINT_DEDUCTION); + bonusPlagiarismCase2.setVerdictPointDeduction(50); + plagiarismCaseRepository.save(bonusPlagiarismCase2); + + BonusStrategy bonusStrategy = BonusStrategy.GRADES_CONTINUOUS; + bonusToGradingScale.setBonusStrategy(bonusStrategy); + gradingScaleRepository.save(bonusToGradingScale); + + GradingScale sourceGradingScale = gradingScaleUtilService.generateGradingScaleWithStickyStep(new double[] { 60, 40, 50 }, Optional.of(new String[] { "0", "0.3", "0.6" }), + true, 1); + sourceGradingScale.setGradeType(GradeType.BONUS); + sourceGradingScale.setCourse(course); + gradingScaleRepository.save(sourceGradingScale); + + var bonus = BonusFactory.generateBonus(bonusStrategy, -1.0, sourceGradingScale.getId(), bonusToGradingScale.getId()); + bonusRepository.save(bonus); + + course.setMaxPoints(100); + course.setPresentationScore(null); + courseRepo.save(course); + + } + + private void waitForParticipantScores() { + participantScoreScheduleService.executeScheduledTasks(); + await().until(() -> participantScoreScheduleService.isIdle()); + } + + private double calculateOverallPoints(Double correctionResultScore, StudentExam studentExamOfUser) { + return studentExamOfUser.getExercises().stream().filter(exercise -> !exercise.getIncludedInOverallScore().equals(IncludedInOverallScore.NOT_INCLUDED)) + .map(Exercise::getMaxPoints).reduce(0.0, (total, maxScore) -> (Math.round((total + maxScore * correctionResultScore / 100) * 10) / 10.0)); + } + + private Set getRegisteredStudentsForExam() { + var registeredStudents = new HashSet(); + for (int i = 1; i <= NUMBER_OF_STUDENTS; i++) { + registeredStudents.add(userUtilService.getUserByLogin(TEST_PREFIX + "student" + i)); + } + for (int i = 1; i <= NUMBER_OF_TUTORS; i++) { + registeredStudents.add(userUtilService.getUserByLogin(TEST_PREFIX + "tutor" + i)); + } + + return registeredStudents; + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamRegistrationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamRegistrationIntegrationTest.java new file mode 100644 index 000000000000..27293f20ffba --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamRegistrationIntegrationTest.java @@ -0,0 +1,396 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +import java.util.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.exam.ExamUser; +import de.tum.in.www1.artemis.domain.metis.conversation.Channel; +import de.tum.in.www1.artemis.repository.ExamRepository; +import de.tum.in.www1.artemis.repository.ExamUserRepository; +import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; +import de.tum.in.www1.artemis.service.dto.StudentDTO; +import de.tum.in.www1.artemis.service.exam.ExamAccessService; +import de.tum.in.www1.artemis.service.exam.ExamRegistrationService; +import de.tum.in.www1.artemis.service.ldap.LdapUserDto; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.service.user.PasswordService; +import de.tum.in.www1.artemis.user.UserFactory; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; + +class ExamRegistrationIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "examregistrationtest"; + + public static final String STUDENT_111 = TEST_PREFIX + "student111"; + + @Autowired + private UserRepository userRepo; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ExamUserRepository examUserRepository; + + @Autowired + private ExamRegistrationService examRegistrationService; + + @Autowired + private PasswordService passwordService; + + @Autowired + private ExamAccessService examAccessService; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + private Course course1; + + private Exam exam1; + + private Exam testExam1; + + private static final int NUMBER_OF_STUDENTS = 3; + + private static final int NUMBER_OF_TUTORS = 1; + + private User student1; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, NUMBER_OF_TUTORS, 0, 1); + // Add a student that is not in the course + userUtilService.createAndSaveUser(TEST_PREFIX + "student42", passwordService.hashPassword(UserFactory.USER_PASSWORD)); + + course1 = courseUtilService.addEmptyCourse(); + student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + + exam1 = examUtilService.addExam(course1); + examUtilService.addExamChannel(exam1, "exam1 channel"); + testExam1 = examUtilService.addTestExam(course1); + examUtilService.addStudentExamForTestExam(testExam1, student1); + + bitbucketRequestMockProvider.enableMockingOfRequests(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; + participantScoreScheduleService.activate(); + } + + @AfterEach + void tearDown() { + bitbucketRequestMockProvider.reset(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 500; + participantScoreScheduleService.shutdown(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRegisterUserInExam_addedToCourseStudentsGroup() throws Exception { + User student42 = userUtilService.getUserByLogin(TEST_PREFIX + "student42"); + jiraRequestMockProvider.enableMockingOfRequests(); + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + bitbucketRequestMockProvider.mockUpdateUserDetails(student42.getLogin(), student42.getEmail(), student42.getName()); + bitbucketRequestMockProvider.mockAddUserToGroups(); + + Set studentsInCourseBefore = userRepo.findAllInGroupWithAuthorities(course1.getStudentGroupName()); + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/students/" + TEST_PREFIX + "student42", null, HttpStatus.OK, null); + Set studentsInCourseAfter = userRepo.findAllInGroupWithAuthorities(course1.getStudentGroupName()); + studentsInCourseBefore.add(student42); + assertThat(studentsInCourseBefore).containsExactlyInAnyOrderElementsOf(studentsInCourseAfter); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddStudentToExam_testExam() throws Exception { + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students/" + TEST_PREFIX + "student42", null, HttpStatus.BAD_REQUEST, + null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRemoveStudentToExam_testExam() throws Exception { + request.delete("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students/" + TEST_PREFIX + "student42", HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRegisterUsersInExam() throws Exception { + jiraRequestMockProvider.enableMockingOfRequests(); + + var savedExam = examUtilService.addExam(course1); + + List registrationNumbers = Arrays.asList("1111111", "1111112", "1111113"); + List students = userUtilService.setRegistrationNumberOfStudents(registrationNumbers, TEST_PREFIX); + + User student1 = students.get(0); + User student2 = students.get(1); + User student3 = students.get(2); + + var registrationNumber3WithTypo = "1111113" + "0"; + var registrationNumber4WithTypo = "1111115" + "1"; + var registrationNumber99 = "1111199"; + var registrationNumber111 = "1111100"; + var emptyRegistrationNumber = ""; + + // mock the ldap service + doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(registrationNumber3WithTypo); + doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(emptyRegistrationNumber); + doReturn(Optional.empty()).when(ldapUserService).findByRegistrationNumber(registrationNumber4WithTypo); + + var ldapUser111Dto = new LdapUserDto().registrationNumber(registrationNumber111).firstName(STUDENT_111).lastName(STUDENT_111).username(STUDENT_111) + .email(STUDENT_111 + "@tum.de"); + doReturn(Optional.of(ldapUser111Dto)).when(ldapUserService).findByRegistrationNumber(registrationNumber111); + + // first mocked call is expected to add student 99 to the course student group + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + // second mocked call expected to create student 111 + jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser111Dto.getUsername(), ldapUser111Dto.getFirstName() + " " + ldapUser111Dto.getLastName(), + ldapUser111Dto.getEmail()); + // the last mocked call is expected to add student 111 to the course student group + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + + User student99 = userUtilService.createAndSaveUser("student99"); // not registered for the course + userUtilService.setRegistrationNumberOfUserAndSave("student99", registrationNumber99); + + bitbucketRequestMockProvider.mockUpdateUserDetails(student99.getLogin(), student99.getEmail(), student99.getName()); + bitbucketRequestMockProvider.mockAddUserToGroups(); + student99 = userRepo.findOneWithGroupsAndAuthoritiesByLogin("student99").orElseThrow(); + assertThat(student99.getGroups()).doesNotContain(course1.getStudentGroupName()); + + // Note: student111 is not yet a user of Artemis and should be retrieved from the LDAP + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/" + TEST_PREFIX + "student1", null, HttpStatus.OK, null); + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/nonExistingStudent", null, HttpStatus.NOT_FOUND, null); + + Exam storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); + ExamUser examUserStudent1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()).orElseThrow(); + assertThat(storedExam.getExamUsers()).containsExactly(examUserStudent1); + + request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.OK); + request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students/nonExistingStudent", HttpStatus.NOT_FOUND); + storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); + assertThat(storedExam.getExamUsers()).isEmpty(); + + var studentDto1 = UserFactory.generateStudentDTOWithRegistrationNumber(student1.getRegistrationNumber()); + var studentDto2 = UserFactory.generateStudentDTOWithRegistrationNumber(student2.getRegistrationNumber()); + var studentDto3 = new StudentDTO(student3.getLogin(), null, null, registrationNumber3WithTypo, null); // explicit typo, should be a registration failure later + var studentDto4 = UserFactory.generateStudentDTOWithRegistrationNumber(registrationNumber4WithTypo); // explicit typo, should fall back to login name later + var studentDto10 = UserFactory.generateStudentDTOWithRegistrationNumber(null); // completely empty + + var studentDto99 = new StudentDTO(student99.getLogin(), null, null, registrationNumber99, null); + var studentDto111 = new StudentDTO(null, null, null, registrationNumber111, null); + + // Add a student with login but empty registration number + var studentsToRegister = List.of(studentDto1, studentDto2, studentDto3, studentDto4, studentDto99, studentDto111, studentDto10); + + // now we register all these students for the exam. + List registrationFailures = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students", + studentsToRegister, StudentDTO.class, HttpStatus.OK); + // all students get registered if they can be found in the LDAP + assertThat(registrationFailures).containsExactlyInAnyOrder(studentDto4, studentDto10); + + // TODO check audit events stored properly + + storedExam = examRepository.findWithExamUsersById(savedExam.getId()).orElseThrow(); + + // now a new user student101 should exist + var student111 = userUtilService.getUserByLogin(STUDENT_111); + + var examUser1 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student1.getId()).orElseThrow(); + var examUser2 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student2.getId()).orElseThrow(); + var examUser3 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student3.getId()).orElseThrow(); + var examUser99 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student99.getId()).orElseThrow(); + var examUser111 = examUserRepository.findByExamIdAndUserId(storedExam.getId(), student111.getId()).orElseThrow(); + + assertThat(storedExam.getExamUsers()).containsExactlyInAnyOrder(examUser1, examUser2, examUser3, examUser99, examUser111); + + for (var examUser : storedExam.getExamUsers()) { + // all registered users must have access to the course + var user = userRepo.findOneWithGroupsAndAuthoritiesByLogin(examUser.getUser().getLogin()).orElseThrow(); + assertThat(user.getGroups()).contains(course1.getStudentGroupName()); + } + + // Make sure delete also works if so many objects have been created before + request.delete("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId(), HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRegisterLDAPUsersInExam() throws Exception { + jiraRequestMockProvider.enableMockingOfRequests(); + var savedExam = examUtilService.addExam(course1); + String student100 = TEST_PREFIX + "student100"; + String student200 = TEST_PREFIX + "student200"; + String student300 = TEST_PREFIX + "student300"; + + // setup mocks + var ldapUser1Dto = new LdapUserDto().firstName(student100).lastName(student100).username(student100).registrationNumber("100000").email(student100 + "@tum.de"); + doReturn(Optional.of(ldapUser1Dto)).when(ldapUserService).findByUsername(student100); + jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser1Dto.getUsername(), ldapUser1Dto.getFirstName() + " " + ldapUser1Dto.getLastName(), null); + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + + var ldapUser2Dto = new LdapUserDto().firstName(student200).lastName(student200).username(student200).registrationNumber("200000").email(student200 + "@tum.de"); + doReturn(Optional.of(ldapUser2Dto)).when(ldapUserService).findByEmail(student200 + "@tum.de"); + jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser2Dto.getUsername(), ldapUser2Dto.getFirstName() + " " + ldapUser2Dto.getLastName(), null); + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + + var ldapUser3Dto = new LdapUserDto().firstName(student300).lastName(student300).username(student300).registrationNumber("3000000").email(student300 + "@tum.de"); + doReturn(Optional.of(ldapUser3Dto)).when(ldapUserService).findByRegistrationNumber("3000000"); + jiraRequestMockProvider.mockCreateUserInExternalUserManagement(ldapUser3Dto.getUsername(), ldapUser3Dto.getFirstName() + " " + ldapUser3Dto.getLastName(), null); + jiraRequestMockProvider.mockAddUserToGroup(course1.getStudentGroupName(), false); + + // user with login + StudentDTO dto1 = new StudentDTO(student100, student100, student100, null, null); + // user with email + StudentDTO dto2 = new StudentDTO(null, student200, student200, null, student200 + "@tum.de"); + // user with registration number + StudentDTO dto3 = new StudentDTO(null, student300, student300, "3000000", null); + // user without anything + StudentDTO dto4 = new StudentDTO(null, null, null, null, null); + + List registrationFailures = request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + savedExam.getId() + "/students", + List.of(dto1, dto2, dto3, dto4), StudentDTO.class, HttpStatus.OK); + assertThat(registrationFailures).containsExactly(dto4); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddStudentsToExam_testExam() throws Exception { + userUtilService.setRegistrationNumberOfUserAndSave(TEST_PREFIX + "student1", "1111111"); + + StudentDTO studentDto1 = UserFactory.generateStudentDTOWithRegistrationNumber("1111111"); + List studentDTOS = List.of(studentDto1); + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students", studentDTOS, StudentDTO.class, HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRemoveAllStudentsFromExam_testExam() throws Exception { + request.delete("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/students", HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteStudentThatDoesNotExist() throws Exception { + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 1); + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/nonExistingStudent", HttpStatus.NOT_FOUND); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAddAllRegisteredUsersToExam() throws Exception { + Exam exam = examUtilService.addExam(course1); + Channel channel = examUtilService.addExamChannel(exam, "testchannel"); + int numberOfStudentsInCourse = userRepo.findAllInGroup(course1.getStudentGroupName()).size(); + + User student99 = userUtilService.createAndSaveUser(TEST_PREFIX + "student99"); // not registered for the course + student99.setGroups(Collections.singleton("tumuser")); + userUtilService.setRegistrationNumberOfUserAndSave(student99, "1234"); + assertThat(student99.getGroups()).contains(course1.getStudentGroupName()); + + var examUser99 = examUserRepository.findByExamIdAndUserId(exam.getId(), student99.getId()); + assertThat(examUser99).isEmpty(); + + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/register-course-students", null, HttpStatus.OK, null); + + exam = examRepository.findWithExamUsersById(exam.getId()).orElseThrow(); + examUser99 = examUserRepository.findByExamIdAndUserId(exam.getId(), student99.getId()); + + // the course students + our custom student99 + assertThat(exam.getExamUsers()).hasSize(numberOfStudentsInCourse + 1); + assertThat(exam.getExamUsers()).contains(examUser99.orElseThrow()); + verify(examAccessService).checkCourseAndExamAccessForInstructorElseThrow(course1.getId(), exam.getId()); + + Channel channelFromDB = channelRepository.findChannelByExamId(exam.getId()); + assertThat(channelFromDB).isNotNull(); + assertThat(channelFromDB.getExam()).isEqualTo(exam); + assertThat(channelFromDB.getName()).isEqualTo(channel.getName()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRegisterCourseStudents_testExam() throws Exception { + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/register-course-students", null, HttpStatus.BAD_REQUEST, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testIsUserRegisteredForExam() { + var examUser = new ExamUser(); + examUser.setExam(exam1); + examUser.setUser(student1); + examUser = examUserRepository.save(examUser); + exam1.addExamUser(examUser); + final var exam = examRepository.save(exam1); + final var isUserRegistered = examRegistrationService.isUserRegisteredForExam(exam.getId(), student1.getId()); + final var isCurrentUserRegistered = examRegistrationService.isCurrentUserRegisteredForExam(exam.getId()); + assertThat(isUserRegistered).isTrue(); + assertThat(isCurrentUserRegistered).isFalse(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRegisterInstructorToExam() throws Exception { + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/students/" + TEST_PREFIX + "instructor1", null, HttpStatus.FORBIDDEN, null); + } + + // ExamRegistration Service - checkRegistrationOrRegisterStudentToTestExam + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testCheckRegistrationOrRegisterStudentToTestExam_noTestExam() { + assertThatThrownBy( + () -> examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, exam1.getId(), userUtilService.getUserByLogin(TEST_PREFIX + "student1"))) + .isInstanceOf(BadRequestAlertException.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") + void testCheckRegistrationOrRegisterStudentToTestExam_studentNotPartOfCourse() { + assertThatThrownBy( + () -> examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, exam1.getId(), userUtilService.getUserByLogin(TEST_PREFIX + "student42"))) + .isInstanceOf(BadRequestAlertException.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testCheckRegistrationOrRegisterStudentToTestExam_successfulRegistration() { + Exam testExam = ExamFactory.generateTestExam(course1); + testExam = examRepository.save(testExam); + var examUser = new ExamUser(); + examUser.setExam(testExam); + examUser.setUser(student1); + examUser = examUserRepository.save(examUser); + testExam.addExamUser(examUser); + testExam = examRepository.save(testExam); + examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course1, testExam.getId(), student1); + Exam testExamReloaded = examRepository.findByIdWithExamUsersElseThrow(testExam.getId()); + assertThat(testExamReloaded.getExamUsers()).contains(examUser); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java new file mode 100644 index 000000000000..99e8eef5ecf0 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamStartTest.java @@ -0,0 +1,281 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.enumeration.DiagramType; +import de.tum.in.www1.artemis.domain.exam.*; +import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; +import de.tum.in.www1.artemis.domain.modeling.ModelingSubmission; +import de.tum.in.www1.artemis.domain.participation.Participation; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; +import de.tum.in.www1.artemis.exercise.modelingexercise.ModelingExerciseFactory; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseFactory; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseTestService; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseFactory; +import de.tum.in.www1.artemis.participation.ParticipationUtilService; +import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlRepositoryPermission; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.ExamPrepareExercisesTestUtil; + +// TODO IMPORTANT test more complex exam configurations (mixed exercise type, more variants and more registered students) +class ExamStartTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "examstarttest"; + + @Autowired + private ExerciseRepository exerciseRepo; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ExerciseGroupRepository exerciseGroupRepository; + + @Autowired + private StudentExamRepository studentExamRepository; + + @Autowired + private ParticipationTestRepository participationTestRepository; + + @Autowired + private ProgrammingExerciseTestService programmingExerciseTestService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private ParticipationUtilService participationUtilService; + + private Course course1; + + private Exam exam; + + private static final int NUMBER_OF_STUDENTS = 2; + + private Set registeredUsers; + + private final List createdStudentExams = new ArrayList<>(); + + @BeforeEach + void initTestCase() throws GitAPIException { + userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, 0, 0, 1); + + course1 = courseUtilService.addEmptyCourse(); + exam = examUtilService.addExamWithExerciseGroup(course1, true); + bitbucketRequestMockProvider.enableMockingOfRequests(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; + participantScoreScheduleService.activate(); + + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + + // registering users + User student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); + registeredUsers = Set.of(student1, student2); + exam.setExamUsers(Set.of(new ExamUser())); + // setting dates + exam.setStartDate(ZonedDateTime.now().plusHours(2)); + exam.setEndDate(ZonedDateTime.now().plusHours(3)); + exam.setVisibleDate(ZonedDateTime.now().plusHours(1)); + } + + @AfterEach + void tearDown() throws Exception { + bitbucketRequestMockProvider.reset(); + bambooRequestMockProvider.reset(); + if (programmingExerciseTestService.exerciseRepo != null) { + programmingExerciseTestService.tearDown(); + } + + // Cleanup of Bidirectional Relationships + for (StudentExam studentExam : createdStudentExams) { + exam.removeStudentExam(studentExam); + } + examRepository.save(exam); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testStartExercisesWithTextExercise() throws Exception { + // creating exercise + ExerciseGroup exerciseGroup = exam.getExerciseGroups().get(0); + + TextExercise textExercise = TextExerciseFactory.generateTextExerciseForExam(exerciseGroup); + exerciseGroup.addExercise(textExercise); + exerciseGroupRepository.save(exerciseGroup); + textExercise = exerciseRepo.save(textExercise); + + createStudentExams(textExercise); + + List studentParticipations = invokePrepareExerciseStart(); + + for (Participation participation : studentParticipations) { + assertThat(participation.getExercise()).isEqualTo(textExercise); + assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); + assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam.getExerciseGroups().get(0)); + assertThat(participation.getSubmissions()).hasSize(1); + var textSubmission = (TextSubmission) participation.getSubmissions().iterator().next(); + assertThat(textSubmission.getText()).isNull(); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testStartExercisesWithModelingExercise() throws Exception { + // creating exercise + ModelingExercise modelingExercise = ModelingExerciseFactory.generateModelingExerciseForExam(DiagramType.ClassDiagram, exam.getExerciseGroups().get(0)); + exam.getExerciseGroups().get(0).addExercise(modelingExercise); + exerciseGroupRepository.save(exam.getExerciseGroups().get(0)); + modelingExercise = exerciseRepo.save(modelingExercise); + + createStudentExams(modelingExercise); + + List studentParticipations = invokePrepareExerciseStart(); + + for (Participation participation : studentParticipations) { + assertThat(participation.getExercise()).isEqualTo(modelingExercise); + assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); + assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam.getExerciseGroups().get(0)); + assertThat(participation.getSubmissions()).hasSize(1); + var modelingSubmission = (ModelingSubmission) participation.getSubmissions().iterator().next(); + assertThat(modelingSubmission.getModel()).isNull(); + assertThat(modelingSubmission.getExplanationText()).isNull(); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testStartExerciseWithProgrammingExercise() throws Exception { + bitbucketRequestMockProvider.enableMockingOfRequests(true); + bambooRequestMockProvider.enableMockingOfRequests(true); + + ProgrammingExercise programmingExercise = createProgrammingExercise(); + + participationUtilService.mockCreationOfExerciseParticipation(programmingExercise, versionControlService, continuousIntegrationService); + + createStudentExams(programmingExercise); + + var studentParticipations = invokePrepareExerciseStart(); + + for (Participation participation : studentParticipations) { + assertThat(participation.getExercise()).isEqualTo(programmingExercise); + assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); + assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam.getExerciseGroups().get(0)); + // No initial submissions should be created for programming exercises + assertThat(participation.getSubmissions()).isEmpty(); + assertThat(((ProgrammingExerciseParticipation) participation).isLocked()).isTrue(); + verify(versionControlService, never()).configureRepository(eq(programmingExercise), (ProgrammingExerciseStudentParticipation) eq(participation), eq(true)); + } + } + + private static class ExamStartDateSource implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of(Arguments.of(ZonedDateTime.now().minusHours(1)), // after exam start + Arguments.arguments(ZonedDateTime.now().plusMinutes(3)) // before exam start but after pe unlock date + ); + } + } + + @ParameterizedTest(name = "{displayName} [{index}]") + @ArgumentsSource(ExamStartDateSource.class) + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testStartExerciseWithProgrammingExercise_participationUnlocked(ZonedDateTime startDate) throws Exception { + exam.setVisibleDate(ZonedDateTime.now().minusHours(2)); + exam.setStartDate(startDate); + examRepository.save(exam); + + bitbucketRequestMockProvider.enableMockingOfRequests(true); + bambooRequestMockProvider.enableMockingOfRequests(true); + + ProgrammingExercise programmingExercise = createProgrammingExercise(); + + participationUtilService.mockCreationOfExerciseParticipation(programmingExercise, versionControlService, continuousIntegrationService); + + createStudentExams(programmingExercise); + + var studentParticipations = invokePrepareExerciseStart(); + + for (Participation participation : studentParticipations) { + assertThat(participation.getExercise()).isEqualTo(programmingExercise); + assertThat(participation.getExercise().getCourseViaExerciseGroupOrCourseMember()).isNotNull(); + assertThat(participation.getExercise().getExerciseGroup()).isEqualTo(exam.getExerciseGroups().get(0)); + // No initial submissions should be created for programming exercises + assertThat(participation.getSubmissions()).isEmpty(); + ProgrammingExerciseStudentParticipation studentParticipation = (ProgrammingExerciseStudentParticipation) participation; + // The participation should not get locked if it gets created after the exam already started + assertThat(studentParticipation.isLocked()).isFalse(); + verify(versionControlService).addMemberToRepository(studentParticipation.getVcsRepositoryUrl(), studentParticipation.getStudent().orElseThrow(), + VersionControlRepositoryPermission.REPO_WRITE); + } + } + + private void createStudentExams(Exercise exercise) { + // creating student exams + for (User user : registeredUsers) { + StudentExam studentExam = new StudentExam(); + studentExam.addExercise(exercise); + studentExam.setUser(user); + exam.addStudentExam(studentExam); + createdStudentExams.add(studentExamRepository.save(studentExam)); + } + + exam = examRepository.save(exam); + } + + private ProgrammingExercise createProgrammingExercise() { + ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exam.getExerciseGroups().get(0)); + programmingExercise = exerciseRepo.save(programmingExercise); + programmingExercise = programmingExerciseUtilService.addTemplateParticipationForProgrammingExercise(programmingExercise); + exam.getExerciseGroups().get(0).addExercise(programmingExercise); + exerciseGroupRepository.save(exam.getExerciseGroups().get(0)); + return programmingExercise; + } + + private List invokePrepareExerciseStart() throws Exception { + // invoke start exercises + int noGeneratedParticipations = ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam, course1); + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + assertThat(noGeneratedParticipations).isEqualTo(exam.getStudentExams().size()); + return participationTestRepository.findByExercise_ExerciseGroup_Exam_Id(exam.getId()); + } + +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamUserIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamUserIntegrationTest.java index f32b3358d11d..9c93d719942a 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamUserIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamUserIntegrationTest.java @@ -7,10 +7,7 @@ import java.io.File; import java.io.FileInputStream; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import javax.validation.constraints.NotNull; @@ -19,10 +16,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; +import org.springframework.http.*; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -39,7 +33,9 @@ import de.tum.in.www1.artemis.domain.exam.ExamUser; import de.tum.in.www1.artemis.domain.exam.StudentExam; import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseTestService; -import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.repository.ExamRepository; +import de.tum.in.www1.artemis.repository.StudentExamRepository; +import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.util.LocalRepository; import de.tum.in.www1.artemis.web.rest.dto.ExamUserAttendanceCheckDTO; diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java index 10932a3ba61e..c40de1f9b855 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java @@ -794,4 +794,23 @@ public StudentExam addExercisesWithParticipationsAndSubmissionsToStudentExam(Exa return studentExamRepository.save(studentExam); } + + /** + * gets the number of programming exercises in the exam + * + * @param examId id of the exam to be searched for programming exercises + * @return number of programming exercises in the exams + */ + public int getNumberOfProgrammingExercises(Long examId) { + Exam exam = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId); + int count = 0; + for (var exerciseGroup : exam.getExerciseGroups()) { + for (var exercise : exerciseGroup.getExercises()) { + if (exercise instanceof ProgrammingExercise) { + count++; + } + } + } + return count; + } } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExerciseGroupIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExerciseGroupIntegrationTest.java index 2e70b4d1a582..2b0e6eaee2bf 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExerciseGroupIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExerciseGroupIntegrationTest.java @@ -4,9 +4,7 @@ import static org.mockito.Mockito.*; import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; +import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,6 +25,7 @@ import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseFactory; import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseFactory; +import de.tum.in.www1.artemis.exercise.textexercise.TextExerciseUtilService; import de.tum.in.www1.artemis.repository.ExamRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.repository.TextExerciseRepository; @@ -55,6 +54,9 @@ class ExerciseGroupIntegrationTest extends AbstractSpringIntegrationBambooBitbuc @Autowired private ExamUtilService examUtilService; + @Autowired + private TextExerciseUtilService textExerciseUtilService; + private Course course1; private Exam exam1; @@ -276,4 +278,53 @@ void importExerciseGroup_preCheckFailed() throws Exception { request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/import-exercise-group", List.of(programmingGroup), ExerciseGroup.class, HttpStatus.BAD_REQUEST); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateOrderOfExerciseGroups() throws Exception { + Exam exam = ExamFactory.generateExam(course1); + ExerciseGroup exerciseGroup1 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "first"); + ExerciseGroup exerciseGroup2 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "second"); + ExerciseGroup exerciseGroup3 = ExamFactory.generateExerciseGroupWithTitle(true, exam, "third"); + examRepository.save(exam); + + TextExercise exercise1_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup1); + TextExercise exercise1_2 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup1); + TextExercise exercise2_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup2); + TextExercise exercise3_1 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); + TextExercise exercise3_2 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); + TextExercise exercise3_3 = textExerciseUtilService.createTextExerciseForExam(exerciseGroup3); + + List orderedExerciseGroups = new ArrayList<>(List.of(exerciseGroup2, exerciseGroup3, exerciseGroup1)); + // Should save new order + request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.OK); + verify(examAccessService).checkCourseAndExamAccessForEditorElseThrow(course1.getId(), exam.getId()); + + List savedExerciseGroups = examRepository.findWithExerciseGroupsById(exam.getId()).orElseThrow().getExerciseGroups(); + assertThat(savedExerciseGroups.get(0).getTitle()).isEqualTo("second"); + assertThat(savedExerciseGroups.get(1).getTitle()).isEqualTo("third"); + assertThat(savedExerciseGroups.get(2).getTitle()).isEqualTo("first"); + + // Exercises should be preserved + Exam savedExam = examRepository.findWithExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); + ExerciseGroup savedExerciseGroup1 = savedExam.getExerciseGroups().get(2); + ExerciseGroup savedExerciseGroup2 = savedExam.getExerciseGroups().get(0); + ExerciseGroup savedExerciseGroup3 = savedExam.getExerciseGroups().get(1); + assertThat(savedExerciseGroup1.getExercises()).containsExactlyInAnyOrder(exercise1_1, exercise1_2); + assertThat(savedExerciseGroup2.getExercises()).containsExactlyInAnyOrder(exercise2_1); + assertThat(savedExerciseGroup3.getExercises()).containsExactlyInAnyOrder(exercise3_1, exercise3_2, exercise3_3); + + // Should fail with too many exercise groups + orderedExerciseGroups.add(exerciseGroup1); + request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); + + // Should fail with too few exercise groups + orderedExerciseGroups.remove(3); + orderedExerciseGroups.remove(2); + request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); + + // Should fail with different exercise group + orderedExerciseGroups = Arrays.asList(exerciseGroup2, exerciseGroup3, ExamFactory.generateExerciseGroup(true, exam)); + request.put("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/exercise-groups-order", orderedExerciseGroups, HttpStatus.BAD_REQUEST); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ProgrammingExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ProgrammingExamIntegrationTest.java new file mode 100644 index 000000000000..e3cfcb57bc23 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/ProgrammingExamIntegrationTest.java @@ -0,0 +1,295 @@ +package de.tum.in.www1.artemis.exam; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; +import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseFactory; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseTestService; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.participation.ParticipationUtilService; +import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.util.ExamPrepareExercisesTestUtil; + +class ProgrammingExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "programmingexamtest"; + + @Autowired + private ExerciseRepository exerciseRepo; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private StudentExamRepository studentExamRepository; + + @Autowired + private ProgrammingExerciseRepository programmingExerciseRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ProgrammingExerciseTestService programmingExerciseTestService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private ParticipationUtilService participationUtilService; + + private Course course1; + + private Exam exam1; + + private static final int NUMBER_OF_STUDENTS = 2; + + private static final int NUMBER_OF_TUTORS = 1; + + private User student1; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, NUMBER_OF_TUTORS, 0, 1); + + course1 = courseUtilService.addEmptyCourse(); + student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + exam1 = examUtilService.addExam(course1); + + bitbucketRequestMockProvider.enableMockingOfRequests(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; + participantScoreScheduleService.activate(); + } + + @AfterEach + void tearDown() throws Exception { + bitbucketRequestMockProvider.reset(); + bambooRequestMockProvider.reset(); + if (programmingExerciseTestService.exerciseRepo != null) { + programmingExerciseTestService.tearDown(); + } + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 500; + participantScoreScheduleService.shutdown(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateExam_rescheduleProgramming_visibleAndStartDateChanged() throws Exception { + // Add a programming exercise to the exam and change the dates in order to invoke a rescheduling + var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); + var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); + + ZonedDateTime visibleDate = examWithProgrammingEx.getVisibleDate(); + ZonedDateTime startDate = examWithProgrammingEx.getStartDate(); + ZonedDateTime endDate = examWithProgrammingEx.getEndDate(); + examUtilService.setVisibleStartAndEndDateOfExam(examWithProgrammingEx, visibleDate.plusSeconds(1), startDate.plusSeconds(1), endDate); + + request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); + verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateExam_rescheduleProgramming_visibleDateChanged() throws Exception { + var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); + var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); + examWithProgrammingEx.setVisibleDate(examWithProgrammingEx.getVisibleDate().plusSeconds(1)); + request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); + verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateExam_rescheduleProgramming_startDateChanged() throws Exception { + var programmingEx = programmingExerciseUtilService.addCourseExamExerciseGroupWithOneProgrammingExerciseAndTestCases(); + var examWithProgrammingEx = programmingEx.getExerciseGroup().getExam(); + + ZonedDateTime visibleDate = examWithProgrammingEx.getVisibleDate(); + ZonedDateTime startDate = examWithProgrammingEx.getStartDate(); + ZonedDateTime endDate = examWithProgrammingEx.getEndDate(); + examUtilService.setVisibleStartAndEndDateOfExam(examWithProgrammingEx, visibleDate, startDate.plusSeconds(1), endDate); + + request.put("/api/courses/" + examWithProgrammingEx.getCourse().getId() + "/exams", examWithProgrammingEx, HttpStatus.OK); + verify(instanceMessageSendService).sendProgrammingExerciseSchedule(programmingEx.getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void lockAllRepositories() throws Exception { + Exam exam = examUtilService.addExamWithExerciseGroup(course1, true); + + Exam examWithExerciseGroups = examRepository.findWithExerciseGroupsAndExercisesById(exam.getId()).orElseThrow(); + ExerciseGroup exerciseGroup1 = examWithExerciseGroups.getExerciseGroups().get(0); + + ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); + programmingExerciseRepository.save(programmingExercise); + + ProgrammingExercise programmingExercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); + programmingExerciseRepository.save(programmingExercise2); + + Integer numOfLockedExercises = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams/lock-all-repositories", + Optional.empty(), Integer.class, HttpStatus.OK); + + assertThat(numOfLockedExercises).isEqualTo(2); + + verify(programmingExerciseScheduleService).lockAllStudentRepositories(programmingExercise); + verify(programmingExerciseScheduleService).lockAllStudentRepositories(programmingExercise2); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void lockAllRepositories_noInstructor() throws Exception { + request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/student-exams/lock-all-repositories", Optional.empty(), Integer.class, + HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void unlockAllRepositories_preAuthNoInstructor() throws Exception { + request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/student-exams/unlock-all-repositories", Optional.empty(), Integer.class, + HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void unlockAllRepositories() throws Exception { + bitbucketRequestMockProvider.enableMockingOfRequests(true); + assertThat(studentExamRepository.findStudentExam(new ProgrammingExercise(), null)).isEmpty(); + + Exam exam = examUtilService.addExamWithExerciseGroup(course1, true); + ExerciseGroup exerciseGroup1 = exam.getExerciseGroups().get(0); + + ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); + programmingExerciseRepository.save(programmingExercise); + + ProgrammingExercise programmingExercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup1); + programmingExerciseRepository.save(programmingExercise2); + + User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); + var studentExam1 = examUtilService.addStudentExamWithUser(exam, student1, 10); + studentExam1.setExercises(List.of(programmingExercise, programmingExercise2)); + var studentExam2 = examUtilService.addStudentExamWithUser(exam, student2, 0); + studentExam2.setExercises(List.of(programmingExercise, programmingExercise2)); + studentExamRepository.saveAll(Set.of(studentExam1, studentExam2)); + + var participationExSt1 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + var participationExSt2 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student2"); + + var participationEx2St1 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise2, TEST_PREFIX + "student1"); + var participationEx2St2 = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise2, TEST_PREFIX + "student2"); + + assertThat(studentExamRepository.findStudentExam(programmingExercise, participationExSt1)).contains(studentExam1); + assertThat(studentExamRepository.findStudentExam(programmingExercise, participationExSt2)).contains(studentExam2); + assertThat(studentExamRepository.findStudentExam(programmingExercise2, participationEx2St1)).contains(studentExam1); + assertThat(studentExamRepository.findStudentExam(programmingExercise2, participationEx2St2)).contains(studentExam2); + + mockConfigureRepository(programmingExercise, TEST_PREFIX + "student1", Set.of(student1), true); + mockConfigureRepository(programmingExercise, TEST_PREFIX + "student2", Set.of(student2), true); + mockConfigureRepository(programmingExercise2, TEST_PREFIX + "student1", Set.of(student1), true); + mockConfigureRepository(programmingExercise2, TEST_PREFIX + "student2", Set.of(student2), true); + + Integer numOfUnlockedExercises = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams/unlock-all-repositories", + Optional.empty(), Integer.class, HttpStatus.OK); + + assertThat(numOfUnlockedExercises).isEqualTo(2); + + verify(programmingExerciseScheduleService).unlockAllStudentRepositories(programmingExercise); + verify(programmingExerciseScheduleService).unlockAllStudentRepositories(programmingExercise2); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGenerateStudentExamsTemplateCombine() throws Exception { + Exam examWithProgramming = examUtilService.addExerciseGroupsAndExercisesToExam(exam1, true); + doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); + + // invoke generate student exams + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + examWithProgramming.getId() + "/generate-student-exams", Optional.empty(), + StudentExam.class, HttpStatus.OK); + + verify(gitService, never()).combineAllCommitsOfRepositoryIntoOne(any()); + + // invoke prepare exercise start + ExamPrepareExercisesTestUtil.prepareExerciseStart(request, exam1, course1); + + verify(gitService, times(examUtilService.getNumberOfProgrammingExercises(exam1.getId()))).combineAllCommitsOfRepositoryIntoOne(any()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testImportExamWithProgrammingExercise_preCheckFailed() throws Exception { + Exam exam = ExamFactory.generateExam(course1); + ExerciseGroup programmingGroup = ExamFactory.generateExerciseGroup(false, exam); + exam = examRepository.save(exam); + exam.setId(null); + ProgrammingExercise programming = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(programmingGroup, ProgrammingLanguage.JAVA); + programmingGroup.addExercise(programming); + exerciseRepo.save(programming); + + doReturn(true).when(versionControlService).checkIfProjectExists(any(), any()); + doReturn(null).when(continuousIntegrationService).checkIfProjectExists(any(), any()); + + request.getMvc().perform(post("/api/courses/" + course1.getId() + "/exam-import").contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(exam))) + .andExpect(status().isBadRequest()) + .andExpect(result -> assertThat(result.getResolvedException()).hasMessage("Exam contains programming exercise(s) with invalid short name.")); + } + + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @CsvSource({ "A,A,B,C", "A,B,C,C", "A,A,B,B" }) + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testImportExamWithExercises_programmingExerciseSameShortNameOrTitle(String shortName1, String shortName2, String title1, String title2) throws Exception { + Exam exam = ExamFactory.generateExamWithExerciseGroup(course1, true); + ExerciseGroup exerciseGroup = exam.getExerciseGroups().get(0); + ProgrammingExercise exercise1 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup); + ProgrammingExercise exercise2 = ProgrammingExerciseFactory.generateProgrammingExerciseForExam(exerciseGroup); + + exercise1.setShortName(shortName1); + exercise2.setShortName(shortName2); + exercise1.setTitle(title1); + exercise2.setTitle(title2); + + request.postWithoutLocation("/api/courses/" + course1.getId() + "/exam-import", exam, HttpStatus.BAD_REQUEST, null); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java new file mode 100644 index 000000000000..c23ac2a21094 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java @@ -0,0 +1,246 @@ +package de.tum.in.www1.artemis.exam; + +import static java.time.ZonedDateTime.now; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import java.net.URI; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.exam.ExamUser; +import de.tum.in.www1.artemis.domain.exam.StudentExam; +import de.tum.in.www1.artemis.domain.metis.conversation.Channel; +import de.tum.in.www1.artemis.repository.ExamRepository; +import de.tum.in.www1.artemis.repository.ExamUserRepository; +import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; +import de.tum.in.www1.artemis.service.exam.ExamAccessService; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.service.user.PasswordService; +import de.tum.in.www1.artemis.user.UserFactory; +import de.tum.in.www1.artemis.user.UserUtilService; + +class TestExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { + + private static final String TEST_PREFIX = "testexamintegration"; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ExamUserRepository examUserRepository; + + @Autowired + private PasswordService passwordService; + + @Autowired + private ExamAccessService examAccessService; + + @Autowired + private UserUtilService userUtilService; + + @Autowired + private CourseUtilService courseUtilService; + + @Autowired + private ExamUtilService examUtilService; + + @Autowired + ChannelRepository channelRepository; + + private Course course1; + + private Course course2; + + private Exam testExam1; + + private static final int NUMBER_OF_STUDENTS = 1; + + private static final int NUMBER_OF_TUTORS = 1; + + private User student1; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, NUMBER_OF_TUTORS, 0, 1); + // Add a student that is not in the course + userUtilService.createAndSaveUser(TEST_PREFIX + "student42", passwordService.hashPassword(UserFactory.USER_PASSWORD)); + + course1 = courseUtilService.addEmptyCourse(); + course2 = courseUtilService.addEmptyCourse(); + + student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + testExam1 = examUtilService.addTestExam(course1); + examUtilService.addStudentExamForTestExam(testExam1, student1); + + bitbucketRequestMockProvider.enableMockingOfRequests(); + + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; + participantScoreScheduleService.activate(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGenerateStudentExams_testExam() throws Exception { + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/generate-student-exams", Optional.empty(), StudentExam.class, + HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGenerateMissingStudentExams_testExam() throws Exception { + request.postListWithResponseBody("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/generate-missing-student-exams", Optional.empty(), StudentExam.class, + HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testEvaluateQuizExercises_testExam() throws Exception { + request.post("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/student-exams/evaluate-quiz-exercises", Optional.empty(), HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor() throws Exception { + // Test the creation of a test exam + Exam examA = ExamFactory.generateTestExam(course1); + URI examUri = request.post("/api/courses/" + course1.getId() + "/exams", examA, HttpStatus.CREATED); + Exam savedExam = request.get(String.valueOf(examUri), HttpStatus.OK, Exam.class); + + verify(examAccessService).checkCourseAccessForInstructorElseThrow(course1.getId()); + Channel channelFromDB = channelRepository.findChannelByExamId(savedExam.getId()); + assertThat(channelFromDB).isNotNull(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor_withVisibleDateEqualsStartDate() throws Exception { + // Test the creation of a test exam, where visibleDate equals StartDate + Exam examB = ExamFactory.generateTestExam(course1); + examB.setVisibleDate(examB.getStartDate()); + request.post("/api/courses/" + course1.getId() + "/exams", examB, HttpStatus.CREATED); + + verify(examAccessService).checkCourseAccessForInstructorElseThrow(course1.getId()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor_badRequestWithWorkingTimeGreaterThanWorkingWindow() throws Exception { + // Test for bad request, where workingTime is greater than difference between StartDate and EndDate + Exam examC = ExamFactory.generateTestExam(course1); + examC.setWorkingTime(5000); + request.post("/api/courses/" + course1.getId() + "/exams", examC, HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor_badRequestWithWorkingTimeSetToZero() throws Exception { + // Test for bad request, if the working time is 0 + Exam examD = ExamFactory.generateTestExam(course1); + examD.setWorkingTime(0); + request.post("/api/courses/" + course1.getId() + "/exams", examD, HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor_testExam_CorrectionRoundViolation() throws Exception { + Exam exam = ExamFactory.generateTestExam(course1); + exam.setNumberOfCorrectionRoundsInExam(1); + request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateTestExam_asInstructor_realExam_CorrectionRoundViolation() throws Exception { + Exam exam = ExamFactory.generateExam(course1); + exam.setNumberOfCorrectionRoundsInExam(0); + request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); + + exam.setNumberOfCorrectionRoundsInExam(3); + request.post("/api/courses/" + course1.getId() + "/exams", exam, HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testUpdateTestExam_asInstructor_withExamModeChanged() throws Exception { + // The Exam-Mode should not be changeable with a PUT / update operation, a CONFLICT should be returned instead + // Case 1: test exam should be updated to real exam + Exam examA = ExamFactory.generateTestExam(course1); + Exam createdExamA = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams", examA, Exam.class, HttpStatus.CREATED); + createdExamA.setNumberOfCorrectionRoundsInExam(1); + createdExamA.setTestExam(false); + request.putWithResponseBody("/api/courses/" + course1.getId() + "/exams", createdExamA, Exam.class, HttpStatus.CONFLICT); + + // Case 2: real exam should be updated to test exam + Exam examB = ExamFactory.generateTestExam(course1); + examB.setNumberOfCorrectionRoundsInExam(1); + examB.setTestExam(false); + examB.setChannelName("examB"); + Exam createdExamB = request.postWithResponseBody("/api/courses/" + course1.getId() + "/exams", examB, Exam.class, HttpStatus.CREATED); + createdExamB.setTestExam(true); + createdExamB.setNumberOfCorrectionRoundsInExam(0); + request.putWithResponseBody("/api/courses/" + course1.getId() + "/exams", createdExamB, Exam.class, HttpStatus.CONFLICT); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteStudentForTestExam_badRequest() throws Exception { + // Create an exam with registered students + Exam exam = examUtilService.setupExamWithExerciseGroupsExercisesRegisteredStudents(TEST_PREFIX, course1, 1); + exam.setTestExam(true); + examRepository.save(exam); + + // Remove student1 from the exam + request.delete("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/students/" + TEST_PREFIX + "student1", HttpStatus.BAD_REQUEST); + } + + // ExamResource - getStudentExamForTestExamForStart + @Test + @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") + void testGetStudentExamForTestExamForStart_notRegisteredInCourse() throws Exception { + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForTestExamForStart_notVisible() throws Exception { + testExam1.setVisibleDate(now().plusMinutes(60)); + testExam1 = examRepository.save(testExam1); + + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, StudentExam.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForTestExamForStart_ExamDoesNotBelongToCourse() throws Exception { + Exam testExam = examUtilService.addTestExam(course2); + + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.CONFLICT, StudentExam.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForTestExamForStart_fetchExam_successful() throws Exception { + var testExam = examUtilService.addTestExam(course2); + testExam = examRepository.save(testExam); + var examUser = new ExamUser(); + examUser.setExam(testExam); + examUser.setUser(student1); + examUser = examUserRepository.save(examUser); + testExam.addExamUser(examUser); + examRepository.save(testExam); + var studentExam5 = examUtilService.addStudentExamForTestExam(testExam, student1); + StudentExam studentExamReceived = request.get("/api/courses/" + course2.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.OK, StudentExam.class); + assertThat(studentExamReceived).isEqualTo(studentExam5); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseIntegrationTestService.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseIntegrationTestService.java index 564dbd60afb2..8b3a6699c17d 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseIntegrationTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseIntegrationTestService.java @@ -303,8 +303,8 @@ List exportSubmissionsWithPracticeSubmissionByParticipationIds(boolean exc doReturn(repository2).when(gitService).getOrCheckoutRepository(eq(participation2.getVcsRepositoryUrl()), anyString(), anyBoolean()); // Set one of the participations to practice mode - participation1.setTestRun(false); - participation2.setTestRun(true); + participation1.setPracticeMode(false); + participation2.setPracticeMode(true); final var participations = List.of(participation1, participation2); programmingExerciseStudentParticipationRepository.saveAll(participations); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseParticipationIntegrationTest.java index bed74e3d4160..79ebb1edaadf 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseParticipationIntegrationTest.java @@ -427,7 +427,7 @@ void checkResetRepository_noAccess_forbidden() throws Exception { void checkResetRepository_noAccessToGradedParticipation_forbidden() throws Exception { var gradedParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student2"); var practiceParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); participationRepository.save(practiceParticipation); request.put("/api/programming-exercise-participations/" + practiceParticipation.getId() + "/reset-repository?gradedParticipationId=" + gradedParticipation.getId(), null, diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTest.java index 2e8c72b59314..00a610275474 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTest.java @@ -219,7 +219,7 @@ void testFindRelevantParticipations() { gradedParticipationFinished.setInitializationState(InitializationState.FINISHED); gradedParticipationFinished.setExercise(exercise); StudentParticipation practiceParticipation = new StudentParticipation(); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); practiceParticipation.setExercise(exercise); List allParticipations = List.of(gradedParticipationInitialized, gradedParticipationFinished, practiceParticipation); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTestService.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTestService.java index 88c0ca71d2cf..64a93b2726b6 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseTestService.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.exercise.programmingexercise; -import static de.tum.in.www1.artemis.domain.enumeration.ExerciseMode.*; +import static de.tum.in.www1.artemis.domain.enumeration.ExerciseMode.INDIVIDUAL; +import static de.tum.in.www1.artemis.domain.enumeration.ExerciseMode.TEAM; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.*; import static de.tum.in.www1.artemis.service.programming.ProgrammingExerciseExportService.EXPORTED_EXERCISE_DETAILS_FILE_PREFIX; import static de.tum.in.www1.artemis.service.programming.ProgrammingExerciseExportService.EXPORTED_EXERCISE_PROBLEM_STATEMENT_FILE_PREFIX; @@ -17,6 +18,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -48,6 +50,8 @@ import de.tum.in.www1.artemis.config.StaticCodeAnalysisConfigurer; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.domain.Authority; +import de.tum.in.www1.artemis.domain.AuxiliaryRepository; import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.exam.ExamUser; @@ -1492,7 +1496,7 @@ private void generateProgrammingExerciseForExport(boolean saveEmbeddedFiles) thr exercise.setProblemStatement(String.format(""" Problem statement ![mountain.jpg](/api/files/markdown/%s) - ![matterhorn.jpg](/api/files/markdown/%s) + """, embeddedFileName1, embeddedFileName2)); if (saveEmbeddedFiles) { Files.write(Path.of(FilePathService.getMarkdownFilePath(), embeddedFileName1), diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java index 8a94261d32cd..2350f2b45477 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java @@ -949,7 +949,7 @@ void shouldCreateGradleFeedback() throws Exception { @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @MethodSource("testSubmissionAfterDueDateValues") @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testSubmissionAfterDueDate(ZonedDateTime dueDate, SubmissionType expectedType, boolean expectedRated, boolean testRun) throws Exception { + void testSubmissionAfterDueDate(ZonedDateTime dueDate, SubmissionType expectedType, boolean expectedRated, boolean practiceMode) throws Exception { var user = userRepository.findUserWithGroupsAndAuthoritiesByLogin(TEST_PREFIX + "student1").orElseThrow(); Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); @@ -959,8 +959,8 @@ void testSubmissionAfterDueDate(ZonedDateTime dueDate, SubmissionType expectedTy // Add a participation for the programming exercise var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, user.getLogin()); - if (testRun) { - participation.setTestRun(testRun); + if (practiceMode) { + participation.setPracticeMode(practiceMode); participation = participationRepository.save(participation); } diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/RepositoryIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/RepositoryIntegrationTest.java index 88baf78befc3..f2f2f9cc0494 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/RepositoryIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/RepositoryIntegrationTest.java @@ -888,7 +888,7 @@ void testCommitChangesAllowedForPracticeModeAfterDueDate() throws Exception { programmingExercise.setAssessmentType(AssessmentType.MANUAL); programmingExerciseRepository.save(programmingExercise); - participation.setTestRun(true); + participation.setPracticeMode(true); studentParticipationRepository.save(participation); testCommitChanges(); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java index 00c060e4168b..3d542fa3e9d4 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java @@ -318,7 +318,7 @@ public static void initializeQuizExerciseWithAllQuestionTypes(QuizExercise quizE private static ShortAnswerQuestion createShortAnswerQuestionWithRealisticText() { var shortAnswerQuestion = createShortAnswerQuestion(); - shortAnswerQuestion.setText("This [-spot1] a [-spot 2] answer text"); + shortAnswerQuestion.setText("This [-spot0] a [-spot 2] answer text"); return shortAnswerQuestion; } diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java index a611a7547b27..76429a21d3a5 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseUtilService.java @@ -275,10 +275,10 @@ public void setQuizBatchExerciseAndSave(QuizBatch batch, QuizExercise quizExerci quizBatchRepository.save(batch); } - public QuizExercise addQuizExerciseToCourseWithParticipationAndSubmissionForUser(Course course, String login, boolean assessmentDueDateInTheFuture) throws IOException { + public QuizSubmission addQuizExerciseToCourseWithParticipationAndSubmissionForUser(Course course, String login, boolean dueDateInTheFuture) throws IOException { QuizExercise quizExercise; - if (assessmentDueDateInTheFuture) { - quizExercise = createAndSaveQuizWithAllQuestionTypes(course, pastTimestamp, pastTimestamp, futureTimestamp, QuizMode.SYNCHRONIZED); + if (dueDateInTheFuture) { + quizExercise = createAndSaveQuizWithAllQuestionTypes(course, pastTimestamp, futureTimestamp, futureTimestamp, QuizMode.SYNCHRONIZED); } else { quizExercise = createAndSaveQuizWithAllQuestionTypes(course, pastTimestamp, pastTimestamp, pastTimestamp, QuizMode.SYNCHRONIZED); @@ -380,7 +380,7 @@ public QuizExercise addQuizExerciseToCourseWithParticipationAndSubmissionForUser quizExercise.addParticipation(studentParticipation); courseRepo.save(course); quizExerciseRepository.save(quizExercise); - return quizExercise; + return quizSubmission; } public QuizExercise createAndSaveQuizWithAllQuestionTypes(Course course, ZonedDateTime releaseDate, ZonedDateTime dueDate, ZonedDateTime assessmentDueDate, QuizMode quizMode) { diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitIntegrationTest.java index 428985b572c4..5fd380e9bf79 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitIntegrationTest.java @@ -14,6 +14,7 @@ import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -125,6 +126,8 @@ private MockHttpServletRequestBuilder buildCreateAttachmentUnit(@NotNull Attachm */ private MockMultipartFile createAttachmentUnitPdf() throws IOException { + var font = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = new PDDocument()) { for (int i = 1; i <= SLIDE_COUNT; i++) { @@ -133,7 +136,7 @@ private MockMultipartFile createAttachmentUnitPdf() throws IOException { if (i == 2) { contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, -15); contentStream.showText("itp20.."); contentStream.newLineAtOffset(25, 500); @@ -144,7 +147,7 @@ private MockMultipartFile createAttachmentUnitPdf() throws IOException { continue; } contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, 500); String text = "This is the sample document"; contentStream.showText(text); diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java index 42aedfac9a5f..dea2195856dd 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java @@ -7,10 +7,12 @@ import java.util.ArrayList; import java.util.List; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -172,20 +174,18 @@ void splitLectureFile_asInstructor_shouldRemoveSolutionSlides_and_removeBreakSli String attachmentPathFirstUnit = attachmentUnitList.get(0).getAttachment().getLink(); byte[] fileBytesFirst = request.get(attachmentPathFirstUnit, HttpStatus.OK, byte[].class); - try (PDDocument document = PDDocument.load(fileBytesFirst)) { + try (PDDocument document = Loader.loadPDF(fileBytesFirst)) { // 5 is the number of pages for the first unit (after break and solution are removed) assertThat(document.getNumberOfPages()).isEqualTo(5); - document.close(); } // second unit String attachmentPathSecondUnit = attachmentUnitList.get(1).getAttachment().getLink(); byte[] fileBytesSecond = request.get(attachmentPathSecondUnit, HttpStatus.OK, byte[].class); - try (PDDocument document = PDDocument.load(fileBytesSecond)) { + try (PDDocument document = Loader.loadPDF(fileBytesSecond)) { // 13 is the number of pages for the second unit assertThat(document.getNumberOfPages()).isEqualTo(13); - document.close(); } } @@ -204,6 +204,8 @@ private void testAllPreAuthorize() throws Exception { */ private MockMultipartFile createLectureFile(boolean shouldBePDF) throws IOException { + var font = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = new PDDocument()) { if (shouldBePDF) { for (int i = 1; i <= 20; i++) { @@ -212,20 +214,21 @@ private MockMultipartFile createLectureFile(boolean shouldBePDF) throws IOExcept if (i == 6) { contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, -15); contentStream.showText("itp20.."); contentStream.newLineAtOffset(25, 500); contentStream.showText("Break"); contentStream.newLineAtOffset(0, -15); contentStream.showText("Have fun"); + contentStream.endText(); contentStream.close(); continue; } if (i == 7) { contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, -15); contentStream.showText("itp20.."); contentStream.newLineAtOffset(25, 500); @@ -241,7 +244,7 @@ private MockMultipartFile createLectureFile(boolean shouldBePDF) throws IOExcept if (i == 2 || i == 8) { contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, -15); contentStream.showText("itp20.."); contentStream.newLineAtOffset(25, 500); @@ -255,7 +258,7 @@ private MockMultipartFile createLectureFile(boolean shouldBePDF) throws IOExcept continue; } contentStream.beginText(); - contentStream.setFont(PDType1Font.TIMES_ROMAN, 12); + contentStream.setFont(font, 12); contentStream.newLineAtOffset(25, 500); String text = "This is the sample document"; contentStream.showText(text); diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIIntegrationTest.java index a071df4a63c6..2b763da0c2fc 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIIntegrationTest.java @@ -636,7 +636,7 @@ void testFetchPush_studentPracticeRepository() throws Exception { // Create practice participation. ProgrammingExerciseStudentParticipation practiceParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, student1Login); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); practiceParticipation.setRepositoryUrl(localVCLocalCITestService.constructLocalVCUrl("", "", projectKey1, practiceRepositorySlug)); programmingExerciseStudentParticipationRepository.save(practiceParticipation); @@ -682,7 +682,7 @@ void testFetchPush_teachingAssistantPracticeRepository() throws Exception { // Create practice participation. ProgrammingExerciseStudentParticipation practiceParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, tutor1Login); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); programmingExerciseStudentParticipationRepository.save(practiceParticipation); // Students should not be able to access, teaching assistants should be able to fetch and push and editors and higher should be able to fetch and push. @@ -721,7 +721,7 @@ void testFetchPush_instructorPracticeRepository() throws Exception { // Create practice participation. ProgrammingExerciseStudentParticipation practiceParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, instructor1Login); - practiceParticipation.setTestRun(true); + practiceParticipation.setPracticeMode(true); programmingExerciseStudentParticipationRepository.save(practiceParticipation); // Students should not be able to access, teaching assistants should be able to fetch, and editors and higher should be able to fetch and push. diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java index 9f93d1fa34e1..512daad3da8f 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java @@ -55,7 +55,7 @@ void testStartParticipation() throws Exception { StudentParticipation participation = request.postWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/participations", null, StudentParticipation.class, HttpStatus.CREATED); assertThat(participation).isNotNull(); - assertThat(participation.isTestRun()).isFalse(); + assertThat(participation.isPracticeMode()).isFalse(); assertThat(participation.getStudent()).contains(user); LocalVCRepositoryUrl studentAssignmentRepositoryUrl = new LocalVCRepositoryUrl(projectKey, projectKey.toLowerCase() + "-" + TEST_PREFIX + "student1", localVCBaseUrl); assertThat(studentAssignmentRepositoryUrl.getLocalRepositoryPath(localVCBasePath)).exists(); diff --git a/src/test/java/de/tum/in/www1/artemis/participation/ParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/participation/ParticipationIntegrationTest.java index 4255a6dec31a..c3b757d87240 100644 --- a/src/test/java/de/tum/in/www1/artemis/participation/ParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/participation/ParticipationIntegrationTest.java @@ -306,7 +306,7 @@ void participateInProgrammingExerciseAsEditorDueDatePassed() throws Exception { HttpStatus.CREATED); var participationUsers = participation.getStudents(); assertThat(participation).isNotNull(); - assertThat(participation.isTestRun()).isFalse(); + assertThat(participation.isPracticeMode()).isFalse(); assertThat(participationUsers).contains(user); } @@ -339,7 +339,7 @@ void practiceProgrammingExercise_successful() throws Exception { StudentParticipation participation = request.postWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/participations/practice", null, StudentParticipation.class, HttpStatus.CREATED); assertThat(participation).isNotNull(); - assertThat(participation.isTestRun()).isTrue(); + assertThat(participation.isPracticeMode()).isTrue(); assertThat(participation.getStudent()).contains(user); } @@ -354,7 +354,7 @@ void participateInProgrammingExercise_successful() throws Exception { StudentParticipation participation = request.postWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/participations", null, StudentParticipation.class, HttpStatus.CREATED); assertThat(participation).isNotNull(); - assertThat(participation.isTestRun()).isFalse(); + assertThat(participation.isPracticeMode()).isFalse(); assertThat(participation.getStudent()).contains(user); } @@ -593,7 +593,7 @@ void getAllParticipationsForExercise() throws Exception { participationUtilService.createAndSaveParticipationForExercise(textExercise, TEST_PREFIX + "student1"); participationUtilService.createAndSaveParticipationForExercise(textExercise, TEST_PREFIX + "student2"); StudentParticipation testParticipation = participationUtilService.createAndSaveParticipationForExercise(textExercise, TEST_PREFIX + "student3"); - testParticipation.setTestRun(true); + testParticipation.setPracticeMode(true); participationRepo.save(testParticipation); var participations = request.getList("/api/exercises/" + textExercise.getId() + "/participations", HttpStatus.OK, StudentParticipation.class); assertThat(participations).as("Exactly 3 participations are returned").hasSize(3).as("Only participation that has student are returned") @@ -617,7 +617,7 @@ void getAllParticipationsForExercise_withLatestResults() throws Exception { Submission onlySubmission = textExerciseUtilService.createSubmissionForTextExercise(textExercise, students.get(2), "asdf"); StudentParticipation testParticipation = participationUtilService.createAndSaveParticipationForExercise(textExercise, TEST_PREFIX + "student4"); - testParticipation.setTestRun(true); + testParticipation.setPracticeMode(true); participationRepo.save(testParticipation); final var params = new LinkedMultiValueMap(); @@ -1066,7 +1066,7 @@ void getSubmissionOfParticipation() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void cleanupBuildPlan(boolean practiceMode, boolean afterDueDate) throws Exception { var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); - participation.setTestRun(practiceMode); + participation.setPracticeMode(practiceMode); participationRepo.save(participation); if (afterDueDate) { programmingExercise.setDueDate(ZonedDateTime.now().minusHours(1)); diff --git a/src/test/java/de/tum/in/www1/artemis/service/DataExportCreationServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/DataExportCreationServiceTest.java index d1c0cfa81241..b911774ec828 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/DataExportCreationServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/DataExportCreationServiceTest.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -8,9 +9,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.eclipse.jgit.lib.Repository; import org.junit.jupiter.api.*; @@ -27,8 +31,7 @@ import de.tum.in.www1.artemis.connector.apollon.ApollonRequestMockProvider; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.*; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; -import de.tum.in.www1.artemis.domain.enumeration.DataExportState; +import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.exam.StudentExam; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; @@ -172,12 +175,16 @@ void testDataExportCreationSuccess_containsCorrectCourseContent() throws Excepti Predicate courseDir = path -> path.getFileName().toString().startsWith("course_short"); assertThat(extractedZipDirPath).isDirectoryContaining(generalUserInformationCsv).isDirectoryContaining(readmeMd).isDirectoryContaining(courseDir); var courseDirPath = getCourseOrExamDirectoryPath(extractedZipDirPath, "short"); - assertThat(courseDirPath).isDirectoryContaining(path -> path.getFileName().toString().endsWith("FileUpload2")) + var exercisesDirPath = courseDirPath.resolve("exercises"); + assertThat(courseDirPath).isDirectoryContaining(exercisesDirPath::equals); + assertThat(exercisesDirPath).isDirectoryContaining(path -> path.getFileName().toString().endsWith("FileUpload2")) .isDirectoryContaining(path -> path.getFileName().toString().endsWith("Modeling0")) .isDirectoryContaining(path -> path.getFileName().toString().endsWith("Modeling3")).isDirectoryContaining(path -> path.getFileName().toString().endsWith("Text1")) .isDirectoryContaining(path -> path.getFileName().toString().endsWith("Programming")).isDirectoryContaining(path -> path.getFileName().toString().endsWith("quiz")); assertCommunicationDataCsvFile(courseDirPath); - getExerciseDirectoryPaths(courseDirPath).forEach(exercise -> assertCorrectContentForExercise(exercise, true, assessmentDueDateInTheFuture)); + for (var exercisePath : getExerciseDirectoryPaths(exercisesDirPath)) { + assertCorrectContentForExercise(exercisePath, true, assessmentDueDateInTheFuture); + } } @@ -198,7 +205,8 @@ private Course prepareCourseDataForDataExportCreation(boolean assessmentDueDateI else { course1 = courseUtilService.addCourseWithExercisesAndSubmissions(TEST_PREFIX, "", 4, 2, 1, 1, true, 1, validModel); } - quizExerciseUtilService.addQuizExerciseToCourseWithParticipationAndSubmissionForUser(course1, TEST_PREFIX + "student1", assessmentDueDateInTheFuture); + var quizSubmission = quizExerciseUtilService.addQuizExerciseToCourseWithParticipationAndSubmissionForUser(course1, TEST_PREFIX + "student1", assessmentDueDateInTheFuture); + participationUtilService.addResultToSubmission(quizSubmission, AssessmentType.AUTOMATIC, null, 3.0, true, ZonedDateTime.now().minusMinutes(2)); programmingExerciseTestService.setup(this, versionControlService, continuousIntegrationService); ProgrammingExercise programmingExercise; if (assessmentDueDateInTheFuture) { @@ -210,14 +218,30 @@ private Course prepareCourseDataForDataExportCreation(boolean assessmentDueDateI var participation = participationUtilService.addStudentParticipationForProgrammingExerciseForLocalRepo(programmingExercise, userLogin, programmingExerciseTestService.studentRepo.localRepoFile.toURI()); var submission = programmingExerciseUtilService.createProgrammingSubmission(participation, false, "abc"); - var submission2 = programmingExerciseUtilService.createProgrammingSubmission(participation, false, "def"); + var submission2 = programmingExerciseUtilService.createProgrammingSubmission(participation, true, "def"); participationUtilService.addResultToSubmission(submission, AssessmentType.AUTOMATIC, null, 2.0, true, ZonedDateTime.now().minusMinutes(1)); - participationUtilService.addResultToSubmission(submission2, AssessmentType.AUTOMATIC, null, 3.0, true, ZonedDateTime.now().minusMinutes(2)); + participationUtilService.addResultToSubmission(submission2, AssessmentType.SEMI_AUTOMATIC, null, 3.0, true, ZonedDateTime.now().minusMinutes(2)); var feedback = new Feedback(); feedback.setCredits(1.0); feedback.setDetailText("detailed feedback"); feedback.setText("feedback"); + feedback.setType(FeedbackType.AUTOMATIC); + feedback.setVisibility(Visibility.ALWAYS); + var hiddenFeedback = new Feedback(); + hiddenFeedback.setCredits(1.0); + hiddenFeedback.setDetailText("hidden detailed feedback"); + hiddenFeedback.setText("hidden feedback"); + hiddenFeedback.setType(FeedbackType.AUTOMATIC); + var feedback2 = new Feedback(); + feedback2.setCredits(2.0); + feedback2.setDetailText("detailed feedback 2"); + feedback2.setText("feedback 2"); + feedback2.setType(FeedbackType.MANUAL); + submission.getFirstResult().setTestCaseCount(2); + submission.getFirstResult().setPassedTestCaseCount(1); participationUtilService.addFeedbackToResult(feedback, submission.getFirstResult()); + participationUtilService.addFeedbackToResult(hiddenFeedback, submission.getFirstResult()); + participationUtilService.addFeedbackToResult(feedback2, submission2.getFirstResult()); participationUtilService.addSubmission(participation, submission); participationUtilService.addSubmission(participation, submission2); var modelingExercises = exerciseRepository.findAllExercisesByCourseId(course1.getId()).stream().filter(exercise -> exercise instanceof ModelingExercise).toList(); @@ -246,8 +270,8 @@ private Exam prepareExamDataForDataExportCreation(String courseShortName) throws Files.createDirectories(repoDownloadClonePath); } var userForExport = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); - var course = courseUtilService.createCourseWithCustomStudentUserGroupWithExamAndExerciseGroupAndExercises(userForExport, TEST_PREFIX + "student", courseShortName, true, - true); + var course = courseUtilService.createCourseWithCustomStudentUserGroupWithExamAndExerciseGroupAndExercisesAndGradingScale(userForExport, TEST_PREFIX + "student", + courseShortName, true, true); programmingExerciseTestService.setup(this, versionControlService, continuousIntegrationService); var exam = course.getExams().iterator().next(); exam = examRepository.findWithExerciseGroupsExercisesParticipationsAndSubmissionsById(exam.getId()).orElseThrow(); @@ -284,15 +308,15 @@ private void assertNoResultsFile(Path exerciseDirPath) { assertThat(exerciseDirPath).isDirectoryNotContaining(path -> path.getFileName().toString().endsWith(FILE_FORMAT_TXT) && path.getFileName().toString().contains("result")); } - private void assertCorrectContentForExercise(Path exerciseDirPath, boolean courseExercise, boolean assessmentDueDateInTheFuture) { + private void assertCorrectContentForExercise(Path exerciseDirPath, boolean courseExercise, boolean assessmentDueDateInTheFuture) throws IOException { Predicate resultsFile = path -> path.getFileName().toString().endsWith(FILE_FORMAT_TXT) && path.getFileName().toString().contains("result"); Predicate submissionFile = path -> path.getFileName().toString().endsWith(FILE_FORMAT_CSV) && path.getFileName().toString().contains("submission"); assertThat(exerciseDirPath).isDirectoryContaining(submissionFile); - if (assessmentDueDateInTheFuture) { + // programming exercises have a results with the automatic test feedback + if (assessmentDueDateInTheFuture && !exerciseDirPath.toString().contains("Programming")) { assertThat(exerciseDirPath).isDirectoryNotContaining(resultsFile); } - // quizzes do not have a result file - if (!exerciseDirPath.toString().contains("quiz") && !assessmentDueDateInTheFuture) { + if (!assessmentDueDateInTheFuture) { assertThat(exerciseDirPath).isDirectoryContaining(resultsFile); } if (exerciseDirPath.toString().contains("Programming")) { @@ -304,6 +328,29 @@ private void assertCorrectContentForExercise(Path exerciseDirPath, boolean cours .isDirectoryContaining(path -> path.getFileName().toString().contains("plagiarism_case") && path.getFileName().toString().endsWith(FILE_FORMAT_CSV)); } } + // only include automatic test feedback if the assessment due date is in the future + if (exerciseDirPath.toString().contains("Programming") && assessmentDueDateInTheFuture && courseExercise) { + var fileContentResult1 = Files.readString(getProgrammingResultsFilePath(exerciseDirPath, true)); + // automatic feedback + assertThat(fileContentResult1).contains("1.0"); + assertThat(fileContentResult1).contains("feedback"); + // this result should not be included, so the path should be null + assertThat(getProgrammingResultsFilePath(exerciseDirPath, false)).isNull(); + + } + else if (exerciseDirPath.toString().contains("Programming") && !assessmentDueDateInTheFuture && courseExercise) { + var fileContentResult1 = Files.readString(getProgrammingResultsFilePath(exerciseDirPath, true)); + var fileContentResult2 = Files.readString(getProgrammingResultsFilePath(exerciseDirPath, false)); + // automatic feedback + assertThat(fileContentResult1).contains("1.0"); + assertThat(fileContentResult1).contains("feedback"); + // automatic hidden feedback + assertThat(fileContentResult1).contains("hidden feedback"); + assertThat(fileContentResult1).contains("hidden detailed feedback"); + // manual feedback + assertThat(fileContentResult2).contains("2.0"); + assertThat(fileContentResult2).contains("detailed feedback 2"); + } if (exerciseDirPath.toString().contains("Modeling")) { // model as pdf file assertThat(exerciseDirPath).isDirectoryContaining(path -> path.getFileName().toString().endsWith(FILE_FORMAT_PDF)); @@ -322,12 +369,72 @@ private void assertCorrectContentForExercise(Path exerciseDirPath, boolean cours .isDirectoryContaining(path -> path.getFileName().toString().endsWith("multiple_choice_questions_answers" + FILE_FORMAT_TXT)) .isDirectoryContaining(path -> path.getFileName().toString().contains("dragAndDropQuestion") && path.getFileName().toString().endsWith(FILE_FORMAT_PDF)); } + if (exerciseDirPath.toString().contains("quiz") && assessmentDueDateInTheFuture) { + var fileContentMC = Files.readString(getMCQuestionsAnswersFilePath(exerciseDirPath)); + var fileContentSA = Files.readString(getSAQuestionsAnswersFilePath(exerciseDirPath)); + assertThat(fileContentMC).doesNotContain("Correct"); + assertThat(fileContentMC).doesNotContain("Incorrect"); + assertThat(fileContentSA).doesNotContain("Correct"); + assertThat(fileContentSA).doesNotContain("Incorrect"); + + } + else if (exerciseDirPath.toString().contains("quiz") && !assessmentDueDateInTheFuture) { + var fileContentMC = Files.readString(getMCQuestionsAnswersFilePath(exerciseDirPath)); + var fileContentSA = Files.readString(getSAQuestionsAnswersFilePath(exerciseDirPath)); + assertThat(fileContentMC).contains("Correct"); + assertThat(fileContentMC).contains("Incorrect"); + assertThat(fileContentSA).contains("Correct"); + assertThat(fileContentSA).contains("Incorrect"); + } boolean notQuizOrProgramming = !exerciseDirPath.toString().contains("quiz") && !exerciseDirPath.toString().contains("Programming"); if (notQuizOrProgramming && courseExercise && !assessmentDueDateInTheFuture) { assertThat(exerciseDirPath).isDirectoryContaining(path -> path.getFileName().toString().contains("complaint")); } } + private Path getMCQuestionsAnswersFilePath(Path exerciseDirPath) { + try (var files = Files.list(exerciseDirPath)) { + return files.filter(path -> path.getFileName().toString().endsWith(FILE_FORMAT_TXT) && path.getFileName().toString().contains("multiple_choice")).findFirst() + .orElseThrow(); + } + catch (IOException e) { + fail("Failed while getting multiple choice questions answers file"); + } + return null; + } + + private Path getSAQuestionsAnswersFilePath(Path exerciseDirPath) { + try (var files = Files.list(exerciseDirPath)) { + return files.filter(path -> path.getFileName().toString().endsWith(FILE_FORMAT_TXT) && path.getFileName().toString().contains("short_answer")).findFirst() + .orElseThrow(); + } + catch (IOException e) { + fail("Failed while getting short answer questions answers file"); + } + return null; + } + + private Path getProgrammingResultsFilePath(Path exerciseDirPath, boolean firstResult) { + List paths; + try (var files = Files.list(exerciseDirPath)) { + paths = files.filter(path -> path.getFileName().toString().endsWith(FILE_FORMAT_TXT) && path.getFileName().toString().contains("result")) + .collect(Collectors.toCollection(ArrayList::new)); + } + catch (IOException e) { + fail("Failed while getting programming results file"); + return null; + } + paths.sort(Comparator.comparing(Path::getFileName)); + if (firstResult) { + return paths.get(0); + } + if (paths.size() > 1) { + return paths.get(1); + } + // file doesn't exist + return null; + } + private Path getCourseOrExamDirectoryPath(Path rootPath, String shortName) throws IOException { try (var files = Files.list(rootPath).filter(Files::isDirectory).filter(path -> path.getFileName().toString().contains(shortName))) { return files.findFirst().orElseThrow(); @@ -356,10 +463,12 @@ void testDataExportCreationSuccess_containsCorrectExamContent() throws Exception Path extractedZipDirPath = Path.of(dataExportFromDb.getFilePath().substring(0, dataExportFromDb.getFilePath().length() - 4)); var courseDirPath = getCourseOrExamDirectoryPath(extractedZipDirPath, "exam"); assertCommunicationDataCsvFile(courseDirPath); - assertThat(courseDirPath).isDirectoryContaining(path -> path.getFileName().toString().startsWith("exam")); - var examDirPath = getCourseOrExamDirectoryPath(courseDirPath, "exam"); - getExerciseDirectoryPaths(examDirPath).forEach(exercise -> assertCorrectContentForExercise(exercise, false, false)); - + var examsDirPath = courseDirPath.resolve("exams"); + assertThat(courseDirPath).isDirectoryContaining(examsDirPath::equals); + var examDirPath = getCourseOrExamDirectoryPath(examsDirPath, "exam"); + for (var exerciseDirPath : getExerciseDirectoryPaths(examDirPath)) { + assertCorrectContentForExercise(exerciseDirPath, false, false); + } } private void addOnlyAnswerPostReactionInCourse(Course course) { @@ -400,7 +509,11 @@ void testDataExportDoesntLeakResultsIfAssessmentDueDateInTheFuture() throws Exce Path extractedZipDirPath = Path.of(dataExportFromDb.getFilePath().substring(0, dataExportFromDb.getFilePath().length() - 4)); var courseDirPath = getCourseOrExamDirectoryPath(extractedZipDirPath, courseShortName); assertCommunicationDataCsvFile(courseDirPath); - getExerciseDirectoryPaths(courseDirPath).forEach(exercise -> assertCorrectContentForExercise(exercise, true, assessmentDueDateInTheFuture)); + var exercisesDirPath = courseDirPath.resolve("exercises"); + assertThat(courseDirPath).isDirectoryContaining(exercisesDirPath::equals); + for (var exerciseDirectory : getExerciseDirectoryPaths(exercisesDirPath)) { + assertCorrectContentForExercise(exerciseDirectory, true, assessmentDueDateInTheFuture); + } } private void addOnlyAnswerPostInCourse(Course course) { @@ -449,7 +562,11 @@ void testDataExportContainsDataAboutCourseStudentUnenrolled() throws Exception { zipFileTestUtilService.extractZipFileRecursively(dataExportFromDb.getFilePath()); Path extractedZipDirPath = Path.of(dataExportFromDb.getFilePath().substring(0, dataExportFromDb.getFilePath().length() - 4)); var courseDirPath = getCourseOrExamDirectoryPath(extractedZipDirPath, courseShortName); - getExerciseDirectoryPaths(courseDirPath).forEach(exercise -> assertCorrectContentForExercise(exercise, true, assessmentDueDateInTheFuture)); + var exercisesDirPath = courseDirPath.resolve("exercises"); + assertThat(courseDirPath).isDirectoryContaining(exercisesDirPath::equals); + for (var exerciseDirectory : getExerciseDirectoryPaths(exercisesDirPath)) { + assertCorrectContentForExercise(exerciseDirectory, true, assessmentDueDateInTheFuture); + } } private DataExport initDataExport() { diff --git a/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java index 30a3dc719c1d..9608e4605370 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/FileServiceTest.java @@ -13,6 +13,7 @@ import java.util.*; import org.apache.commons.io.FileUtils; +import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.junit.jupiter.api.AfterEach; @@ -85,6 +86,15 @@ private void writeFile(String destinationPath, String content) { } } + private void writeFile(String destinationPath, byte[] bytes) { + try { + FileUtils.writeByteArrayToFile(Path.of(".", "exportTest", destinationPath).toFile(), bytes); + } + catch (IOException ex) { + fail("Failed while writing test files", ex); + } + } + @AfterEach @BeforeEach void deleteFiles() throws IOException { @@ -202,15 +212,16 @@ void testMergePdf() throws IOException { doc1.save(outputStream); doc1.close(); - writeFile("testfile1.pdf", outputStream.toString()); + writeFile("testfile1.pdf", outputStream.toByteArray()); + outputStream.reset(); PDDocument doc2 = new PDDocument(); doc2.addPage(new PDPage()); doc2.addPage(new PDPage()); doc2.save(outputStream); doc2.close(); - writeFile("testfile2.pdf", outputStream.toString()); + writeFile("testfile2.pdf", outputStream.toByteArray()); paths.add(Path.of(".", "exportTest", "testfile1.pdf").toString()); paths.add(Path.of(".", "exportTest", "testfile2.pdf").toString()); @@ -218,7 +229,7 @@ void testMergePdf() throws IOException { Optional mergedFile = fileService.mergePdfFiles(paths, "list_of_pdfs"); assertThat(mergedFile).isPresent(); assertThat(mergedFile.get()).isNotEmpty(); - PDDocument mergedDoc = PDDocument.load(mergedFile.get()); + PDDocument mergedDoc = Loader.loadPDF(mergedFile.get()); assertThat(mergedDoc.getNumberOfPages()).isEqualTo(5); } diff --git a/src/test/java/de/tum/in/www1/artemis/service/ParticipationServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/ParticipationServiceTest.java index 2e87e4cca224..083965aa0378 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/ParticipationServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/ParticipationServiceTest.java @@ -183,7 +183,7 @@ void testStartPracticeMode(boolean useGradedParticipation) throws URISyntaxExcep StudentParticipation studentParticipationReceived = participationService.startPracticeMode(programmingExercise, participant, Optional.of((StudentParticipation) gradedResult.getParticipation()), useGradedParticipation); - assertThat(studentParticipationReceived.isTestRun()).isTrue(); + assertThat(studentParticipationReceived.isPracticeMode()).isTrue(); assertThat(studentParticipationReceived.getExercise()).isEqualTo(programmingExercise); assertThat(studentParticipationReceived.getStudent()).isPresent(); assertThat(studentParticipationReceived.getStudent().get()).isEqualTo(participant); diff --git a/src/test/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleServiceTest.java index 418790393a9a..196f0ac906d2 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/scheduled/DataExportScheduleServiceTest.java @@ -52,7 +52,7 @@ void init() { @ParameterizedTest @MethodSource("provideDataExportStatesAndExpectedToBeCreated") - void testScheduledCronTaskCreatesDataExports(DataExportState state, boolean shouldBeCreated) { + void testScheduledCronTaskCreatesDataExports(DataExportState state, boolean shouldBeCreated) throws InterruptedException { dataExportRepository.deleteAll(); var dataExport = createDataExportWithState(state); dataExportScheduleService.createDataExportsAndDeleteOldOnes(); @@ -69,7 +69,7 @@ void testScheduledCronTaskCreatesDataExports(DataExportState state, boolean shou } @Test - void testScheduledCronTaskSendsEmailToAdminAboutSuccessfulDataExports() { + void testScheduledCronTaskSendsEmailToAdminAboutSuccessfulDataExports() throws InterruptedException { dataExportRepository.deleteAll(); createDataExportWithState(DataExportState.REQUESTED); createDataExportWithState(DataExportState.REQUESTED); @@ -89,7 +89,7 @@ private static Stream provideDataExportStatesAndExpectedToBeCreated() @ParameterizedTest @MethodSource("provideCreationDatesAndExpectedToDelete") - void testScheduledCronTaskDeletesOldDataExports(ZonedDateTime creationDate, DataExportState state, boolean shouldDelete) { + void testScheduledCronTaskDeletesOldDataExports(ZonedDateTime creationDate, DataExportState state, boolean shouldDelete) throws InterruptedException { var dataExport = createDataExportWithCreationDateAndState(creationDate, state); doNothing().when(fileService).scheduleForDeletion(any(), anyLong()); var dataExportId = dataExport.getId(); diff --git a/src/test/javascript/spec/component/exam/participate/exam-exercise-update-highlighter.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-exercise-update-highlighter.component.spec.ts index 1ff604604877..2883192e0396 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-exercise-update-highlighter.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-exercise-update-highlighter.component.spec.ts @@ -3,7 +3,7 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockPipe } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { ExamExerciseUpdate, ExamExerciseUpdateService } from 'app/exam/manage/exam-exercise-update.service'; -import { Exercise } from 'app/entities/exercise.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { ExamExerciseUpdateHighlighterComponent } from 'app/exam/participate/exercises/exam-exercise-update-highlighter/exam-exercise-update-highlighter.component'; describe('ExamExerciseUpdateHighlighterComponent', () => { @@ -17,8 +17,7 @@ describe('ExamExerciseUpdateHighlighterComponent', () => { const oldProblemStatement = 'problem statement with errors'; const updatedProblemStatement = 'new updated ProblemStatement'; - const exerciseDummy = { id: 42, problemStatement: oldProblemStatement } as Exercise; - + const textExerciseDummy = { id: 42, problemStatement: oldProblemStatement } as Exercise; beforeAll(() => { return TestBed.configureTestingModule({ declarations: [MockPipe(ArtemisTranslatePipe), ExamExerciseUpdateHighlighterComponent], @@ -29,7 +28,7 @@ describe('ExamExerciseUpdateHighlighterComponent', () => { fixture = TestBed.createComponent(ExamExerciseUpdateHighlighterComponent); component = fixture.componentInstance; - component.exercise = exerciseDummy; + component.exercise = textExerciseDummy; const exerciseId = component.exercise.id!; const update = { exerciseId, problemStatement: updatedProblemStatement }; @@ -61,4 +60,36 @@ describe('ExamExerciseUpdateHighlighterComponent', () => { expect(problemStatementAfterClick).not.toEqual(component.updatedProblemStatementWithHighlightedDifferences); expect(problemStatementAfterClick).not.toEqual(problemStatementBeforeClick); }); + + describe('ExamExerciseUpdateHighlighterComponent for programming exercises', () => { + const oldProblemStatementWithPlantUml = + 'problem statement with errors @startuml class BubbleSort {+performSort(List)' + '@enduml'; + const programmingExerciseDummy = { id: 42, problemStatement: oldProblemStatementWithPlantUml, type: ExerciseType.PROGRAMMING } as Exercise; + const updatedProblemStatementWithPlantUml = + 'new updated ProblemStatement @startuml class BubbleSort {+performSortUpdate(List)' + '@enduml'; + beforeAll(() => { + return TestBed.configureTestingModule({ + declarations: [MockPipe(ArtemisTranslatePipe), ExamExerciseUpdateHighlighterComponent], + providers: [{ provide: ExamExerciseUpdateService, useValue: mockExamExerciseUpdateService }], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ExamExerciseUpdateHighlighterComponent); + component = fixture.componentInstance; + + component.exercise = programmingExerciseDummy; + const exerciseId = component.exercise.id!; + const update = { exerciseId, problemStatement: updatedProblemStatementWithPlantUml }; + + fixture.detectChanges(); + examExerciseIdAndProblemStatementSourceMock.next(update); + }); + }); + + it('should ignore plantuml diagrams in programming exercise problem statements for diff calculation', () => { + const result = component.exercise.problemStatement; + expect(result).toEqual(component.updatedProblemStatementWithHighlightedDifferences); + fixture.detectChanges(); + }); + }); }); diff --git a/src/test/javascript/spec/component/exercises/shared/exercise-update-notification.component.spec.ts b/src/test/javascript/spec/component/exercises/shared/exercise-update-notification.component.spec.ts new file mode 100644 index 000000000000..b834b6acc617 --- /dev/null +++ b/src/test/javascript/spec/component/exercises/shared/exercise-update-notification.component.spec.ts @@ -0,0 +1,30 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExerciseUpdateNotificationComponent } from 'app/exercises/shared/exercise-update-notification/exercise-update-notification.component'; +import { Exercise } from 'app/entities/exercise.model'; +import { FormsModule } from '@angular/forms'; +import { MockModule } from 'ng-mocks'; + +describe('ExerciseUpdateNotificationComponent', () => { + let component: ExerciseUpdateNotificationComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MockModule(FormsModule)], + declarations: [ExerciseUpdateNotificationComponent], + }); + fixture = TestBed.createComponent(ExerciseUpdateNotificationComponent); + component = fixture.componentInstance; + component.exercise = { id: 1 } as Exercise; + component.isImport = false; + component.notificationText = 'notificationText'; + fixture.detectChanges(); + }); + + it('should emit event on inputChange', () => { + const emitSpy = jest.spyOn(component.notificationTextChange, 'emit'); + component.onInputChanged(); + expect(emitSpy).toHaveBeenCalledExactlyOnceWith(component.notificationText); + }); +}); diff --git a/src/test/javascript/spec/component/legal/data-export.component.spec.ts b/src/test/javascript/spec/component/legal/data-export.component.spec.ts index 0485b825f30f..e0e28e064291 100644 --- a/src/test/javascript/spec/component/legal/data-export.component.spec.ts +++ b/src/test/javascript/spec/component/legal/data-export.component.spec.ts @@ -11,7 +11,6 @@ import { ButtonComponent } from 'app/shared/components/button.component'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; import { DataExportService } from 'app/core/legal/data-export/data-export.service'; import { of, throwError } from 'rxjs'; -import { HttpResponse } from '@angular/common/http'; import { DataExport } from 'app/entities/data-export.model'; import { User } from 'app/core/user/user.model'; import dayjs from 'dayjs/esm'; @@ -92,7 +91,7 @@ describe('DataExportComponent', () => { }); it('should call data export service when data export is downloaded', () => { - const dataExportServiceSpy = jest.spyOn(dataExportService, 'downloadDataExport').mockReturnValue(of>({} as unknown as HttpResponse)); + const dataExportServiceSpy = jest.spyOn(dataExportService, 'downloadDataExport').mockImplementation(); component.canDownload = true; component.dataExportId = 1; component.downloadDataExport(); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts index a6fd03404e2f..067cfc087089 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-instruction.component.spec.ts @@ -576,6 +576,17 @@ describe('ProgrammingExerciseInstructionComponent', () => { expect(injectSpy).toHaveBeenCalledWith(expectedUML, 0); })); + it('should update the markdown and set the correct problem statement if renderUpdatedProblemStatement is called', () => { + const problemStatement = 'lorem ipsum'; + const updatedProblemStatement = 'new lorem ipsum'; + const updateMarkdownStub = jest.spyOn(comp, 'updateMarkdown'); + comp.problemStatement = problemStatement; + comp.exercise = { problemStatement: updatedProblemStatement } as ProgrammingExercise; + comp.renderUpdatedProblemStatement(); + expect(comp.problemStatement).toBe(updatedProblemStatement); + expect(updateMarkdownStub).toHaveBeenCalledOnce(); + }); + const verifyTask = (expectedInvocations: number, expected: NgbModalRef) => { expect(openModalStub).toHaveBeenCalledTimes(expectedInvocations); expect(openModalStub).toHaveBeenCalledWith(FeedbackComponent, { keyboard: true, size: 'lg' }); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts index 2acc13557c7b..2e5de9756348 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts @@ -61,6 +61,7 @@ import { ProgrammingExerciseGradingComponent } from 'app/exercises/programming/m import { ProgrammingExerciseProblemComponent } from 'app/exercises/programming/manage/update/update-components/programming-exercise-problem.component'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { ExerciseUpdateNotificationComponent } from 'app/exercises/shared/exercise-update-notification/exercise-update-notification.component'; describe('ProgrammingExercise Management Update Component', () => { const courseId = 1; @@ -116,6 +117,7 @@ describe('ProgrammingExercise Management Update Component', () => { MockDirective(CustomMaxDirective), MockDirective(TranslateDirective), MockComponent(ModePickerComponent), + MockComponent(ExerciseUpdateNotificationComponent), ], providers: [ { provide: LocalStorageService, useClass: MockSyncStorage }, diff --git a/src/test/javascript/spec/service/data-export.service.spec.ts b/src/test/javascript/spec/service/data-export.service.spec.ts index 189d199713dd..940754dea6ec 100644 --- a/src/test/javascript/spec/service/data-export.service.spec.ts +++ b/src/test/javascript/spec/service/data-export.service.spec.ts @@ -33,6 +33,12 @@ describe('DataExportService', () => { tick(); })); + it('should make open download link to download data export', () => { + const windowSpy = jest.spyOn(window, 'open').mockImplementation(); + service.downloadDataExport(1); + expect(windowSpy).toHaveBeenCalledWith('api/data-exports/1', '_blank'); + }); + it('should make POST request to request data export as admin for another user', fakeAsync(() => { const dataExport = new DataExport(); const user = new User(); @@ -43,13 +49,6 @@ describe('DataExportService', () => { tick(); })); - it('should make GET request to download data export', fakeAsync(() => { - service.downloadDataExport(1).subscribe(); - const req = httpMock.expectOne({ method: 'GET', url: `api/data-exports/1` }); - req.flush(new Blob()); - tick(); - })); - it('should make GET request to check if any data export can be downloaded', fakeAsync(() => { service.canDownloadAnyDataExport().subscribe(); const req = httpMock.expectOne({ method: 'GET', url: `api/data-exports/can-download` });