diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7e405ef32689..2296ed26a829 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -94,6 +94,7 @@ Prerequisites: #### Performance Review + - [ ] I (as a reviewer) confirm that the client changes (in particular related to REST calls and UI responsiveness) are implemented with a very good performance even for very large courses with more than 2000 students. - [ ] I (as a reviewer) confirm that the server changes (in particular related to database calls) are implemented with a very good performance even for very large courses with more than 2000 students. #### Code Review diff --git a/docker/artemis/config/playwright.env b/docker/artemis/config/playwright.env index c57d79d581c7..a3ee64a57442 100644 --- a/docker/artemis/config/playwright.env +++ b/docker/artemis/config/playwright.env @@ -27,6 +27,8 @@ ARTEMIS_CONTINUOUSINTEGRATION_EMPTYCOMMITNECESSARY="true" ARTEMIS_APOLLON_CONVERSIONSERVICEURL="https://apollon.ase.in.tum.de/api/converter" +ARTEMIS_TELEMETRY_ENABLED="false" + # Token is valid 3 days JHIPSTER_SECURITY_AUTHENTICATION_JWT_TOKENVALIDITYINSECONDS="259200" # Token is valid 30 days @@ -38,6 +40,7 @@ INFO_IMPRINT="https://ase.in.tum.de/lehrstuhl_1/component/content/article/179-im INFO_TESTSERVER="true" INFO_TEXTASSESSMENTANALYTICSENABLED="true" INFO_STUDENTEXAMSTORESESSIONDATA="true" +INFO_OPERATORNAME="TUM" LOGGING_FILE_NAME="/opt/artemis/data/artemis.log" diff --git a/docs/.gitignore b/docs/.gitignore index 7c0f5b604f5b..220f5e8dc6d3 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,6 @@ _build/ .venv/ .idea/ +__pycache__/ +.env +venv diff --git a/docs/README.md b/docs/README.md index a806a39d5ef5..323127c4f6f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,15 +56,34 @@ For pull requests, the documentation is available at `https://artemis-platform-- RtD will build and deploy changes automatically. ## Installing Sphinx Locally + +Optionally, create and activate a virtual environment: +``` +python3 -m venv venv +``` +On Linux or macOS: +``` +source venv/bin/activate +``` +On Windows (CMD): +``` +venv\Scripts\activate.bat +``` +On Windows (PowerShell): +``` +venv\Scripts\Activate.ps1 +``` + + [Sphinx] can run locally to generate the documentation in HTML and other formats. You can install Sphinx using `pip` or choose a system-wide installation instead. When using pip, consider using [Python virtual environments]. ```bash -pip install -r requirements.txt --break-system-packages +pip install -r requirements.txt ``` or ```bash -pip3 install -r requirements.txt --break-system-packages +pip3 install -r requirements.txt ``` The [Installing Sphinx] documentation explains more install options. For macOS, it is recommended to install it using homebrew: @@ -128,3 +147,17 @@ A list of useful tools to write documentation: [Python virtual environments]: https://docs.python.org/3/library/venv.html [sphinx-autobuild]: https://pypi.org/project/sphinx-autobuild/ [Read the Docs]: https://readthedocs.org + + +### Dependency management + +Find outdated dependencies using the following command: +``` +pip list --outdated +``` + +Find unused dependencies using the following command: +``` +pip install deptry +deptry . +``` diff --git a/docs/dev/guidelines/client.rst b/docs/dev/guidelines/client.rst index 46c7871cdd6d..35bbb6d3db42 100644 --- a/docs/dev/guidelines/client.rst +++ b/docs/dev/guidelines/client.rst @@ -11,7 +11,24 @@ Some general aspects: * The Artemis client uses lazy loading to keep the initial bundle size below 2 MB. * Code quality and test coverage are important. Try to reuse code and avoid code duplication. Write meaningful tests! +* Use **standalone components** instead of Angular modules: https://angular.dev/reference/migrations/standalone +* Use the new ``signals`` to granularly track how and where state is used throughout an application, allowing Angular to optimize rendering updates: https://angular.dev/guide/signals +* Find out more in the following guide: https://blog.angular-university.io/angular-signal-components/ +* Use the new ``input()`` and ``output()`` decorators instead of ``@Input()`` and ``@Output()``. + .. code-block:: ts + + // Don't + @Input() myInput: string; + @Output() myOutput = new EventEmitter(); + + // Do + myInput = input(); + myOutput = output(); + +* Use the new ``inject`` function, because it offers more accurate types and better compatibility with standard decorators, compared to constructor-based injection: https://angular.dev/reference/migrations/inject-function +* Use the new way of defining queries for ``viewChild()``, ``contentChild()``, ``viewChildren()``, ``contentChildren()``: https://ngxtension.netlify.app/utilities/migrations/queries-migration/ +* Use ``OnPush`` change detection strategy for components whenever possible: https://blog.angular-university.io/onpush-change-detection-how-it-works/ .. WARNING:: **Never invoke methods from the html template. The automatic change tracking in Angular will kill the application performance!** @@ -72,7 +89,7 @@ More info about standalone components: https://angular.dev/guide/components/impo -6. Use strict typing to avoid type errors: Do not use ``any``. +6. Use strict typing to avoid type errors: **Never** use ``any``. 7. Do not use anonymous data structures. @@ -92,7 +109,7 @@ More info about standalone components: https://angular.dev/guide/components/impo 4. ``null`` and ``undefined`` ============================= -Use **undefined**. Do not use null. +Use **undefined**. **Never** use ``null``. 5. General Assumptions ====================== @@ -104,6 +121,8 @@ Use **undefined**. Do not use null. ============ Use JSDoc style comments for functions, interfaces, enums, and classes. +Provide extensive documentation inline and using JSDoc to make sure other developers can understand the code and the rationale behind the implementation +without having to read the code. 7. Strings ============ diff --git a/docs/requirements.txt b/docs/requirements.txt index d7f6f239f035..e009c5eb21e1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ Sphinx==7.4.7 sphinx-rtd-theme==2.0.0 -sphinx-autobuild==2024.04.16 +sphinx-autobuild==2024.4.16 sphinxcontrib-bibtex==2.6.2 diff --git a/package-lock.json b/package-lock.json index 8b95e957e32e..f05b86d4cc8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,18 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular/animations": "18.2.2", - "@angular/cdk": "18.2.2", - "@angular/common": "18.2.2", - "@angular/compiler": "18.2.2", - "@angular/core": "18.2.2", - "@angular/forms": "18.2.2", - "@angular/localize": "18.2.2", - "@angular/material": "18.2.2", - "@angular/platform-browser": "18.2.2", - "@angular/platform-browser-dynamic": "18.2.2", - "@angular/router": "18.2.2", - "@angular/service-worker": "18.2.2", + "@angular/animations": "18.2.3", + "@angular/cdk": "18.2.3", + "@angular/common": "18.2.3", + "@angular/compiler": "18.2.3", + "@angular/core": "18.2.3", + "@angular/forms": "18.2.3", + "@angular/localize": "18.2.3", + "@angular/material": "18.2.3", + "@angular/platform-browser": "18.2.3", + "@angular/platform-browser-dynamic": "18.2.3", + "@angular/router": "18.2.3", + "@angular/service-worker": "18.2.3", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.4.3", @@ -34,7 +34,7 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.27.0", + "@sentry/angular": "8.28.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", @@ -43,9 +43,9 @@ "core-js": "3.38.1", "crypto-js": "4.2.0", "dayjs": "1.11.13", - "diff-match-patch-typescript": "1.0.8", + "diff-match-patch-typescript": "1.1.0", "dompurify": "3.1.6", - "export-to-csv": "1.3.0", + "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", "franc-min": "6.2.0", "html-diff-ts": "1.4.2", @@ -59,8 +59,8 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "pdfjs-dist": "^4.5.136", - "posthog-js": "1.160.0", + "pdfjs-dist": "4.6.82", + "posthog-js": "1.160.3", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", @@ -78,33 +78,33 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.2", + "@angular-devkit/build-angular": "18.2.3", "@angular-eslint/builder": "18.3.0", "@angular-eslint/eslint-plugin": "18.3.0", "@angular-eslint/eslint-plugin-template": "18.3.0", "@angular-eslint/schematics": "18.3.0", "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "18.2.2", - "@angular/compiler-cli": "18.2.2", - "@angular/language-service": "18.2.2", - "@sentry/types": "8.27.0", + "@angular/cli": "18.2.3", + "@angular/compiler-cli": "18.2.3", + "@angular/language-service": "18.2.3", + "@sentry/types": "8.28.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", "@types/jest": "29.5.12", "@types/lodash-es": "4.17.12", - "@types/node": "22.5.1", + "@types/node": "22.5.4", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.3.0", - "@typescript-eslint/parser": "8.3.0", + "@typescript-eslint/eslint-plugin": "8.4.0", + "@typescript-eslint/parser": "8.4.0", "eslint": "9.9.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", - "eslint-plugin-jest": "28.8.1", + "eslint-plugin-jest": "28.8.3", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", @@ -116,10 +116,11 @@ "jest-fail-on-console": "3.3.0", "jest-junit": "16.0.0", "jest-preset-angular": "14.2.2", - "lint-staged": "15.2.9", + "lint-staged": "15.2.10", "ng-mocks": "14.13.1", "prettier": "3.3.3", - "sass": "1.77.8", + "rimraf": "6.0.1", + "sass": "1.78.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -210,13 +211,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.2.tgz", - "integrity": "sha512-LPRl9jhcf0NgshaL6RoUy1uL/cAyNt7oxctoZ9EHUu8eh5E9W/jZGhVowjOLpirwqYhmEzKJJIeS49Ssqs3RQg==", + "version": "0.1802.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.3.tgz", + "integrity": "sha512-WQ2AmkUKy1bqrDlNfozW8+VT2Tv/Fdmu4GIXps3ytZANyAKiIvTzmmql2cRCXXraa9FNMjLWNvz+qolDxWVdYQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.2", + "@angular-devkit/core": "18.2.3", "rxjs": "7.8.1" }, "engines": { @@ -226,17 +227,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.2.tgz", - "integrity": "sha512-7HEnTN2T1jnjuItXKcApOsoYGgfou4+POju3ZbwIQukDZ3B2COskvQkVTxqPNrQ0ZjT2mxZYoVlmGW9M+7N25g==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.3.tgz", + "integrity": "sha512-uUQba0SIskKORHcPayt7LpqPRKD//48EW92SgGHEArn2KklM+FSYBOA9OtrJeZ/UAcoJpdLDtvyY4+S7oFzomg==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.2", - "@angular-devkit/build-webpack": "0.1802.2", - "@angular-devkit/core": "18.2.2", - "@angular/build": "18.2.2", + "@angular-devkit/architect": "0.1802.3", + "@angular-devkit/build-webpack": "0.1802.3", + "@angular-devkit/core": "18.2.3", + "@angular/build": "18.2.3", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -247,7 +248,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.2", + "@ngtools/webpack": "18.2.3", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -290,7 +291,7 @@ "vite": "5.4.0", "watchpack": "2.4.1", "webpack": "5.94.0", - "webpack-dev-middleware": "7.3.0", + "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.0.4", "webpack-merge": "6.0.1", "webpack-subresource-integrity": "5.1.0" @@ -380,13 +381,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.2.tgz", - "integrity": "sha512-Pj+YmKh0nJOKl6QAsqYh3SqfuVJrFqjyp5WrG9BgfsMD9GCMD+5teMHNYJlp+vG/C8e7VdZp4rqOon8K9Xn4Mw==", + "version": "0.1802.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.3.tgz", + "integrity": "sha512-/Nixv9uAg6v/OPoZa0PB0zi+iezzBkgLrnrJnestny5B536l9WRtsw97RjeQDu+x2BClQsxNe8NL2A7EvjVD6w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.2", + "@angular-devkit/architect": "0.1802.3", "rxjs": "7.8.1" }, "engines": { @@ -400,9 +401,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.2.tgz", - "integrity": "sha512-Zz0tGptI/QQnUBDdp+1G5wGwQWMjpfe2oO+UohkrDVgFS71yVj4VDnOy51kMTxBvzw+36evTgthPpmzqPIfxBw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.3.tgz", + "integrity": "sha512-vbFs+ofNK9OWeMIcFarFjegXVklhtSdLTEFKZ9trDVr8alTJdjI9AiYa6OOUTDAyq0hqYxV26xlCisWAPe7s5w==", "dev": true, "license": "MIT", "dependencies": { @@ -428,13 +429,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.2.tgz", - "integrity": "sha512-PU6+3nX+gQ3gofR7BGwXuvNUNeeV2raURaZjlPfGpBqjyTBxukMV71QsTTWptAZT4WibCWkTFp6X1gvsOGbjMg==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.3.tgz", + "integrity": "sha512-N3tRAzBW2yWQhebvc1Ha18XTMSXOQTfr8HNjx7Fasx0Fg1tNyGR612MJNZw6je/PqyItKeUHOhztvFMfCQjRyg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.2", + "@angular-devkit/core": "18.2.3", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -547,9 +548,9 @@ } }, "node_modules/@angular/animations": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.2.tgz", - "integrity": "sha512-jh/dGrY77HGm54HdTiQsxmvoRfFeJgHeWAK2+nWCPoc4b7OHcWxy/04cYffs0/27ThmABmppP7ERAyZ0f60uow==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.2.3.tgz", + "integrity": "sha512-rIATopHr83lYR0X05buHeHssq9CGw0I0YPIQcpUTGnlqIpJcQVCf7jCFn4KGZrE9V55hFY3MD4S28njlwCToQQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -558,18 +559,18 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.2" + "@angular/core": "18.2.3" } }, "node_modules/@angular/build": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.2.tgz", - "integrity": "sha512-okaDdTMXnDhvnnnih6rPQnexL6htfEAPr19bB1Ci9d31gEjVuKZCjlcw2sPZ6BUyilwC9nZlCI5vbH1Ljf6mzA==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.3.tgz", + "integrity": "sha512-USrD2Zvcb1te2dnqhH7JZ5XeJDg/t7fjUHR4f93vvMrnrncwCjLoHbHpz01HCHfcIVRgsYUdAmAi1iG7vpak7w==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.2", + "@angular-devkit/architect": "0.1802.3", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -649,9 +650,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.2.tgz", - "integrity": "sha512-+u7ZcMA24WO03vDzlBJJWq+okZLFDeW9JrtHzrdiT09FDt4sdUp+7PddXaZcRHIXjJL+CaCLQ6slaqPNEufqgg==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.3.tgz", + "integrity": "sha512-lUcpYTxPZuntJ1FK7V2ugapCGMIhT6TUDjIGgXfS9AxGSSKgwr8HNs6Ze9pcjYC44UhP40sYAZuiaFwmE60A2A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -666,18 +667,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.2.tgz", - "integrity": "sha512-HVVaMxnbID0q+V3KE+JqzGbPHcBUFo1RKhBZ/jxY7USZNzgtyYbRc0IYqPWNdr99UT5QefTJrjVazJo1nqQZvQ==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.3.tgz", + "integrity": "sha512-40258vuliH6+p8QSByZe5EcIXSj0iR3PNF6yuusClR/ByToHOnmuPw7WC+AYr0ooozmqlim/EjQe4/037OUB3w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.2", - "@angular-devkit/core": "18.2.2", - "@angular-devkit/schematics": "18.2.2", + "@angular-devkit/architect": "0.1802.3", + "@angular-devkit/core": "18.2.3", + "@angular-devkit/schematics": "18.2.3", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.2", + "@schematics/angular": "18.2.3", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -700,9 +701,9 @@ } }, "node_modules/@angular/common": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.2.tgz", - "integrity": "sha512-AQe4xnnNNch/sXRnV82C8FmhijxPATKfPGojC2qbAG2o6VkWKgt5Lbj0O8WxvSIOS5Syedv+O2kLY/JMGWHNtw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-18.2.3.tgz", + "integrity": "sha512-NFL4yXXImSCH7i1xnHykUjHa9vl9827fGiwSV2mnf7LjSUsyDzFD8/54dNuYN9OY8AUD+PnK0YdNro6cczVyIA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -711,14 +712,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.2", + "@angular/core": "18.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.2.tgz", - "integrity": "sha512-gmVNCXZiv/CIk2eKRLnH19N9VsPuE2s3Oxm0MNi003zk1cLy7D4YEm4fSrjKXtPY8MMpRXiu5f63W94hLwWEVw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.2.3.tgz", + "integrity": "sha512-Il3ljs0j1GaYoqYFdShjUP1ryck5xTOaA8uQuRgqwU0FOwEDfugSAM3Qf7nJx/sgxTM0Lm/Nrdv2u6i1gZWeuQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -727,7 +728,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "18.2.2" + "@angular/core": "18.2.3" }, "peerDependenciesMeta": { "@angular/core": { @@ -736,9 +737,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.2.tgz", - "integrity": "sha512-fF7lDrTA12YGqVjF4LyMi4hm58cv9G6CWmzSlvun0nMYCwrbRNnakZsj19dOfiIqqu4MwHaF4w3PTmUSxkMuiw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.3.tgz", + "integrity": "sha512-BcmqYKnkcJTkGjuPztClZNQve7tdI290J5F3iZBx6c7/vaw8EU8EGZtpWYZpgiVn5S6jhcKyc1dLF9ggO9vftg==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -759,14 +760,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.2", + "@angular/compiler": "18.2.3", "typescript": ">=5.4 <5.6" } }, "node_modules/@angular/core": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.2.tgz", - "integrity": "sha512-Rx6XajL0Ydj9hXUSPDvL2Q/kMzWtbiE3VxZFJnkE+fLQiWvr0GncB+NTb/nQ6QlPQ0ly60DvuI3KLcGDuFtGVA==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-18.2.3.tgz", + "integrity": "sha512-VGhMJxj7d0rYpqVfQrcGRB7EE/BCziotft/I/YPl6bOMPSAvMukG7DXQuJdYpNrr62ks78mlzHlZX/cdmB9Prw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -780,9 +781,9 @@ } }, "node_modules/@angular/forms": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.2.tgz", - "integrity": "sha512-K8cv0w6o7+ocQfUrdSA3XaKrYfa1+2TlmtyxPHjEd2mCu2R+Yqo5RqJ3P8keFewJ1+bSLhz6xnn6mumwl0RnUQ==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.2.3.tgz", + "integrity": "sha512-+OBaAH0e8hue9eyLnbgpxg1/X9fps6bwXECfJ0nL5BDPU5itZ428YJbEnj5bTx0hEbqfTRiV4LgexdI+D9eOpw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -791,16 +792,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.2", - "@angular/core": "18.2.2", - "@angular/platform-browser": "18.2.2", + "@angular/common": "18.2.3", + "@angular/core": "18.2.3", + "@angular/platform-browser": "18.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/language-service": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.2.tgz", - "integrity": "sha512-aROQNQeLf+o+F5OVvE/9BUe/Tpv8pjzmrZlogBbic5cb4IqSNhR4RjxbgIyXBO/6bhLCZwqfmMqRbW2J2xqMkg==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-18.2.3.tgz", + "integrity": "sha512-bTZ1O7s0uJqKdd9ImCleRS9Wg6yVy2ZXchnS5ap2gYJx51MJgwOM/fL6is0OsovtZG/UJaKK5FeEqUUxNqZJVA==", "dev": true, "license": "MIT", "engines": { @@ -808,9 +809,9 @@ } }, "node_modules/@angular/localize": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.2.tgz", - "integrity": "sha512-grWQ3CVbizOWCthGpyIlNNnZCpF/xpWYa6tIsPzKOXLCyqFQ7vOEtSludNN1nsUmMlZQt76+wA17Fx0qcNx0EA==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.3.tgz", + "integrity": "sha512-ZTliuRfH/hGwQTmFb1FwKOyMUks2ATuFVFzKnxbsxoo+XgTg+e12FcUfPEfdtPAteZ9gSuc/9hP8sM0RzW0LPg==", "license": "MIT", "dependencies": { "@babel/core": "7.25.2", @@ -827,21 +828,21 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "18.2.2", - "@angular/compiler-cli": "18.2.2" + "@angular/compiler": "18.2.3", + "@angular/compiler-cli": "18.2.3" } }, "node_modules/@angular/material": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.2.tgz", - "integrity": "sha512-c+EQo1GEvM2w3qasgV/BGxB0bpJeSGs2WcMVTXCYVMcqEk8nwpALwfZiCAYl8JoKoiC5k993zz19xP2Eu14qkQ==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.3.tgz", + "integrity": "sha512-JFfvXaMHMhskncaxxus4sDvie9VYdMkfYgfinkLXpZlPFyn1IzjDw0c1BcrcsuD7UxQVZ/v5tucCgq1FQfGRpA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.2", + "@angular/cdk": "18.2.3", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -850,9 +851,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.2.tgz", - "integrity": "sha512-Bfvl8elCFxyJ9vlwamr4X5sVMcp/tSwBal2coyl0WR+/PH2PAAtf+/WMYxIN90yZmPiJx6RZWUSJRlHOFiFp3A==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.3.tgz", + "integrity": "sha512-M2ob4zN7tAcL2mx7U6KnZNqNFPFl9MlPBE0FrjQjIzAjU0wSYPIJXmaPu9aMUp9niyo+He5iX98I+URi2Yc99g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -861,9 +862,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "18.2.2", - "@angular/common": "18.2.2", - "@angular/core": "18.2.2" + "@angular/animations": "18.2.3", + "@angular/common": "18.2.3", + "@angular/core": "18.2.3" }, "peerDependenciesMeta": { "@angular/animations": { @@ -872,9 +873,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.2.tgz", - "integrity": "sha512-UM/+1nY4iIj1v4lxAmV3XRHPAh/4qfNKScCLq8tJGot64rPCbtCl0Rl8rFFGqxAFvTErVDaJycUgWNZSfVl/hw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.3.tgz", + "integrity": "sha512-nWi9ZxN4KpbJkttIckFO1PCoW0+gb/18xFO+JWyLBAtcbsudj/Mv0P/fdOaSfQdLkPhZfORr3ZcfiTkhmuGyEg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -883,16 +884,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.2", - "@angular/compiler": "18.2.2", - "@angular/core": "18.2.2", - "@angular/platform-browser": "18.2.2" + "@angular/common": "18.2.3", + "@angular/compiler": "18.2.3", + "@angular/core": "18.2.3", + "@angular/platform-browser": "18.2.3" } }, "node_modules/@angular/router": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.2.tgz", - "integrity": "sha512-tBHwuNtZNjzYAoVdveTI1ke/ZnQjKhc7gqDk9HCH2JUpdQhGbTvCKwDM51ktJpPMPcZlA263lQyy7VIyvdtK0A==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-18.2.3.tgz", + "integrity": "sha512-fvD9eSDIiIbeYoUokoWkXzu7/ZaxlzKPUHFqX1JuKuH5ciQDeT/d7lp4mj31Bxammhohzi3+z12THJYsCkj/iQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -901,16 +902,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.2", - "@angular/core": "18.2.2", - "@angular/platform-browser": "18.2.2", + "@angular/common": "18.2.3", + "@angular/core": "18.2.3", + "@angular/platform-browser": "18.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/service-worker": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.2.tgz", - "integrity": "sha512-az0v0gNkAjOQ4DThDWfNJv2DkH63B4Vj/WnXd8pbY/C7Be6w3S1mN2y9vJClWAzUH/GSLQHnOrZJfnZtTc8M0w==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-18.2.3.tgz", + "integrity": "sha512-KplaBYhhwsM3gPeOImfDGhAknN+BIcZJkHl8YRnhoUEFHsTZ8LTV02C4LWQL3YTu3pK+uj/lPMKi1CA37cXQ8g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -922,8 +923,8 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "18.2.2", - "@angular/core": "18.2.2" + "@angular/common": "18.2.3", + "@angular/core": "18.2.3" } }, "node_modules/@babel/code-frame": { @@ -3560,15 +3561,15 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.4.7.tgz", - "integrity": "sha512-5YwCySyV1UEgqzz34gNsC38eKxRBtlRDpJLlKcRtTjlYA/yDKuc1rfw+hjw+2WJxbAZtaDPsRl5Zk7J14SBoBw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", + "integrity": "sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", + "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.2", + "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -3591,16 +3592,16 @@ } }, "node_modules/@inquirer/core": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.0.10.tgz", - "integrity": "sha512-TdESOKSVwf6+YWDz8GhS6nKscwzkIyakEzCLJ5Vh6O3Co2ClhCJ0A4MG909MUWfaWdpJm7DE45ii51/2Kat9tA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.1.0.tgz", + "integrity": "sha512-RZVfH//2ytTjmaBIzeKT1zefcQZzuruwkpTwwbe/i2jTl4o9M+iML5ChULzz6iw1Ok8iUBBsRCjY2IEbD8Ft4w==", "dev": true, "license": "MIT", "dependencies": { "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.2", + "@inquirer/type": "^1.5.3", "@types/mute-stream": "^0.0.4", - "@types/node": "^22.1.0", + "@types/node": "^22.5.2", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.3.2", "cli-spinners": "^2.9.2", @@ -3616,14 +3617,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "2.1.22", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.1.22.tgz", - "integrity": "sha512-K1QwTu7GCK+nKOVRBp5HY9jt3DXOfPGPr6WRDrPImkcJRelG9UTx2cAtK1liXmibRrzJlTWOwqgWT3k2XnS62w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-2.2.0.tgz", + "integrity": "sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2", + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", "external-editor": "^3.1.0" }, "engines": { @@ -3631,14 +3632,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "2.1.22", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.1.22.tgz", - "integrity": "sha512-wTZOBkzH+ItPuZ3ZPa9lynBsdMp6kQ9zbjVPYEtSBG7UulGjg2kQiAnUjgyG4SlntpTce5bOmXAPvE4sguXjpA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-2.2.0.tgz", + "integrity": "sha512-PD0z1dTRTIlpcnXRMRvdVPfBe10jBf4i7YLBU8tNWDkf3HxqmdymVvqnT8XG+hxQSvqfpJCe13Jv2Iv1eB3bIg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2", + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -3656,42 +3657,42 @@ } }, "node_modules/@inquirer/input": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.2.9.tgz", - "integrity": "sha512-7Z6N+uzkWM7+xsE+3rJdhdG/+mQgejOVqspoW+w0AbSZnL6nq5tGMEVASaYVWbkoSzecABWwmludO2evU3d31g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", + "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2" + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" }, "engines": { "node": ">=18" } }, "node_modules/@inquirer/number": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.0.10.tgz", - "integrity": "sha512-kWTxRF8zHjQOn2TJs+XttLioBih6bdc5CcosXIzZsrTY383PXI35DuhIllZKu7CdXFi2rz2BWPN9l0dPsvrQOA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-1.1.0.tgz", + "integrity": "sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2" + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" }, "engines": { "node": ">=18" } }, "node_modules/@inquirer/password": { - "version": "2.1.22", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.1.22.tgz", - "integrity": "sha512-5Fxt1L9vh3rAKqjYwqsjU4DZsEvY/2Gll+QkqR4yEpy6wvzLxdSgFhUcxfDAOtO4BEoTreWoznC0phagwLU5Kw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-2.2.0.tgz", + "integrity": "sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2", + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2" }, "engines": { @@ -3721,14 +3722,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.2.4.tgz", - "integrity": "sha512-pb6w9pWrm7EfnYDgQObOurh2d2YH07+eDo3xQBsNAM2GRhliz6wFXGi1thKQ4bN6B0xDd6C3tBsjdr3obsCl3Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-2.3.0.tgz", + "integrity": "sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", - "@inquirer/type": "^1.5.2", + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -3736,15 +3737,15 @@ } }, "node_modules/@inquirer/search": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.0.7.tgz", - "integrity": "sha512-p1wpV+3gd1eST/o5N3yQpYEdFNCzSP0Klrl+5bfD3cTTz8BGG6nf4Z07aBW0xjlKIj1Rp0y3x/X4cZYi6TfcLw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-1.1.0.tgz", + "integrity": "sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", + "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.2", + "@inquirer/type": "^1.5.3", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -3752,15 +3753,15 @@ } }, "node_modules/@inquirer/select": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.4.7.tgz", - "integrity": "sha512-JH7XqPEkBpNWp3gPCqWqY8ECbyMoFcCZANlL6pV9hf59qK6dGmkOlx1ydyhY+KZ0c5X74+W6Mtp+nm2QX0/MAQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", + "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^9.0.10", + "@inquirer/core": "^9.1.0", "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.2", + "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -3769,9 +3770,9 @@ } }, "node_modules/@inquirer/type": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.2.tgz", - "integrity": "sha512-w9qFkumYDCNyDZmNQjf/n6qQuvQ4dMC3BJesY4oF+yr0CxR5vxujflAVeIcS6U336uzi9GM0kAfZlLrZ9UTkpA==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.3.tgz", + "integrity": "sha512-xUQ14WQGR/HK5ei+2CvgcwoH9fQ4PgPGmVFSN0pc1+fVyDL3MREhyAY7nxEErSu6CkllBM3D7e3e+kOvtu+eIg==", "dev": true, "license": "MIT", "dependencies": { @@ -5015,6 +5016,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", "optional": true, "dependencies": { "detect-libc": "^2.0.0", @@ -5035,12 +5037,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", "optional": true }, "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", "optional": true, "dependencies": { "debug": "4" @@ -5053,6 +5057,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "optional": true, "dependencies": { "agent-base": "6", @@ -5066,6 +5071,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", "optional": true, "dependencies": { "semver": "^6.0.0" @@ -5081,6 +5087,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", "optional": true, "dependencies": { "abbrev": "1" @@ -5092,22 +5099,6 @@ "node": ">=6" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -5210,9 +5201,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.2.tgz", - "integrity": "sha512-YhADmc+lVjLt3kze07A+yLry2yzcghdclu+7D3EDfa6fG2Pk33HK3MY2I0Z0BO+Ivoq7cV7yxm+naR+Od0Y5ng==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.3.tgz", + "integrity": "sha512-DDuBHcu23qckt43SexBJaPEIeMc/HKaFOidILZM9D4gU4C9VroMActdR218dvQ802QfL0S46t5Ykz8ENprIfjA==", "dev": true, "license": "MIT", "engines": { @@ -5857,14 +5848,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.2.tgz", - "integrity": "sha512-0uPA1kQ38RnbNrzMlveX/QAqQIDu2INl5IYd3EUbJZRfYSp1VVyOSyuIBJ+1iUl5Y5VUa2uylaVZXhFdKWprXw==", + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.3.tgz", + "integrity": "sha512-whSON70z9HYb4WboVXmPFE/RLKJJQLWNzNcUyi8OSDZkQbJnYgPp0///n738m26Y/XeJDv11q1gESy+Zl2AdUw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.2", - "@angular-devkit/schematics": "18.2.2", + "@angular-devkit/core": "18.2.3", + "@angular-devkit/schematics": "18.2.3", "jsonc-parser": "3.3.1" }, "engines": { @@ -5874,73 +5865,73 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.27.0.tgz", - "integrity": "sha512-YTIwQ1GM1NTRXgN4DvpFSQ2x4pjlqQ0FQAyHW5x2ZYv4z7VmqG4Xkid1P/srQUipECk6nxkebfD4WR19nLsvnQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.28.0.tgz", + "integrity": "sha512-tE9++KEy8SlqibTmYymuxFVAnutsXBqrwQ936WJbjaMfkqXiro7C1El0ybkprskd0rKS7kln20Q6nQlNlMEoTA==", "license": "MIT", "dependencies": { - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.27.0.tgz", - "integrity": "sha512-b71PQc9aK1X9b/SO1DiJlrnAEx4n0MzPZQ/tKd9oRWDyGit6pJWZfQns9r2rvc96kJPMOTxFAa/upXRCkA723A==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.28.0.tgz", + "integrity": "sha512-5vYunPCDBLCJ8QNnhepacdYheiN+UtYxpGAIaC/zjBC1nDuBgWs+TfKPo1UlO/1sesfgs9ibpxtShOweucL61g==", "license": "MIT", "dependencies": { - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.27.0.tgz", - "integrity": "sha512-Ofucncaon98dvlxte2L//hwuG9yILSxNrTz/PmO0k+HzB9q+oBic4667QF+azWR2qv4oKSWpc+vEovP3hVqveA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.28.0.tgz", + "integrity": "sha512-70jvzzOL5O74gahgXKyRkZgiYN93yly5gq+bbj4/6NRQ+EtPd285+ccy0laExdfyK0ugvvwD4v+1MQit52OAsg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.27.0", - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry-internal/browser-utils": "8.28.0", + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.27.0.tgz", - "integrity": "sha512-uuEfiWbjwugB9M4KxXxovHYiKRqg/R6U4EF8xM/Ub4laUuEcWsfRp7lQ3MxL3qYojbca8ncIFic2bIoKMPeejA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.28.0.tgz", + "integrity": "sha512-RfpYHDHMUKGeEdx41QtHITjEn6P3tGaDPHvatqdrD3yv4j+wbJ6laX1PrIxCpGFUtjdzkqi/KUcvUd2kzbH/FA==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.27.0", - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry-internal/replay": "8.28.0", + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.27.0.tgz", - "integrity": "sha512-0BjjrqnVMofVbQGEwfZgYAZWFl4ewkWRjcUj+NIX4iJpRZZniKZxo6XOlo/pTkt4oVHsbNHJO0C1tS+gRZFErg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.28.0.tgz", + "integrity": "sha512-zHl0OSgBsHnQCINepRxYDsosvKnwJPc9tdRJyIgQ6JCG1kWZf0lHncXRnJBkBSrJk2wJQ0acondhwHRyAptRGg==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.27.0", - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0", + "@sentry/browser": "8.28.0", + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0", "tslib": "^2.4.1" }, "engines": { @@ -5954,52 +5945,52 @@ } }, "node_modules/@sentry/browser": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.27.0.tgz", - "integrity": "sha512-eL1eaHwoYUGkp4mpeYesH6WtCrm+0u9jYCW5Lm0MAeTmpx22BZKEmj0OljuUJXGnJwFbvPDlRjyz6QG11m8kZA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.28.0.tgz", + "integrity": "sha512-i/gjMYzIGQiPFH1pCbdnTwH9xs9mTAqzN+goP3GWX5a58frc7h8vxyA/5z0yMd0aCW6U8mVxnoAT72vGbKbx0g==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.27.0", - "@sentry-internal/feedback": "8.27.0", - "@sentry-internal/replay": "8.27.0", - "@sentry-internal/replay-canvas": "8.27.0", - "@sentry/core": "8.27.0", - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry-internal/browser-utils": "8.28.0", + "@sentry-internal/feedback": "8.28.0", + "@sentry-internal/replay": "8.28.0", + "@sentry-internal/replay-canvas": "8.28.0", + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.27.0.tgz", - "integrity": "sha512-4frlXluHT3Du+Omw91K04jpvbfMtydvg4Bxj2+gt/DT19Swhm/fbEpzdUjgbAd3Jinj/n0qk/jFRXjr9JZKFjg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.28.0.tgz", + "integrity": "sha512-+If9uubvpZpvaQQw4HLiKPhrSS9/KcoA/AcdQkNm+5CVwAoOmDPtyYfkPBgfo2hLZnZQqR1bwkz/PrNoOm+gqA==", "license": "MIT", "dependencies": { - "@sentry/types": "8.27.0", - "@sentry/utils": "8.27.0" + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.27.0.tgz", - "integrity": "sha512-B6lrP46+m2x0lfqWc9F4VcUbN893mVGnPEd7KIMRk95mPzkFJ3sNxggTQF5/ZfNO7lDQYQb22uysB5sj/BqFiw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.28.0.tgz", + "integrity": "sha512-hOfqfd92/AzBrEdMgmmV1VfOXJbIfleFTnerRl0mg/+CcNgP/6+Fdonp354TD56ouWNF2WkOM6sEKSXMWp6SEQ==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/utils": { - "version": "8.27.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.27.0.tgz", - "integrity": "sha512-gyJM3SyLQe0A3mkQVVNdKYvk3ZoikkYgyA/D+5StFNLKdyUgEbJgXOGXrQSSYPF7BSX6Sc5b0KHCglPII0KuKw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.28.0.tgz", + "integrity": "sha512-smhk7PJpvDMQ2DB5p2qn9UeoUHdU41IgjMmS2xklZpa8tjzBTxDeWpGvrX2fuH67D9bAJuLC/XyZjJCHLoEW5g==", "license": "MIT", "dependencies": { - "@sentry/types": "8.27.0" + "@sentry/types": "8.28.0" }, "engines": { "node": ">=14.18" @@ -6418,19 +6409,6 @@ "@types/trusted-types": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -6593,9 +6571,9 @@ } }, "node_modules/@types/node": { - "version": "22.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", - "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dev": true, "license": "MIT", "dependencies": { @@ -6643,9 +6621,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", - "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", + "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6799,17 +6777,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.4.0.tgz", + "integrity": "sha512-rg8LGdv7ri3oAlenMACk9e+AR4wUV0yrrG+XKsGKOK0EVgeEDqurkXMPILG2836fW4ibokTB5v4b6Z9+GYQDEw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.4.0", + "@typescript-eslint/type-utils": "8.4.0", + "@typescript-eslint/utils": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -6833,16 +6811,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.4.0.tgz", + "integrity": "sha512-NHgWmKSgJk5K9N16GIhQ4jSobBoJwrmURaLErad0qlLjrpP5bECYg+wxVTGlGZmJbU03jj/dfnb6V9bw+5icsA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.4.0", + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/typescript-estree": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0", "debug": "^4.3.4" }, "engines": { @@ -6862,14 +6840,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.4.0.tgz", + "integrity": "sha512-n2jFxLeY0JmKfUqy3P70rs6vdoPjHK8P/w+zJcV3fk0b0BwRXC/zxRTEnAsgYT7MwdQDt/ZEbtdzdVC+hcpF0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6880,14 +6858,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.4.0.tgz", + "integrity": "sha512-pu2PAmNrl9KX6TtirVOrbLPLwDmASpZhK/XU7WvoKoCUkdtq9zF7qQ7gna0GBZFN0hci0vHaSusiL2WpsQk37A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.4.0", + "@typescript-eslint/utils": "8.4.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -6905,9 +6883,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.4.0.tgz", + "integrity": "sha512-T1RB3KQdskh9t3v/qv7niK6P8yvn7ja1mS7QK7XfRVL6wtZ8/mFs/FHf4fKvTA0rKnqnYxl/uHFNbnEt0phgbw==", "dev": true, "license": "MIT", "engines": { @@ -6919,14 +6897,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.4.0.tgz", + "integrity": "sha512-kJ2OIP4dQw5gdI4uXsaxUZHRwWAGpREJ9Zq6D5L0BweyOrWsL6Sz0YcAZGWhvKnH7fm1J5YFE1JrQL0c9dd53A==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -6948,16 +6926,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.4.0.tgz", + "integrity": "sha512-swULW8n1IKLjRAgciCkTCafyTHHfwVQFt8DovmaF69sKbOxTSFMmIZaSHjqO9i/RV0wIblaawhzvtva8Nmm7lQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.4.0", + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/typescript-estree": "8.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6971,13 +6949,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.4.0.tgz", + "integrity": "sha512-zTQD6WLNTre1hj5wp09nBIDiOc2U5r/qmzo7wxPn4ZgAjHql09EofqhF9WF+fZHzL5aCyaIpPcT2hyxl73kr9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.4.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -7452,6 +7430,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC", "optional": true }, "node_modules/are-we-there-yet": { @@ -7459,6 +7438,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", "deprecated": "This package is no longer supported.", + "license": "ISC", "optional": true, "dependencies": { "delegates": "^1.0.0", @@ -7472,6 +7452,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", "optional": true, "dependencies": { "inherits": "^2.0.3", @@ -8268,9 +8249,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001654", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001654.tgz", - "integrity": "sha512-wLJc602fW0OdrUR+PqsBUH3dgrjDcT+mWs/Kw86zPvgjiqOiI2TXMkBFK4KihYzZclmJxrFwgYhZDSEogFai/g==", + "version": "1.0.30001657", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001657.tgz", + "integrity": "sha512-DPbJAlP8/BAXy3IgiWmZKItubb3TYGP0WscQQlVGIfT4s/YlFYVuJgyOsQNP7rJRChx/qdMeLJQJP0Sgg2yjNA==", "funding": [ { "type": "opencollective", @@ -8292,6 +8273,7 @@ "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { "@mapbox/node-pre-gyp": "^1.0.0", @@ -8631,6 +8613,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", "optional": true, "bin": { "color-support": "bin.js" @@ -8732,6 +8715,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", "optional": true }, "node_modules/content-disposition": { @@ -9593,6 +9577,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", "optional": true, "dependencies": { "mimic-response": "^2.0.0" @@ -9734,6 +9719,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", "optional": true }, "node_modules/depd": { @@ -9805,9 +9791,9 @@ } }, "node_modules/diff-match-patch-typescript": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/diff-match-patch-typescript/-/diff-match-patch-typescript-1.0.8.tgz", - "integrity": "sha512-UPvsAUDje0DUOhx5V5jrXPe/5GHyBwZzS4myPFDM3Tbd/xJQyXbMkklc6aFqKBYzyhtdSMPD1CHHTDye/7cgow==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/diff-match-patch-typescript/-/diff-match-patch-typescript-1.1.0.tgz", + "integrity": "sha512-7WFVb3bRj5o+xRJtd1mLpbB9o19GE1FpY/v7z4GgMurmyaxZnuYdsEwn/K93ugn3nB+ce7KMn9hYjfAtXmUkVQ==", "license": "Apache-2.0" }, "node_modules/diff-sequences": { @@ -9952,9 +9938,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", - "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "version": "1.5.15", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.15.tgz", + "integrity": "sha512-Z4rIDoImwEJW+YYKnPul4DzqsWVqYetYVN3XqDmRpgV0mjz0hYTaeeh+8/9CL1bk3AHYmF4freW/NTiVoXA2gA==", "license": "ISC" }, "node_modules/emittery": { @@ -10434,9 +10420,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "28.8.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.1.tgz", - "integrity": "sha512-G46XMyYu6PtSNJUkQ0hsPjzXYpzq/O4vpCciMizTKRJG8kNsRreGoMRDG6H9FIB/xVgfFuclVnuX4XRvFUzrZQ==", + "version": "28.8.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.3.tgz", + "integrity": "sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11051,9 +11037,9 @@ "license": "Apache-2.0" }, "node_modules/export-to-csv": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/export-to-csv/-/export-to-csv-1.3.0.tgz", - "integrity": "sha512-msPjbfozZdYzDghAEKmCVH5veMeKHNacplE6noXvGiA8AeV1qa/SOxp6JXDjF9R8Kf6v3ypI6jskiY19dkhZeA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/export-to-csv/-/export-to-csv-1.4.0.tgz", + "integrity": "sha512-6CX17Cu+rC2Fi2CyZ4CkgVG3hLl6BFsdAxfXiZkmDFIDY4mRx2y2spdeH6dqPHI9rP+AsHEfGeKz84Uuw7+Pmg==", "license": "MIT", "engines": { "node": "^v12.20.0 || >=v14.13.0" @@ -11395,9 +11381,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.8.tgz", + "integrity": "sha512-xgrmBhBToVKay1q2Tao5LI26B83UhrB/vM1avwVSDzt8rx3rO6AizBAaF46EgksTVr+rFTQaqZZ9MVBfUe4nig==", "dev": true, "funding": [ { @@ -11543,6 +11529,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", "deprecated": "This package is no longer supported.", + "license": "ISC", "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", @@ -11563,12 +11550,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", "optional": true }, "node_modules/gauge/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", "optional": true, "engines": { "node": ">=8" @@ -11578,12 +11567,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", "optional": true }, "node_modules/gauge/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", "optional": true, "dependencies": { "emoji-regex": "^8.0.0", @@ -11875,6 +11866,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", "optional": true }, "node_modules/hasown": { @@ -15086,9 +15078,9 @@ } }, "node_modules/launch-editor": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.1.tgz", - "integrity": "sha512-elBx2l/tp9z99X5H/qev8uyDywVh0VXAwEbjk8kJhnc5grOFkGh7aW6q55me9xnYbss261XtnUrysZ+XvGbhQA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz", + "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==", "dev": true, "license": "MIT", "dependencies": { @@ -15248,9 +15240,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "15.2.9", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.9.tgz", - "integrity": "sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==", + "version": "15.2.10", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", + "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", "dev": true, "license": "MIT", "dependencies": { @@ -15260,7 +15252,7 @@ "execa": "~8.0.1", "lilconfig": "~3.1.2", "listr2": "~8.2.4", - "micromatch": "~4.0.7", + "micromatch": "~4.0.8", "pidtree": "~0.6.0", "string-argv": "~0.3.2", "yaml": "~2.5.0" @@ -16066,6 +16058,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", "optional": true, "engines": { "node": ">=8" @@ -16431,6 +16424,7 @@ "version": "2.20.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "license": "MIT", "optional": true }, "node_modules/nanoid": { @@ -16585,6 +16579,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "optional": true, "dependencies": { "whatwg-url": "^5.0.0" @@ -16605,18 +16600,21 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", "optional": true }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", "optional": true }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", "optional": true, "dependencies": { "tr46": "~0.0.3", @@ -16914,6 +16912,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", "deprecated": "This package is no longer supported.", + "license": "ISC", "optional": true, "dependencies": { "are-we-there-yet": "^2.0.0", @@ -17530,15 +17529,17 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.1.tgz", "integrity": "sha512-Fl2z/BHvkTNvkuBzYTpTuirHZg6wW9z8+4SND/3mDTEcYbbNKWAy21dz9D3ePNNwrrK8pqZO5vLPZ1hLF6T7XA==", + "license": "MIT", "optional": true, "engines": { "node": ">=6" } }, "node_modules/pdfjs-dist": { - "version": "4.5.136", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.5.136.tgz", - "integrity": "sha512-V1BALcAN/FmxBEShLxoP73PlQZAZtzlaNfRbRhJrKvXzjLC5VaIlBAQUJuWP8iaYUmIdmdLHmt3E2TBglxOm3w==", + "version": "4.6.82", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.6.82.tgz", + "integrity": "sha512-BUOryeRFwvbLe0lOU6NhkJNuVQUp06WxlJVVCsxdmJ4y5cU3O3s3/0DunVdK1PMm7v2MUw52qKYaidhDH1Z9+w==", + "license": "Apache-2.0", "engines": { "node": ">=18" }, @@ -17554,9 +17555,9 @@ "license": "MIT" }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "license": "ISC" }, "node_modules/picomatch": { @@ -17872,9 +17873,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.160.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.160.0.tgz", - "integrity": "sha512-K/RRgmPYIpP69nnveCJfkclb8VU+R+jsgqlrKaLGsM5CtQM9g01WOzAiT3u36WLswi58JiFMXgJtECKQuoqTgQ==", + "version": "1.160.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.160.3.tgz", + "integrity": "sha512-mGvxOIlWPtdPx8EI0MQ81wNKlnH2K0n4RqwQOl044b34BCKiFVzZ7Hc7geMuZNaRAvCi5/5zyGeWHcAYZQxiMQ==", "license": "MIT", "dependencies": { "fflate": "^0.4.8", @@ -18653,38 +18654,107 @@ "license": "MIT" }, "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", "dev": true, "license": "ISC", "dependencies": { - "glob": "^10.3.7" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rimraf/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", + "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", + "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -18791,9 +18861,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.77.8", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", - "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", + "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19081,6 +19151,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", "optional": true }, "node_modules/set-function-length": { @@ -19296,12 +19367,14 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "optional": true }, "node_modules/simple-get": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", "optional": true, "dependencies": { "decompress-response": "^4.2.0", @@ -21914,6 +21987,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" @@ -21923,12 +21997,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", "optional": true }, "node_modules/wide-align/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", "optional": true, "engines": { "node": ">=8" @@ -21938,6 +22014,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", "optional": true, "dependencies": { "emoji-regex": "^8.0.0", @@ -22237,9 +22314,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index 951a680f6aa5..ab806fb582e0 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,18 @@ "node_modules" ], "dependencies": { - "@angular/animations": "18.2.2", - "@angular/cdk": "18.2.2", - "@angular/common": "18.2.2", - "@angular/compiler": "18.2.2", - "@angular/core": "18.2.2", - "@angular/forms": "18.2.2", - "@angular/localize": "18.2.2", - "@angular/material": "18.2.2", - "@angular/platform-browser": "18.2.2", - "@angular/platform-browser-dynamic": "18.2.2", - "@angular/router": "18.2.2", - "@angular/service-worker": "18.2.2", + "@angular/animations": "18.2.3", + "@angular/cdk": "18.2.3", + "@angular/common": "18.2.3", + "@angular/compiler": "18.2.3", + "@angular/core": "18.2.3", + "@angular/forms": "18.2.3", + "@angular/localize": "18.2.3", + "@angular/material": "18.2.3", + "@angular/platform-browser": "18.2.3", + "@angular/platform-browser-dynamic": "18.2.3", + "@angular/router": "18.2.3", + "@angular/service-worker": "18.2.3", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", "@fingerprintjs/fingerprintjs": "4.4.3", @@ -37,7 +37,7 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "15.0.0", "@ngx-translate/http-loader": "8.0.0", - "@sentry/angular": "8.27.0", + "@sentry/angular": "8.28.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.4.0", "@vscode/codicons": "0.0.36", @@ -46,9 +46,9 @@ "core-js": "3.38.1", "crypto-js": "4.2.0", "dayjs": "1.11.13", - "diff-match-patch-typescript": "1.0.8", + "diff-match-patch-typescript": "1.1.0", "dompurify": "3.1.6", - "export-to-csv": "1.3.0", + "export-to-csv": "1.4.0", "fast-json-patch": "3.1.1", "franc-min": "6.2.0", "html-diff-ts": "1.4.2", @@ -62,8 +62,8 @@ "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", - "pdfjs-dist": "^4.5.136", - "posthog-js": "1.160.0", + "pdfjs-dist": "4.6.82", + "posthog-js": "1.160.3", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", @@ -97,11 +97,12 @@ "eslint": "^9.9.0" }, "eslint-plugin-jest": { - "@typescript-eslint/eslint-plugin": "^8.1.0" + "@typescript-eslint/eslint-plugin": "^8.4.0" }, "jsdom": "24.1.1", "katex": "0.16.11", "postcss": "8.4.41", + "rimraf": "6.0.1", "semver": "7.6.3", "showdown-katex": { "showdown": "2.1.0" @@ -114,33 +115,33 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.2", + "@angular-devkit/build-angular": "18.2.3", "@angular-eslint/builder": "18.3.0", "@angular-eslint/eslint-plugin": "18.3.0", "@angular-eslint/eslint-plugin-template": "18.3.0", "@angular-eslint/schematics": "18.3.0", "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "18.2.2", - "@angular/compiler-cli": "18.2.2", - "@angular/language-service": "18.2.2", - "@sentry/types": "8.27.0", + "@angular/cli": "18.2.3", + "@angular/compiler-cli": "18.2.3", + "@angular/language-service": "18.2.3", + "@sentry/types": "8.28.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", "@types/jest": "29.5.12", "@types/lodash-es": "4.17.12", - "@types/node": "22.5.1", + "@types/node": "22.5.4", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.3.0", - "@typescript-eslint/parser": "8.3.0", + "@typescript-eslint/eslint-plugin": "8.4.0", + "@typescript-eslint/parser": "8.4.0", "eslint": "9.9.1", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", - "eslint-plugin-jest": "28.8.1", + "eslint-plugin-jest": "28.8.3", "eslint-plugin-jest-extended": "2.4.0", "eslint-plugin-prettier": "5.2.1", "folder-hash": "4.0.4", @@ -152,10 +153,11 @@ "jest-fail-on-console": "3.3.0", "jest-junit": "16.0.0", "jest-preset-angular": "14.2.2", - "lint-staged": "15.2.9", + "lint-staged": "15.2.10", "ng-mocks": "14.13.1", "prettier": "3.3.3", - "sass": "1.77.8", + "rimraf": "6.0.1", + "sass": "1.78.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" diff --git a/src/main/java/de/tum/in/www1/artemis/repository/PersistenceAuditEventRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/PersistenceAuditEventRepository.java index 815b57746081..172de00d1839 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/PersistenceAuditEventRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/PersistenceAuditEventRepository.java @@ -3,6 +3,7 @@ import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.LOAD; import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -52,6 +53,7 @@ default Page findAllWithDataByAuditEventDateBetween(Instan return Page.empty(pageable); } List result = findWithDataByIdIn(ids); + result.sort(Comparator.comparing(event -> ids.indexOf(event.getId()))); return new PageImpl<>(result, pageable, countByAuditEventDateBetween(fromDate, toDate)); } @@ -73,6 +75,7 @@ default Page findAllWithData(@NotNull Pageable pageable) { return Page.empty(pageable); } List result = findWithDataByIdIn(ids); + result.sort(Comparator.comparing(event -> ids.indexOf(event.getId()))); return new PageImpl<>(result, pageable, count()); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java index f339eb61f51d..83c8b716db94 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/StudentParticipationRepository.java @@ -39,6 +39,7 @@ import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.quiz.QuizSubmittedAnswerCount; import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; /** * Spring Data JPA repository for the Participation entity. @@ -1230,4 +1231,53 @@ SELECT COALESCE(AVG(p.presentationScore), 0) AND p.presentationScore IS NOT NULL """) double getAvgPresentationScoreByCourseId(@Param("courseId") long courseId); + + /** + * Retrieves aggregated feedback details for a given exercise, including the count of each unique feedback detail text and test case name. + *
+ * The relative count and task number are initially set to 0 and are calculated in a separate step in the service layer. + * + * @param exerciseId Exercise ID. + * @return a list of {@link FeedbackDetailDTO} objects, with the relative count and task number set to 0. + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO( + COUNT(f.id), + 0, + f.detailText, + f.testCase.testName, + 0 + ) + FROM StudentParticipation p + JOIN p.results r + JOIN r.feedbacks f + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + AND r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + ) + AND f.positive = FALSE + GROUP BY f.detailText, f.testCase.testName + """) + List findAggregatedFeedbackByExerciseId(@Param("exerciseId") long exerciseId); + + /** + * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. + * + * @param exerciseId Exercise ID. + * @return The count of distinct latest results for the exercise. + */ + @Query(""" + SELECT COUNT(DISTINCT r.id) + FROM StudentParticipation p + JOIN p.results r + WHERE p.exercise.id = :exerciseId + AND p.testRun = FALSE + AND r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + ) + """) + long countDistinctResultsByExerciseId(@Param("exerciseId") long exerciseId); } 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 3ec13cb3d5e0..139a3b01b01c 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 @@ -34,6 +34,7 @@ import de.tum.in.www1.artemis.domain.enumeration.BuildPlanType; import de.tum.in.www1.artemis.domain.enumeration.FeedbackType; import de.tum.in.www1.artemis.domain.exam.Exam; +import de.tum.in.www1.artemis.domain.hestia.ProgrammingExerciseTask; 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; @@ -49,11 +50,15 @@ import de.tum.in.www1.artemis.repository.ResultRepository; import de.tum.in.www1.artemis.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.in.www1.artemis.repository.StudentExamRepository; +import de.tum.in.www1.artemis.repository.StudentParticipationRepository; import de.tum.in.www1.artemis.repository.TemplateProgrammingExerciseParticipationRepository; import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.repository.hestia.ProgrammingExerciseTaskRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.service.connectors.localci.dto.ResultBuildJob; import de.tum.in.www1.artemis.service.connectors.lti.LtiNewResultService; +import de.tum.in.www1.artemis.service.hestia.ProgrammingExerciseTaskService; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.websocket.ResultWebsocketService; @@ -99,6 +104,10 @@ public class ResultService { private final BuildLogEntryService buildLogEntryService; + private final StudentParticipationRepository studentParticipationRepository; + + private final ProgrammingExerciseTaskService programmingExerciseTaskService; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, @@ -106,7 +115,8 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository, - BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService) { + BuildJobRepository buildJobRepository, BuildLogEntryService buildLogEntryService, StudentParticipationRepository studentParticipationRepository, + ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ProgrammingExerciseTaskService programmingExerciseTaskService) { this.userRepository = userRepository; this.resultRepository = resultRepository; this.ltiNewResultService = ltiNewResultService; @@ -125,6 +135,8 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos this.studentExamRepository = studentExamRepository; this.buildJobRepository = buildJobRepository; this.buildLogEntryService = buildLogEntryService; + this.studentParticipationRepository = studentParticipationRepository; + this.programmingExerciseTaskService = programmingExerciseTaskService; } /** @@ -513,4 +525,33 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { return result; } } + + /** + * Retrieves aggregated feedback details for a given exercise, calculating relative counts based on the total number of distinct results. + * The task numbers are assigned based on the associated test case names, using the set of tasks fetched from the database. + *
+ * For each feedback detail: + * 1. The relative count is calculated as a percentage of the total number of distinct results for the exercise. + * 2. The task number is determined by matching the test case name with the tasks. + * + * @param exerciseId The ID of the exercise for which feedback details should be retrieved. + * @return A list of FeedbackDetailDTO objects, each containing: + * - feedback count, + * - relative count (as a percentage of distinct results), + * - detail text, + * - test case name, + * - determined task number (based on the test case name). + */ + public List findAggregatedFeedbackByExerciseId(long exerciseId) { + long distinctResultCount = studentParticipationRepository.countDistinctResultsByExerciseId(exerciseId); + Set tasks = programmingExerciseTaskService.getTasksWithUnassignedTestCases(exerciseId); + List feedbackDetails = studentParticipationRepository.findAggregatedFeedbackByExerciseId(exerciseId); + + return feedbackDetails.stream().map(detail -> { + double relativeCount = (detail.count() * 100.0) / distinctResultCount; + int taskNumber = tasks.stream().filter(task -> task.getTestCases().stream().anyMatch(tc -> tc.getTestName().equals(detail.testCaseName()))).findFirst() + .map(task -> tasks.stream().toList().indexOf(task) + 1).orElse(0); + return new FeedbackDetailDTO(detail.count(), relativeCount, detail.detailText(), detail.testCaseName(), taskNumber); + }).toList(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildJobExecutionService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildJobExecutionService.java index fcfc9357ce21..305c68a46c9b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildJobExecutionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildJobExecutionService.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.UUID; import jakarta.annotation.Nullable; @@ -36,6 +37,7 @@ import de.tum.in.www1.artemis.domain.VcsRepositoryUri; import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; import de.tum.in.www1.artemis.domain.enumeration.StaticCodeAnalysisTool; +import de.tum.in.www1.artemis.exception.GitException; import de.tum.in.www1.artemis.exception.LocalCIException; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildJobQueueItem; import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildResult; @@ -64,6 +66,8 @@ public class BuildJobExecutionService { private final BuildLogsMap buildLogsMap; + private static final int MAX_CLONE_RETRIES = 3; + @Value("${artemis.version-control.default-branch:main}") private String defaultBranch; @@ -275,18 +279,19 @@ private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String buildJobContainerService.stopContainer(containerName); // Delete the cloned repositories - deleteCloneRepo(assignmentRepositoryUri, assignmentRepoCommitHash, buildJob.id()); - deleteCloneRepo(testRepositoryUri, assignmentRepoCommitHash, buildJob.id()); + deleteCloneRepo(assignmentRepositoryUri, assignmentRepoCommitHash, buildJob.id(), assignmentRepositoryPath); + deleteCloneRepo(testRepositoryUri, assignmentRepoCommitHash, buildJob.id(), testsRepositoryPath); // do not try to delete the temp repository if it does not exist or is the same as the assignment reposity if (solutionRepositoryUri != null && !Objects.equals(assignmentRepositoryUri.repositorySlug(), solutionRepositoryUri.repositorySlug())) { - deleteCloneRepo(solutionRepositoryUri, assignmentRepoCommitHash, buildJob.id()); + deleteCloneRepo(solutionRepositoryUri, assignmentRepoCommitHash, buildJob.id(), solutionRepositoryPath); } - for (VcsRepositoryUri auxiliaryRepositoryUri : auxiliaryRepositoriesUris) { - deleteCloneRepo(auxiliaryRepositoryUri, assignmentRepoCommitHash, buildJob.id()); + + for (int i = 0; i < auxiliaryRepositoriesUris.length; i++) { + deleteCloneRepo(auxiliaryRepositoriesUris[i], assignmentRepoCommitHash, buildJob.id(), auxiliaryRepositoriesPaths[i]); } try { - FileUtils.deleteDirectory(Path.of(CHECKED_OUT_REPOS_TEMP_DIR, assignmentRepoCommitHash).toFile()); + deleteRepoParentFolder(assignmentRepoCommitHash, assignmentRepositoryPath, testRepoCommitHash, testsRepositoryPath); } catch (IOException e) { msg = "Could not delete " + CHECKED_OUT_REPOS_TEMP_DIR + " directory"; @@ -453,32 +458,52 @@ private BuildResult constructBuildResult(List fai } private Path cloneRepository(VcsRepositoryUri repositoryUri, @Nullable String commitHash, boolean checkout, String buildJobId) { + Repository repository = null; + + for (int attempt = 1; attempt <= MAX_CLONE_RETRIES; attempt++) { + try { + // Generate a random folder name for the repository parent folder if the commit hash is null. This is to avoid conflicts when cloning multiple repositories. + String repositoryParentFolder = commitHash != null ? commitHash : UUID.randomUUID().toString(); + // Clone the assignment repository into a temporary directory + repository = buildJobGitService.cloneRepository(repositoryUri, + Path.of(CHECKED_OUT_REPOS_TEMP_DIR, repositoryParentFolder, repositoryUri.folderNameForRepositoryUri())); + + break; + } + catch (GitAPIException | IOException | URISyntaxException e) { + if (attempt >= MAX_CLONE_RETRIES) { + String msg = "Error while cloning repository " + repositoryUri.repositorySlug() + " with uri " + repositoryUri + " after " + MAX_CLONE_RETRIES + " attempts"; + buildLogsMap.appendBuildLogEntry(buildJobId, msg); + throw new LocalCIException(msg, e); + } + buildLogsMap.appendBuildLogEntry(buildJobId, + "Attempt " + attempt + " to clone repository " + repositoryUri.repositorySlug() + " failed due to " + e.getMessage() + ". Retrying..."); + } + } + try { - // Clone the assignment repository into a temporary directory - // TODO: use a random value if commitHash is null - Repository repository = buildJobGitService.cloneRepository(repositoryUri, Path.of(CHECKED_OUT_REPOS_TEMP_DIR, commitHash, repositoryUri.folderNameForRepositoryUri())); if (checkout && commitHash != null) { // Checkout the commit hash buildJobGitService.checkoutRepositoryAtCommit(repository, commitHash); } + // if repository is not closed, it causes weird IO issues when trying to delete the repository later on // java.io.IOException: Unable to delete file: ...\.git\objects\pack\... repository.closeBeforeDelete(); return repository.getLocalPath(); } - catch (GitAPIException | IOException | URISyntaxException e) { - String msg = "Error while cloning repository " + repositoryUri.repositorySlug() + " with uri " + repositoryUri; + catch (GitException e) { + String msg = "Error while checking out commit " + commitHash + " in repository " + repositoryUri.repositorySlug(); buildLogsMap.appendBuildLogEntry(buildJobId, msg); throw new LocalCIException(msg, e); } } - private void deleteCloneRepo(VcsRepositoryUri repositoryUri, @Nullable String commitHash, String buildJobId) { + private void deleteCloneRepo(VcsRepositoryUri repositoryUri, @Nullable String commitHash, String buildJobId, Path repositoryPath) { String msg; try { - // TODO: handle the case when commitHash is null - Repository repository = buildJobGitService.getExistingCheckedOutRepositoryByLocalPath( - Paths.get(CHECKED_OUT_REPOS_TEMP_DIR, commitHash, repositoryUri.folderNameForRepositoryUri()), repositoryUri, defaultBranch); + Path repositoryPathForDeletion = commitHash != null ? Paths.get(CHECKED_OUT_REPOS_TEMP_DIR, commitHash, repositoryUri.folderNameForRepositoryUri()) : repositoryPath; + Repository repository = buildJobGitService.getExistingCheckedOutRepositoryByLocalPath(repositoryPathForDeletion, repositoryUri, defaultBranch); if (repository == null) { msg = "Repository with commit hash " + commitHash + " not found"; buildLogsMap.appendBuildLogEntry(buildJobId, msg); @@ -497,4 +522,16 @@ private void deleteCloneRepo(VcsRepositoryUri repositoryUri, @Nullable String co throw new LocalCIException(msg, e); } } + + private void deleteRepoParentFolder(String assignmentRepoCommitHash, Path assignmentRepositoryPath, String testRepoCommitHash, Path testsRepositoryPath) throws IOException { + Path assignmentRepo = assignmentRepoCommitHash != null ? Path.of(CHECKED_OUT_REPOS_TEMP_DIR, assignmentRepoCommitHash) + : getRepositoryParentFolderPath(assignmentRepositoryPath); + FileUtils.deleteDirectory(assignmentRepo.toFile()); + Path testRepo = testRepoCommitHash != null ? Path.of(CHECKED_OUT_REPOS_TEMP_DIR, testRepoCommitHash) : getRepositoryParentFolderPath(testsRepositoryPath); + FileUtils.deleteDirectory(testRepo.toFile()); + } + + private Path getRepositoryParentFolderPath(Path repoPath) { + return repoPath.getParent().getParent(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java index 37b9afb94432..bd901ccd824b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java @@ -45,12 +45,14 @@ import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastTutor; +import de.tum.in.www1.artemis.security.annotations.enforceRoleInExercise.EnforceAtLeastEditorInExercise; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationAuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationService; import de.tum.in.www1.artemis.service.ResultService; import de.tum.in.www1.artemis.service.exam.ExamDateService; import de.tum.in.www1.artemis.web.rest.dto.ResultWithPointsPerGradingCriterionDTO; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; @@ -276,4 +278,18 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo return ResponseEntity.created(new URI("/api/results/" + savedResult.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, savedResult.getId().toString())).body(savedResult); } + + /** + * GET /exercises/:exerciseId/feedback-details : Retrieves all aggregated feedback details for a given exercise. + * The feedback details include counts and relative counts of feedback occurrences, along with associated test case names and task numbers. + * + * @param exerciseId The ID of the exercise for which feedback details should be retrieved. + * @return A ResponseEntity containing a list of {@link FeedbackDetailDTO}s + */ + @GetMapping("exercises/{exerciseId}/feedback-details") + @EnforceAtLeastEditorInExercise + public ResponseEntity> getAllFeedbackDetailsForExercise(@PathVariable Long exerciseId) { + log.debug("REST request to get all Feedback details for Exercise {}", exerciseId); + return ResponseEntity.ok(resultService.findAggregatedFeedbackByExerciseId(exerciseId)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java new file mode 100644 index 000000000000..d9e1f86d231d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/feedback/FeedbackDetailDTO.java @@ -0,0 +1,7 @@ +package de.tum.in.www1.artemis.web.rest.dto.feedback; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record FeedbackDetailDTO(long count, double relativeCount, String detailText, String testCaseName, int taskNumber) { +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html index 2341a771ae26..afe1b2e3e29b 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.html @@ -6,21 +6,30 @@ } @if (lectureUnit(); as lectureUnit) { - @switch (lectureUnit.type) { - @case (LectureUnitType.VIDEO) { - - } - @case (LectureUnitType.TEXT) { - - } - @case (LectureUnitType.ATTACHMENT) { - - } - @case (LectureUnitType.EXERCISE) { - - } - @case (LectureUnitType.ONLINE) { - +
+
+ @switch (lectureUnit.type) { + @case (LectureUnitType.VIDEO) { + + } + @case (LectureUnitType.TEXT) { + + } + @case (LectureUnitType.ATTACHMENT) { + + } + @case (LectureUnitType.EXERCISE) { + + } + @case (LectureUnitType.ONLINE) { + + } + } +
+ @if (isCommunicationEnabled()) { +
+ +
} - } +
} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts index c927e2a4532e..77f4cf3dd262 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-lecture-unit/learning-path-lecture-unit.component.ts @@ -1,4 +1,4 @@ -import { Component, InputSignal, WritableSignal, effect, inject, input, signal } from '@angular/core'; +import { Component, computed, effect, inject, input, signal } from '@angular/core'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AlertService } from 'app/core/util/alert.service'; import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; @@ -11,24 +11,30 @@ import { VideoUnitComponent } from 'app/overview/course-lectures/video-unit/vide import { TextUnitComponent } from 'app/overview/course-lectures/text-unit/text-unit.component'; import { AttachmentUnitComponent } from 'app/overview/course-lectures/attachment-unit/attachment-unit.component'; import { OnlineUnitComponent } from 'app/overview/course-lectures/online-unit/online-unit.component'; +import { isCommunicationEnabled } from 'app/entities/course.model'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; @Component({ selector: 'jhi-learning-path-lecture-unit', standalone: true, - imports: [ArtemisLectureUnitsModule, ArtemisSharedModule, VideoUnitComponent, TextUnitComponent, AttachmentUnitComponent, OnlineUnitComponent], + imports: [ArtemisLectureUnitsModule, ArtemisSharedModule, VideoUnitComponent, TextUnitComponent, AttachmentUnitComponent, OnlineUnitComponent, DiscussionSectionComponent], templateUrl: './learning-path-lecture-unit.component.html', }) export class LearningPathLectureUnitComponent { protected readonly LectureUnitType = LectureUnitType; - private readonly lectureUnitService: LectureUnitService = inject(LectureUnitService); + private readonly lectureUnitService = inject(LectureUnitService); private readonly learningPathNavigationService = inject(LearningPathNavigationService); - private readonly alertService: AlertService = inject(AlertService); + private readonly alertService = inject(AlertService); - readonly lectureUnitId: InputSignal = input.required(); - readonly isLoading: WritableSignal = signal(false); + readonly lectureUnitId = input.required(); + readonly isLoading = signal(false); readonly lectureUnit = signal(undefined); + readonly lecture = computed(() => this.lectureUnit()?.lecture); + + readonly isCommunicationEnabled = computed(() => isCommunicationEnabled(this.lecture()?.course)); + constructor() { effect(() => this.loadLectureUnit(this.lectureUnitId()), { allowSignalWrites: true }); } @@ -46,11 +52,9 @@ export class LearningPathLectureUnitComponent { } setLearningObjectCompletion(completionEvent: LectureUnitCompletionEvent): void { - try { - this.lectureUnitService.completeLectureUnit(this.lectureUnit()!.lecture!, completionEvent); + this.lectureUnitService.completeLectureUnit(this.lectureUnit()!.lecture!, completionEvent); + if (this.lectureUnit()?.completed === completionEvent.completed) { this.learningPathNavigationService.setCurrentLearningObjectCompletion(completionEvent.completed); - } catch (error) { - this.alertService.error(error); } } } diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts index 1aa64c85f315..7d96b1de44ea 100644 --- a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component.ts @@ -38,8 +38,5 @@ export class LearningPathLectureUnitViewComponent { */ onChildActivate(instance: DiscussionSectionComponent) { this.discussionComponent = instance; // save the reference to the component instance - if (this.lecture) { - instance.lecture = this.lecture; - } } } diff --git a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts index d4c29200f2d0..090649b6990f 100644 --- a/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts +++ b/src/main/webapp/app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.module.ts @@ -19,13 +19,6 @@ const routes: Routes = [ pageTitle: 'overview.learningPath', }, canActivate: [UserRouteAccessService], - children: [ - { - path: '', - pathMatch: 'full', - loadChildren: () => import('app/overview/discussion-section/discussion-section.module').then((m) => m.DiscussionSectionModule), - }, - ], }, ]; diff --git a/src/main/webapp/app/detail-overview-list/components/boolean-detail.component.html b/src/main/webapp/app/detail-overview-list/components/boolean-detail/boolean-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/boolean-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/boolean-detail/boolean-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/boolean-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/boolean-detail/boolean-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/boolean-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/boolean-detail/boolean-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/components/date-detail.component.html b/src/main/webapp/app/detail-overview-list/components/date-detail/date-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/date-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/date-detail/date-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/date-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/date-detail/date-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/date-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/date-detail/date-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/components/link-detail.component.html b/src/main/webapp/app/detail-overview-list/components/link-detail/link-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/link-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/link-detail/link-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/link-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/link-detail/link-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/link-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/link-detail/link-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.html new file mode 100644 index 000000000000..5b7834c51b5a --- /dev/null +++ b/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.html @@ -0,0 +1,24 @@ + +
+ +
+@if (detail.data.addedLineCount > 0 || detail.data.removedLineCount > 0) { +
+ +
+} diff --git a/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.ts new file mode 100644 index 000000000000..b108a561d7c5 --- /dev/null +++ b/src/main/webapp/app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, inject } from '@angular/core'; +import type { ProgrammingDiffReportDetail } from 'app/detail-overview-list/detail.model'; +import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; +import { ButtonSize, ButtonType, TooltipPlacement } from 'app/shared/components/button.component'; +import { faCodeCompare } from '@fortawesome/free-solid-svg-icons'; +import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; +import { GitDiffReportModalComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-report-modal.component'; +import { GitDiffReportModule } from 'app/exercises/programming/hestia/git-diff-report/git-diff-report.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'jhi-programming-diff-report-detail', + templateUrl: 'programming-diff-report-detail.component.html', + standalone: true, + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, GitDiffReportModule], +}) +export class ProgrammingDiffReportDetailComponent { + protected readonly FeatureToggle = FeatureToggle; + protected readonly ButtonSize = ButtonSize; + protected readonly TooltipPlacement = TooltipPlacement; + protected readonly WARNING = ButtonType.WARNING; + + protected readonly faCodeCompare = faCodeCompare; + + private readonly modalService = inject(NgbModal); + + @Input({ required: true }) detail: ProgrammingDiffReportDetail; + + showGitDiff(gitDiff?: ProgrammingExerciseGitDiffReport) { + if (!gitDiff) { + return; + } + + const modalRef = this.modalService.open(GitDiffReportModalComponent, { windowClass: 'diff-view-modal' }); + modalRef.componentInstance.report = gitDiff; + } +} diff --git a/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html new file mode 100644 index 000000000000..366ecdb77efd --- /dev/null +++ b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html @@ -0,0 +1,38 @@ +@if (detail.data.participation) { +
+ @if (!detail.data.loading) { + + } + @if (detail.data.participation.results?.length) { + + @if (detail.data.exercise.isAtLeastEditor) { + + } + } + @if (detail.data.exercise.isAtLeastEditor && detail.data.participation.id) { + + @switch (detail.data.type) { + @case (ProgrammingExerciseParticipationType.TEMPLATE) { + + } + @case (ProgrammingExerciseParticipationType.SOLUTION) { + + } + } + + } +
+} diff --git a/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.ts new file mode 100644 index 000000000000..89a43fb9aee3 --- /dev/null +++ b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import type { ProgrammingTestStatusDetail } from 'app/detail-overview-list/detail.model'; +import { RouterModule } from '@angular/router'; +import { ArtemisProgrammingExerciseActionsModule } from 'app/exercises/programming/shared/actions/programming-exercise-actions.module'; +import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; +import { ArtemisProgrammingExerciseStatusModule } from 'app/exercises/programming/manage/status/programming-exercise-status.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ProgrammingExerciseParticipationType } from 'app/entities/programming/programming-exercise-participation.model'; + +@Component({ + selector: 'jhi-programming-test-status-detail', + templateUrl: 'programming-test-status-detail.component.html', + standalone: true, + imports: [RouterModule, ArtemisProgrammingExerciseActionsModule, SubmissionResultStatusModule, ArtemisProgrammingExerciseStatusModule, TranslateDirective], +}) +export class ProgrammingTestStatusDetailComponent { + protected readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; + + @Input({ required: true }) detail: ProgrammingTestStatusDetail; +} diff --git a/src/main/webapp/app/detail-overview-list/components/text-detail.component.html b/src/main/webapp/app/detail-overview-list/components/text-detail/text-detail.component.html similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/text-detail.component.html rename to src/main/webapp/app/detail-overview-list/components/text-detail/text-detail.component.html diff --git a/src/main/webapp/app/detail-overview-list/components/text-detail.component.ts b/src/main/webapp/app/detail-overview-list/components/text-detail/text-detail.component.ts similarity index 100% rename from src/main/webapp/app/detail-overview-list/components/text-detail.component.ts rename to src/main/webapp/app/detail-overview-list/components/text-detail/text-detail.component.ts diff --git a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html index ae8ce9674c34..54cc218a7f3b 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.html @@ -15,79 +15,6 @@

{{ section } @switch (detail.type) { - @case (DetailType.ProgrammingTestStatus) { -
- @if (detail.data.participation) { -
- @if (!detail.data.loading) { - - } - @if (detail.data.participation.results?.length) { - - @if (detail.data.exercise.isAtLeastEditor) { - - } - } - @if (detail.data.exercise.isAtLeastEditor && detail.data.participation.id) { - - @switch (detail.data.type) { - @case (ProgrammingExerciseParticipationType.TEMPLATE) { - - } - @case (ProgrammingExerciseParticipationType.SOLUTION) { - - } - } - - } -
- } -
- } - @case (DetailType.ProgrammingDiffReport) { -
- -
- -
- @if (detail.data.addedLineCount > 0 || detail.data.removedLineCount > 0) { -
- -
- } -
- } @case (DetailType.ProgrammingProblemStatement) { @if (detail.data.exercise?.templateParticipation) {
diff --git a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts index 4f49ce9fe006..60ed25bd572a 100644 --- a/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts +++ b/src/main/webapp/app/detail-overview-list/detail-overview-list.component.ts @@ -1,11 +1,7 @@ import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { faCodeCompare } from '@fortawesome/free-solid-svg-icons'; import { isEmpty } from 'lodash-es'; import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; -import { ButtonSize, ButtonType, TooltipPlacement } from 'app/shared/components/button.component'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { GitDiffReportModalComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-report-modal.component'; -import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; +import { ButtonSize, TooltipPlacement } from 'app/shared/components/button.component'; import { IrisSubSettingsType } from 'app/entities/iris/settings/iris-sub-settings.model'; import { ModelingExerciseService } from 'app/exercises/modeling/manage/modeling-exercise.service'; import { AlertService } from 'app/core/util/alert.service'; @@ -52,6 +48,7 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { protected readonly FeatureToggle = FeatureToggle; protected readonly ButtonSize = ButtonSize; protected readonly ProgrammingExerciseParticipationType = ProgrammingExerciseParticipationType; + readonly CHAT = IrisSubSettingsType.CHAT; @Input() @@ -62,16 +59,10 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { // headline record to avoid function call in html headlinesRecord: Record; - // icons - readonly faCodeCompare = faCodeCompare; - - WARNING = ButtonType.WARNING; - profileSubscription: Subscription; isLocalVC = false; constructor( - private modalService: NgbModal, private modelingExerciseService: ModelingExerciseService, private alertService: AlertService, private profileService: ProfileService, @@ -92,15 +83,6 @@ export class DetailOverviewListComponent implements OnInit, OnDestroy { }, {}); } - showGitDiff(gitDiff?: ProgrammingExerciseGitDiffReport) { - if (!gitDiff) { - return; - } - - const modalRef = this.modalService.open(GitDiffReportModalComponent, { windowClass: 'diff-view-modal' }); - modalRef.componentInstance.report = gitDiff; - } - downloadApollonDiagramAsPDf(umlModel?: UMLModel, title?: string) { if (umlModel) { this.modelingExerciseService.convertToPdf(JSON.stringify(umlModel), `${title}-example-solution`).subscribe({ diff --git a/src/main/webapp/app/detail-overview-list/detail.model.ts b/src/main/webapp/app/detail-overview-list/detail.model.ts index 77085194fb0b..4e80437fe3d2 100644 --- a/src/main/webapp/app/detail-overview-list/detail.model.ts +++ b/src/main/webapp/app/detail-overview-list/detail.model.ts @@ -97,7 +97,7 @@ export interface ProgrammingAuxiliaryRepositoryButtonsDetail extends DetailBase data: { auxiliaryRepositories: AuxiliaryRepository[]; exerciseId?: number }; } -interface ProgrammingTestStatusDetail extends DetailBase { +export interface ProgrammingTestStatusDetail extends DetailBase { type: DetailType.ProgrammingTestStatus; data: { participation?: TemplateProgrammingExerciseParticipation | SolutionProgrammingExerciseParticipation; @@ -108,7 +108,7 @@ interface ProgrammingTestStatusDetail extends DetailBase { submissionRouterLink?: (string | number | undefined)[]; }; } -interface ProgrammingDiffReportDetail extends DetailBase { +export interface ProgrammingDiffReportDetail extends DetailBase { type: DetailType.ProgrammingDiffReport; data: { addedLineCount: number; removedLineCount: number; isLoadingDiffReport?: boolean; gitDiffReport?: ProgrammingExerciseGitDiffReport }; } diff --git a/src/main/webapp/app/detail-overview-list/exercise-detail.directive.ts b/src/main/webapp/app/detail-overview-list/exercise-detail.directive.ts index 9a0cd295b12e..2bacb55c076b 100644 --- a/src/main/webapp/app/detail-overview-list/exercise-detail.directive.ts +++ b/src/main/webapp/app/detail-overview-list/exercise-detail.directive.ts @@ -1,12 +1,14 @@ import { ComponentRef, Directive, Input, OnDestroy, OnInit, Type, ViewContainerRef } from '@angular/core'; import type { Detail, ShownDetail } from 'app/detail-overview-list/detail.model'; import { DetailType } from 'app/detail-overview-list/detail-overview-list.component'; -import { TextDetailComponent } from 'app/detail-overview-list/components/text-detail.component'; -import { DateDetailComponent } from 'app/detail-overview-list/components/date-detail.component'; -import { LinkDetailComponent } from 'app/detail-overview-list/components/link-detail.component'; -import { BooleanDetailComponent } from 'app/detail-overview-list/components/boolean-detail.component'; -import { ProgrammingRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-repository-buttons-detail.component'; -import { ProgrammingAuxiliaryRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component'; +import { TextDetailComponent } from 'app/detail-overview-list/components/text-detail/text-detail.component'; +import { DateDetailComponent } from 'app/detail-overview-list/components/date-detail/date-detail.component'; +import { LinkDetailComponent } from 'app/detail-overview-list/components/link-detail/link-detail.component'; +import { BooleanDetailComponent } from 'app/detail-overview-list/components/boolean-detail/boolean-detail.component'; +import { ProgrammingRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component'; +import { ProgrammingAuxiliaryRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component'; +import { ProgrammingTestStatusDetailComponent } from 'app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component'; +import { ProgrammingDiffReportDetailComponent } from 'app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component'; @Directive({ selector: '[jhiExerciseDetail]', @@ -33,6 +35,8 @@ export class ExerciseDetailDirective implements OnInit, OnDestroy { | BooleanDetailComponent | ProgrammingRepositoryButtonsDetailComponent | ProgrammingAuxiliaryRepositoryButtonsDetailComponent + | ProgrammingTestStatusDetailComponent + | ProgrammingDiffReportDetailComponent >; } = { [DetailType.Text]: TextDetailComponent, @@ -41,6 +45,8 @@ export class ExerciseDetailDirective implements OnInit, OnDestroy { [DetailType.Boolean]: BooleanDetailComponent, [DetailType.ProgrammingRepositoryButtons]: ProgrammingRepositoryButtonsDetailComponent, [DetailType.ProgrammingAuxiliaryRepositoryButtons]: ProgrammingAuxiliaryRepositoryButtonsDetailComponent, + [DetailType.ProgrammingTestStatus]: ProgrammingTestStatusDetailComponent, + [DetailType.ProgrammingDiffReport]: ProgrammingDiffReportDetailComponent, }; const detailComponent = detailTypeToComponent[this.detail.type]; diff --git a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts index 4a4488812450..ff7875cf23c6 100644 --- a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; @@ -93,6 +93,7 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy private activatedRoute: ActivatedRoute, private router: Router, private navigationUtilService: ArtemisNavigationUtilService, + private changeDetectorRef: ChangeDetectorRef, ) {} get editType(): EditType { @@ -243,6 +244,9 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy !this.modelingExercise.releaseDate?.isValid()), }, ]; + + // otherwise the change detection does not work on the initial load + this.changeDetectorRef.detectChanges(); } /** diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html new file mode 100644 index 000000000000..4c76747e8e96 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -0,0 +1,27 @@ +
+

+ + + + + + + + + + + + @for (item of feedbackDetails; track item) { + + + + + + + + + } + +
{{ item.count }} ({{ item.relativeCount | number: '1.0-0' }}%){{ item.detailText }}{{ item.taskNumber }}{{ item.testCaseName }}Student Error
+
+
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts new file mode 100644 index 000000000000..7e1d48121f1c --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-feedback-analysis', + templateUrl: './feedback-analysis.component.html', + standalone: true, + imports: [ArtemisSharedModule], + providers: [FeedbackAnalysisService], +}) +export class FeedbackAnalysisComponent implements OnInit { + @Input() exerciseTitle: string; + @Input() exerciseId: number; + feedbackDetails: FeedbackDetail[] = []; + + constructor( + private feedbackAnalysisService: FeedbackAnalysisService, + private alertService: AlertService, + ) {} + + ngOnInit(): void { + this.loadFeedbackDetails(this.exerciseId); + } + + async loadFeedbackDetails(exerciseId: number): Promise { + try { + this.feedbackDetails = await this.feedbackAnalysisService.getFeedbackDetailsForExercise(exerciseId); + } catch (error) { + this.alertService.error(`artemisApp.programmingExercise.configureGrading.feedbackAnalysis.error`); + } + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts new file mode 100644 index 000000000000..4fa81cf289d3 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; + +export interface FeedbackDetail { + count: number; + relativeCount: number; + detailText: string; + testCaseName: string; + taskNumber: number; +} + +@Injectable() +export class FeedbackAnalysisService extends BaseApiHttpService { + private readonly EXERCISE_RESOURCE_URL = 'exercises'; + + getFeedbackDetailsForExercise(exerciseId: number): Promise { + return this.get(`${this.EXERCISE_RESOURCE_URL}/${exerciseId}/feedback-details`); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html index f637a537c0a6..147c35adea2f 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html @@ -1,3 +1,8 @@ + +
+ +
+

@@ -5,23 +10,31 @@

-
- Test Cases -
+ @if (programmingExercise.staticCodeAnalysisEnabled) { -
- Code Analysis -
+ + } + + @if (programmingExercise.isAtLeastEditor) { + } -
- Submission Policy -
- @if (activeTab !== 'submission-policy') { + @if (activeTab === 'test-cases' || activeTab === 'code-analysis') { } - @if (programmingExercise.isAtLeastInstructor) { + @if (programmingExercise.isAtLeastInstructor && activeTab !== 'feedback-analysis') {
}

+
+ @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis') { + + } +
}
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts index be99194cbd3c..c5ef675338f5 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts @@ -56,8 +56,7 @@ const DefaultFieldValues: { [key: string]: number } = { [EditableField.MAX_PENALTY]: 0, }; -export type GradingTab = 'test-cases' | 'code-analysis' | 'submission-policy'; - +export type GradingTab = 'test-cases' | 'code-analysis' | 'submission-policy' | 'feedback-analysis'; export type Table = 'testCases' | 'codeAnalysis'; @Component({ @@ -232,7 +231,8 @@ export class ProgrammingExerciseConfigureGradingComponent implements OnInit, OnD this.isLoading = false; } - if (params['tab'] === 'test-cases' || params['tab'] === 'code-analysis' || params['tab'] === 'submission-policy') { + const gradingTabs: GradingTab[] = ['test-cases', 'code-analysis', 'submission-policy', 'feedback-analysis']; + if (gradingTabs.includes(params['tab'])) { this.selectTab(params['tab']); } else { this.selectTab('test-cases'); diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts index 3f91d95f8fc2..ea2a8633af73 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-grading.module.ts @@ -19,6 +19,7 @@ import { SubmissionPolicyUpdateModule } from 'app/exercises/shared/submission-po import { ProgrammingExerciseGradingTasksTableComponent } from 'app/exercises/programming/manage/grading/tasks/programming-exercise-grading-tasks-table.component'; import { BarChartModule } from '@swimlane/ngx-charts'; import { ProgrammingExerciseTaskComponent } from 'app/exercises/programming/manage/grading/tasks/programming-exercise-task/programming-exercise-task.component'; +import { FeedbackAnalysisComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component'; @NgModule({ imports: [ @@ -33,6 +34,7 @@ import { ProgrammingExerciseTaskComponent } from 'app/exercises/programming/mana ArtemisProgrammingExerciseActionsModule, SubmissionPolicyUpdateModule, BarChartModule, + FeedbackAnalysisComponent, ], declarations: [ ProgrammingExerciseConfigureGradingComponent, diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html index a285e73eda79..7f09ade80455 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html @@ -458,10 +458,19 @@

- + } diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts index 49e6017a3f2f..0c3561afc5c9 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts @@ -39,6 +39,7 @@ import { faFont, faPencilAlt, faPlus, + faScissors, faTrash, faUndo, faUnlink, @@ -82,7 +83,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte filePreviewPaths: Map = new Map(); dropAllowed = false; showPreview = false; - + readonly CLICK_LAYER_DIMENSION: number = 200; /** Status boolean for collapse status **/ isQuestionCollapsed = false; @@ -126,6 +127,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte faAngleRight = faAngleRight; faAngleDown = faAngleDown; faUpload = faUpload; + faScissors = faScissors; readonly MAX_POINTS = MAX_QUIZ_QUESTION_POINTS; @@ -272,16 +274,21 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte setBackgroundFile(event: any): void { const fileList: FileList = event.target.files as FileList; if (fileList.length) { - if (this.question.backgroundFilePath) { - this.removeFile.emit(this.question.backgroundFilePath); - } const file = fileList[0]; - const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(file.name), this.filePool); - this.question.backgroundFilePath = fileName; - this.filePreviewPaths.set(fileName, URL.createObjectURL(file)); - this.addNewFile.emit({ fileName, file }); - this.changeDetector.detectChanges(); + this.setBackgroundFileFromFile(file); + } + } + + setBackgroundFileFromFile(file: File) { + if (this.question.backgroundFilePath) { + this.removeFile.emit(this.question.backgroundFilePath); } + + const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(file.name), this.filePool); + this.question.backgroundFilePath = fileName; + this.filePreviewPaths.set(fileName, URL.createObjectURL(file)); + this.addNewFile.emit({ fileName, file }); + this.changeDetector.detectChanges(); } /** @@ -513,11 +520,15 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte /** * Add a Picture Drag Item with the selected file as its picture to the question */ - createImageDragItem(event: any): void { + createImageDragItem(event: any): DragItem | undefined { const dragItemFile = this.getFileFromEvent(event); if (!dragItemFile) { - return; + return undefined; } + return this.createImageDragItemFromFile(dragItemFile); + } + + createImageDragItemFromFile(dragItemFile: File): DragItem { const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(dragItemFile.name), this.filePool); this.addNewFile.emit({ fileName, file: dragItemFile }); this.filePreviewPaths.set(fileName, URL.createObjectURL(dragItemFile)); @@ -531,6 +542,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte this.question.dragItems.push(dragItem); this.questionUpdated.emit(); + return dragItem; } /** @@ -869,4 +881,119 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte this.cleanupQuestion(); this.markdownEditor.parseMarkdown(); } + + /** + * Create new drag items for each drop location in the background image + */ + getImagesFromDropLocations() { + for (const someLocation of this.question.dropLocations!) { + // only crop if there is not mapping to this drop location + if (this.getMappingsForDropLocation(someLocation).length == 0) { + const image = new Image(); + let dataUrl: string = ''; + let bgWidth; + let bgHeight; + image.onload = () => { + bgHeight = image.height; + bgWidth = image.width; + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + if (context) { + // The click layer is 200x200 so it need to be rescaled to the image + const scalarHeight = bgHeight / this.CLICK_LAYER_DIMENSION; + const scalarWidth = bgWidth / this.CLICK_LAYER_DIMENSION; + canvas.width = someLocation.width! * scalarWidth; + canvas.height = someLocation.height! * scalarHeight; + context.drawImage( + image, + someLocation.posX! * scalarWidth, + someLocation.posY! * scalarHeight, + someLocation.width! * scalarWidth, + someLocation.height! * scalarHeight, + 0, + 0, + someLocation.width! * scalarWidth, + someLocation.height! * scalarHeight, + ); + + dataUrl = canvas.toDataURL('image/png'); + const dragItemCreated = this.createImageDragItemFromFile(this.dataUrlToFile(dataUrl, 'placeholder' + someLocation.posX!))!; + const dndMapping = new DragAndDropMapping(dragItemCreated, someLocation); + this.question.correctMappings!.push(dndMapping); + } + }; + image.src = this.backgroundImage.src; + } + } + this.blankOutBackgroundImage(); + } + + /** + * Takes all drop locations and replaces their location with a white rectangle on the background image + */ + blankOutBackgroundImage() { + const backgroundBlankingCanvas = document.createElement('canvas'); + const backgroundBlankingContext = backgroundBlankingCanvas.getContext('2d'); + const image = new Image(); + let bgWidth; + let bgHeight; + image.onload = () => { + bgHeight = image.height; + bgWidth = image.width; + + backgroundBlankingCanvas.width = bgWidth; + backgroundBlankingCanvas.height = bgHeight; + if (backgroundBlankingContext) { + const scalarHeight = bgHeight / this.CLICK_LAYER_DIMENSION; + const scalarWidth = bgWidth / this.CLICK_LAYER_DIMENSION; + + backgroundBlankingContext.drawImage(image, 0, 0); + backgroundBlankingContext.fillStyle = 'white'; + + for (const someLocation of this.question.dropLocations!) { + // Draw a white rectangle over the specified box location + backgroundBlankingContext.fillRect( + someLocation.posX! * scalarWidth, + someLocation.posY! * scalarHeight, + someLocation.width! * scalarWidth, + someLocation.height! * scalarHeight, + ); + } + const dataUrlCanvas = backgroundBlankingCanvas.toDataURL('image/png'); + this.setBackgroundFileFromFile(this.dataUrlToFile(dataUrlCanvas, 'background')); + } + }; + image.src = this.backgroundImage.src; + } + + /** + * Turns a data url into a blob + * @param dataUrl the data url string for which the file should be created + * @returns returns a blob created from the data url + */ + dataUrlToBlob(dataUrl: string): Blob { + // Seperate metadata from base64-encoded content + const byteString = atob(dataUrl.split(',')[1]); + // Isolate the MIME type (e.g "image/png") + const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0]; + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + return new Blob([ab], { type: mimeString }); + } + + /** + * Creates a File object from a blob given through a dataUrl + * @param dataUrl the data url string for which the file should be created + * @param fileName the name of the file to be created + * @returns returns a new file created from the data url + */ + dataUrlToFile(dataUrl: string, fileName: string): File { + const blob = this.dataUrlToBlob(dataUrl); + return new File([blob], fileName, { type: blob.type }); + } } diff --git a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html index bb0b532d5fe7..14c1ad054fb1 100644 --- a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html +++ b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.html @@ -399,7 +399,10 @@

- # + + Id + + @@ -457,7 +460,7 @@

- {{ i + 1 }} + {{ submission.id }} {{ submission.submissionDate | artemisDate }} -
-
- @for (formStatusSection of formStatusSections; track formStatusSection.title) { -
-
- +@if (formStatusSections) { +
+
+
+ @for (formStatusSection of formStatusSections; track formStatusSection.title; let index = $index) { + - {{ formStatusSection.title | artemisTranslate }} -
- } + } +
-
+} diff --git a/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.scss b/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.scss index c4049cd93601..43b8b2720cec 100644 --- a/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.scss +++ b/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.scss @@ -16,8 +16,8 @@ .form-status-circle { position: relative; z-index: 8; - height: 28px; - width: 28px; + height: 1.5rem; + width: 1.5rem; background-color: var(--bs-card-bg); cursor: pointer; } diff --git a/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.ts b/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.ts index 0e41d2f84710..ff512756dd83 100644 --- a/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.ts +++ b/src/main/webapp/app/forms/form-status-bar/form-status-bar.component.ts @@ -1,4 +1,5 @@ -import { AfterViewInit, Component, HostListener, Input } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { updateHeaderHeight } from 'app/shared/util/navbar.util'; export type FormSectionStatus = { title: string; @@ -15,23 +16,28 @@ export class FormStatusBarComponent implements AfterViewInit { @Input() formStatusSections: FormSectionStatus[]; + @ViewChild('statusBar', { static: false }) statusBar?: ElementRef; + @HostListener('window:resize') - onResize() { - setTimeout(() => { - const headerHeight = (document.querySelector('jhi-navbar') as HTMLElement).offsetHeight; - document.documentElement.style.setProperty('--header-height', `${headerHeight}px`); - }); + onResizeAddDistanceFromStatusBarToNavbar() { + updateHeaderHeight(); } ngAfterViewInit() { - this.onResize(); + this.onResizeAddDistanceFromStatusBarToNavbar(); } scrollToHeadline(id: string) { const element = document.getElementById(id); if (element) { - element.style.scrollMarginTop = 'calc(2rem + 78px)'; - element.scrollIntoView(); + const navbarHeight = (document.querySelector('jhi-navbar') as HTMLElement)?.getBoundingClientRect().height; + const statusBarHeight = this.statusBar?.nativeElement.getBoundingClientRect().height; + + /** Needs to be applied to the scrollMarginTop to ensure that the scroll to element is not hidden behind header elements */ + const scrollOffsetInPx = navbarHeight + statusBarHeight; + + element.style.scrollMarginTop = `${scrollOffsetInPx}px`; + element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' }); } } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 52cf58d80242..9979347728ec 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -42,7 +42,7 @@ }
@if (activeConversation) { - + { + this.sidebarConversations = this.courseOverviewService.mapConversationsToSidebarCardElements(this.conversationsOfUser); + this.accordionConversationGroups = this.courseOverviewService.groupConversationsByChannelType(this.conversationsOfUser, this.messagingEnabled); + this.updateSidebarData(); + }, + }); } updateSidebarData() { @@ -308,9 +312,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { .subscribe((chatPartners: UserPublicInfoDTO[]) => { this.metisConversationService.createGroupChat(chatPartners?.map((partner) => partner.login!)).subscribe({ complete: () => { - this.metisConversationService.forceRefresh().subscribe({ - complete: () => {}, - }); this.prepareSidebarData(); }, }); @@ -330,9 +331,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { if (chatPartner?.login) { this.metisConversationService.createOneToOneChat(chatPartner.login).subscribe({ complete: () => { - this.metisConversationService.forceRefresh().subscribe({ - complete: () => {}, - }); this.prepareSidebarData(); }, }); diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-header/conversation-header.component.ts b/src/main/webapp/app/overview/course-conversations/layout/conversation-header/conversation-header.component.ts index 15b5744fc734..2bf7adf1ebae 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-header/conversation-header.component.ts +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-header/conversation-header.component.ts @@ -27,6 +27,7 @@ export class ConversationHeaderComponent implements OnInit, OnDestroy { private ngUnsubscribe = new Subject(); @Output() collapseSearch = new EventEmitter(); + @Output() onUpdateSidebar = new EventEmitter(); INFO = ConversationDetailTabs.INFO; MEMBERS = ConversationDetailTabs.MEMBERS; @@ -107,6 +108,7 @@ export class ConversationHeaderComponent implements OnInit, OnDestroy { this.metisConversationService.forceRefresh().subscribe({ complete: () => {}, }); + this.onUpdateSidebar.emit(); }); } diff --git a/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.html b/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.html index 3e27cd79cf9b..2bec1658d7e9 100644 --- a/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.html +++ b/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.html @@ -142,7 +142,7 @@
@if (lecture && (isCommunicationEnabled(lecture.course) || isMessagingEnabled(lecture.course))) { - + }
diff --git a/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.ts b/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.ts index e1ab434077ff..a87074793132 100644 --- a/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.ts +++ b/src/main/webapp/app/overview/course-lectures/course-lecture-details.component.ts @@ -9,7 +9,6 @@ import { Attachment } from 'app/entities/attachment.model'; import { LectureService } from 'app/lecture/lecture.service'; import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; -import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; import { onError } from 'app/shared/util/global.utils'; import { finalize, tap } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -39,7 +38,6 @@ export class CourseLectureDetailsComponent extends AbstractScienceComponent impl lecture?: Lecture; isDownloadingLink?: string; lectureUnits: LectureUnit[] = []; - discussionComponent?: DiscussionSectionComponent; hasPdfLectureUnit: boolean; paramsSubscription: Subscription; @@ -107,10 +105,6 @@ export class CourseLectureDetailsComponent extends AbstractScienceComponent impl (unit) => unit.attachment?.link?.split('.').pop()!.toLocaleLowerCase() === 'pdf', ).length > 0; } - if (this.discussionComponent) { - // We need to manually update the lecture property of the student questions component - this.discussionComponent.lecture = this.lecture; - } this.endsSameDay = !!this.lecture?.startDate && !!this.lecture.endDate && dayjs(this.lecture.startDate).isSame(this.lecture.endDate, 'day'); }, error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), @@ -160,18 +154,6 @@ export class CourseLectureDetailsComponent extends AbstractScienceComponent impl this.lectureUnitService.completeLectureUnit(this.lecture!, event); } - /** - * This function gets called if the router outlet gets activated. This is - * used only for the DiscussionComponent - * @param instance The component instance - */ - onChildActivate(instance: DiscussionSectionComponent) { - this.discussionComponent = instance; // save the reference to the component instance - if (this.lecture) { - instance.lecture = this.lecture; - } - } - ngOnDestroy() { this.paramsSubscription?.unsubscribe(); this.profileSubscription?.unsubscribe(); diff --git a/src/main/webapp/app/overview/course-lectures/course-lecture-details.module.ts b/src/main/webapp/app/overview/course-lectures/course-lecture-details.module.ts index 8885f4eba764..315de44054b0 100644 --- a/src/main/webapp/app/overview/course-lectures/course-lecture-details.module.ts +++ b/src/main/webapp/app/overview/course-lectures/course-lecture-details.module.ts @@ -13,6 +13,7 @@ import { VideoUnitComponent } from 'app/overview/course-lectures/video-unit/vide import { TextUnitComponent } from 'app/overview/course-lectures/text-unit/text-unit.component'; import { OnlineUnitComponent } from 'app/overview/course-lectures/online-unit/online-unit.component'; import { AttachmentUnitComponent } from 'app/overview/course-lectures/attachment-unit/attachment-unit.component'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; const routes: Routes = [ { @@ -23,13 +24,6 @@ const routes: Routes = [ pageTitle: 'overview.lectures', }, canActivate: [UserRouteAccessService], - children: [ - { - path: '', - pathMatch: 'full', - loadChildren: () => import('../discussion-section/discussion-section.module').then((m) => m.DiscussionSectionModule), - }, - ], }, ]; @NgModule({ @@ -45,6 +39,7 @@ const routes: Routes = [ TextUnitComponent, OnlineUnitComponent, AttachmentUnitComponent, + DiscussionSectionComponent, ], declarations: [CourseLectureDetailsComponent], exports: [CourseLectureDetailsComponent], diff --git a/src/main/webapp/app/overview/discussion-section/discussion-section.component.ts b/src/main/webapp/app/overview/discussion-section/discussion-section.component.ts index fae15df461dd..63d5873cc3b1 100644 --- a/src/main/webapp/app/overview/discussion-section/discussion-section.component.ts +++ b/src/main/webapp/app/overview/discussion-section/discussion-section.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, OnDestroy, QueryList, ViewChild, ViewChildren, effect, input } from '@angular/core'; import interact from 'interactjs'; import { Exercise } from 'app/entities/exercise.model'; import { Lecture } from 'app/entities/lecture.model'; @@ -14,17 +14,22 @@ import { CourseDiscussionDirective } from 'app/shared/metis/course-discussion.di import { FormBuilder } from '@angular/forms'; import { Channel, ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { ChannelService } from 'app/shared/metis/conversations/channel.service'; -import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisPlagiarismCasesSharedModule } from 'app/course/plagiarism-cases/shared/plagiarism-cases-shared.module'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; @Component({ selector: 'jhi-discussion-section', templateUrl: './discussion-section.component.html', styleUrls: ['./discussion-section.component.scss'], + imports: [FontAwesomeModule, ArtemisSharedModule, ArtemisPlagiarismCasesSharedModule, InfiniteScrollModule], + standalone: true, providers: [MetisService], }) -export class DiscussionSectionComponent extends CourseDiscussionDirective implements OnInit, AfterViewInit, OnDestroy { - @Input() exercise?: Exercise; - @Input() lecture?: Lecture; +export class DiscussionSectionComponent extends CourseDiscussionDirective implements AfterViewInit, OnDestroy { + exercise = input(); + lecture = input(); @ViewChild(PostCreateEditModalComponent) postCreateEditModal?: PostCreateEditModalComponent; @ViewChildren('postingThread') messages: QueryList; @@ -60,22 +65,18 @@ export class DiscussionSectionComponent extends CourseDiscussionDirective implem private activatedRoute: ActivatedRoute, private router: Router, private formBuilder: FormBuilder, - private metisConversationService: MetisConversationService, ) { super(metisService); + effect(() => this.loadData(this.exercise(), this.lecture())); } - /** - * on initialization: initializes the metis service, fetches the posts for the exercise or lecture the discussion section is placed at, - * creates the subscription to posts to stay updated on any changes of posts in this course - */ - ngOnInit(): void { + loadData(exercise?: Exercise, lecture?: Lecture): void { this.paramSubscription = combineLatest({ params: this.activatedRoute.params, queryParams: this.activatedRoute.queryParams, }).subscribe((routeParams: { params: Params; queryParams: Params }) => { this.currentPostId = +routeParams.queryParams.postId; - this.course = this.exercise?.course ?? this.lecture?.course; + this.course = exercise?.course ?? lecture?.course; this.metisService.setCourse(this.course); this.metisService.setPageType(this.PAGE_TYPE); if (routeParams.params.courseId) { @@ -146,14 +147,14 @@ export class DiscussionSectionComponent extends CourseDiscussionDirective implem // Currently, an additional REST call is made to retrieve the channel associated with the lecture/exercise // TODO: Add the channel to the response for loading the lecture/exercise - if (this.lecture?.id) { + if (this.lecture()) { this.channelService - .getChannelOfLecture(courseId, this.lecture.id) + .getChannelOfLecture(courseId, this.lecture()!.id!) .pipe(map((res: HttpResponse) => res.body)) .subscribe(getChannel()); - } else if (this.exercise?.id) { + } else if (this.exercise()) { this.channelService - .getChannelOfExercise(courseId, this.exercise.id) + .getChannelOfExercise(courseId, this.exercise()!.id!) .pipe(map((res: HttpResponse) => res.body)) .subscribe(getChannel()); } @@ -271,8 +272,8 @@ export class DiscussionSectionComponent extends CourseDiscussionDirective implem resetFormGroup(): void { this.formGroup = this.formBuilder.group({ conversationId: this.channel?.id, - exerciseId: this.exercise?.id, - lectureId: this.lecture?.id, + exerciseId: this.exercise()?.id, + lectureId: this.lecture()?.id, filterToUnresolved: false, filterToOwn: false, filterToAnsweredOrReacted: false, diff --git a/src/main/webapp/app/overview/discussion-section/discussion-section.module.ts b/src/main/webapp/app/overview/discussion-section/discussion-section.module.ts deleted file mode 100644 index 125a60831748..000000000000 --- a/src/main/webapp/app/overview/discussion-section/discussion-section.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ArtemisSharedModule } from 'app/shared/shared.module'; -import { ArtemisSidePanelModule } from 'app/shared/side-panel/side-panel.module'; -import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; -import { RouterModule, Routes } from '@angular/router'; -import { MetisModule } from 'app/shared/metis/metis.module'; -import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; -import { InfiniteScrollModule } from 'ngx-infinite-scroll'; - -const routes: Routes = [ - { - path: '', - pathMatch: 'full', - component: DiscussionSectionComponent, - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes), MetisModule, ArtemisSharedModule, ArtemisSidePanelModule, ArtemisSharedComponentModule, InfiniteScrollModule], - declarations: [DiscussionSectionComponent], - exports: [DiscussionSectionComponent], -}) -export class DiscussionSectionModule {} diff --git a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.html b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.html index 0d8f889d1ed5..238401e7d4e3 100644 --- a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.html +++ b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.html @@ -255,7 +255,7 @@

@if (exercise.course && (isCommunicationEnabled(exercise.course) || isMessagingEnabled(exercise.course))) { - + }
diff --git a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts index d0b7264723b6..fc623b457d5d 100644 --- a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts +++ b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts @@ -23,7 +23,6 @@ import { TeamAssignmentPayload } from 'app/entities/team.model'; import { TeamService } from 'app/exercises/shared/team/team.service'; import { QuizExercise, QuizStatus } from 'app/entities/quiz/quiz-exercise.model'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; -import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; import { getFirstResultWithComplaintFromResults } from 'app/entities/submission.model'; import { ComplaintService } from 'app/complaints/complaint.service'; @@ -86,7 +85,6 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp isAfterAssessmentDueDate: boolean; allowComplaintsForAutomaticAssessments: boolean; public gradingCriteria: GradingCriterion[]; - private discussionComponent?: DiscussionSectionComponent; baseResource: string; isExamExercise: boolean; submissionPolicy?: SubmissionPolicy; @@ -224,10 +222,6 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp this.subscribeForNewResults(); this.subscribeToTeamAssignmentUpdates(); - if (this.discussionComponent && this.exercise) { - // We need to manually update the exercise property of the posts component - this.discussionComponent.exercise = this.exercise; - } this.baseResource = `/course-management/${this.courseId}/${this.exercise.type}-exercises/${this.exercise.id}/`; } @@ -416,18 +410,6 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp return undefined; } - /** - * This function gets called if the router outlet gets activated. This is - * used only for the DiscussionComponent - * @param instance The component instance - */ - onChildActivate(instance: DiscussionSectionComponent) { - this.discussionComponent = instance; // save the reference to the component instance - if (this.exercise) { - instance.exercise = this.exercise; - } - } - private onError(error: string) { this.alertService.error(error); } diff --git a/src/main/webapp/app/overview/exercise-details/course-exercise-details.module.ts b/src/main/webapp/app/overview/exercise-details/course-exercise-details.module.ts index 0b264b8006a0..63f0858ea8b8 100644 --- a/src/main/webapp/app/overview/exercise-details/course-exercise-details.module.ts +++ b/src/main/webapp/app/overview/exercise-details/course-exercise-details.module.ts @@ -30,6 +30,7 @@ import { ProblemStatementComponent } from 'app/overview/exercise-details/problem import { ArtemisFeedbackModule } from 'app/exercises/shared/feedback/feedback.module'; import { ArtemisExerciseInfoModule } from 'app/exercises/shared/exercise-info/exercise-info.module'; import { IrisModule } from 'app/iris/iris.module'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; const routes: Routes = [ { @@ -41,13 +42,6 @@ const routes: Routes = [ }, pathMatch: 'full', canActivate: [UserRouteAccessService], - children: [ - { - path: '', - pathMatch: 'full', - loadChildren: () => import('../discussion-section/discussion-section.module').then((m) => m.DiscussionSectionModule), - }, - ], }, ]; @@ -76,6 +70,7 @@ const routes: Routes = [ ArtemisFeedbackModule, ArtemisExerciseInfoModule, IrisModule, + DiscussionSectionComponent, ], declarations: [CourseExerciseDetailsComponent, OrionCourseExerciseDetailsComponent, LtiInitializerComponent, LtiInitializerModalComponent, ProblemStatementComponent], exports: [CourseExerciseDetailsComponent, OrionCourseExerciseDetailsComponent, ProblemStatementComponent], diff --git a/src/main/webapp/app/shared/components/checklist-check.component.html b/src/main/webapp/app/shared/components/checklist-check.component.html deleted file mode 100644 index 3dacf1868569..000000000000 --- a/src/main/webapp/app/shared/components/checklist-check.component.html +++ /dev/null @@ -1,7 +0,0 @@ -@if (checkAttribute) { - -} -@if (!checkAttribute) { - -} -  diff --git a/src/main/webapp/app/shared/components/checklist-check.component.scss b/src/main/webapp/app/shared/components/checklist-check.component.scss deleted file mode 100644 index c9e2a2e6dbd7..000000000000 --- a/src/main/webapp/app/shared/components/checklist-check.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.checked { - color: var(--green); -} - -.unchecked { - color: var(--markdown-preview-red); -} diff --git a/src/main/webapp/app/shared/components/checklist-check/checklist-check.component.html b/src/main/webapp/app/shared/components/checklist-check/checklist-check.component.html new file mode 100644 index 000000000000..cae4278c4ea8 --- /dev/null +++ b/src/main/webapp/app/shared/components/checklist-check/checklist-check.component.html @@ -0,0 +1,6 @@ +@if (checkAttribute) { + +} @else { + +} +  diff --git a/src/main/webapp/app/shared/components/checklist-check.component.ts b/src/main/webapp/app/shared/components/checklist-check/checklist-check.component.ts similarity index 78% rename from src/main/webapp/app/shared/components/checklist-check.component.ts rename to src/main/webapp/app/shared/components/checklist-check/checklist-check.component.ts index ce4e07ac614b..3718e0f73adf 100644 --- a/src/main/webapp/app/shared/components/checklist-check.component.ts +++ b/src/main/webapp/app/shared/components/checklist-check/checklist-check.component.ts @@ -5,14 +5,12 @@ import { faCheckCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'jhi-checklist-check', templateUrl: './checklist-check.component.html', - styleUrls: ['./checklist-check.component.scss'], }) export class ChecklistCheckComponent { + protected readonly faTimes = faTimes; + protected readonly faCheckCircle = faCheckCircle; + @Input() checkAttribute: boolean | undefined = false; @Input() iconColor?: string; @Input() size?: SizeProp; - - // Icons - faTimes = faTimes; - faCheckCircle = faCheckCircle; } diff --git a/src/main/webapp/app/shared/components/shared-component.module.ts b/src/main/webapp/app/shared/components/shared-component.module.ts index a12237b224e7..090ebcf99f3d 100644 --- a/src/main/webapp/app/shared/components/shared-component.module.ts +++ b/src/main/webapp/app/shared/components/shared-component.module.ts @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core'; -import { ChecklistCheckComponent } from 'app/shared/components/checklist-check.component'; +import { ChecklistCheckComponent } from 'app/shared/components/checklist-check/checklist-check.component'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { FeatureToggleModule } from 'app/shared/feature-toggle/feature-toggle.module'; import { ConfirmAutofocusModalComponent } from 'app/shared/components/confirm-autofocus-modal.component'; diff --git a/src/main/webapp/app/shared/detail-overview-navigation-bar/detail-overview-navigation-bar.component.ts b/src/main/webapp/app/shared/detail-overview-navigation-bar/detail-overview-navigation-bar.component.ts index 78d79942cada..e9eb25918015 100644 --- a/src/main/webapp/app/shared/detail-overview-navigation-bar/detail-overview-navigation-bar.component.ts +++ b/src/main/webapp/app/shared/detail-overview-navigation-bar/detail-overview-navigation-bar.component.ts @@ -1,4 +1,5 @@ import { AfterViewInit, Component, HostListener, Input } from '@angular/core'; +import { updateHeaderHeight } from 'app/shared/util/navbar.util'; @Component({ selector: 'jhi-detail-overview-navigation-bar', @@ -10,15 +11,12 @@ export class DetailOverviewNavigationBarComponent implements AfterViewInit { sectionHeadlines: { id: string; translationKey: string }[]; @HostListener('window:resize') - onResize() { - setTimeout(() => { - const headerHeight = (document.querySelector('jhi-navbar') as HTMLElement).offsetHeight; - document.documentElement.style.setProperty('--header-height', `${headerHeight - 2}px`); - }); + onResizeAddDistanceFromStatusBarToNavbar() { + updateHeaderHeight(); } ngAfterViewInit() { - this.onResize(); + this.onResizeAddDistanceFromStatusBarToNavbar(); } scrollToView(id: string) { diff --git a/src/main/webapp/app/shared/language/translate.directive.ts b/src/main/webapp/app/shared/language/translate.directive.ts index 8a703341ed63..cb40182df64f 100644 --- a/src/main/webapp/app/shared/language/translate.directive.ts +++ b/src/main/webapp/app/shared/language/translate.directive.ts @@ -9,6 +9,7 @@ import { takeUntil } from 'rxjs/operators'; */ @Directive({ selector: '[jhiTranslate]', + standalone: true, }) export class TranslateDirective implements OnChanges, OnInit, OnDestroy { @Input() jhiTranslate!: string; diff --git a/src/main/webapp/app/shared/shared-common.module.ts b/src/main/webapp/app/shared/shared-common.module.ts index 67381071e8cd..b14b2d663504 100644 --- a/src/main/webapp/app/shared/shared-common.module.ts +++ b/src/main/webapp/app/shared/shared-common.module.ts @@ -15,13 +15,12 @@ import { CloseCircleComponent } from 'app/shared/close-circle/close-circle.compo import { ArtemisDateRangePipe } from 'app/shared/pipes/artemis-date-range.pipe'; @NgModule({ - imports: [ArtemisSharedLibsModule], + imports: [ArtemisSharedLibsModule, TranslateDirective], declarations: [ ArtemisDatePipe, ArtemisDateRangePipe, FindLanguageFromKeyPipe, AlertOverlayComponent, - TranslateDirective, SortByDirective, SortDirective, ArtemisTranslatePipe, diff --git a/src/main/webapp/app/shared/util/navbar.util.ts b/src/main/webapp/app/shared/util/navbar.util.ts new file mode 100644 index 000000000000..7d5a0067c4a8 --- /dev/null +++ b/src/main/webapp/app/shared/util/navbar.util.ts @@ -0,0 +1,16 @@ +/** + * Update the header height SCSS variable based on the navbar height. + * + * The navbar height can change based on the screen size and the content of the navbar + * (e.g. long breadcrumbs due to longs exercise names) + */ +export function updateHeaderHeight() { + setTimeout(() => { + const navbar = document.querySelector('jhi-navbar'); + if (navbar) { + // do not use navbar.offsetHeight, this might not be defined in Safari! + const headerHeight = navbar.getBoundingClientRect().height; + document.documentElement.style.setProperty('--header-height', `${headerHeight}px`); + } + }); +} diff --git a/src/main/webapp/i18n/de/dragAndDropQuestion.json b/src/main/webapp/i18n/de/dragAndDropQuestion.json index 78fd8dd3f720..a2e7c947fb34 100644 --- a/src/main/webapp/i18n/de/dragAndDropQuestion.json +++ b/src/main/webapp/i18n/de/dragAndDropQuestion.json @@ -30,6 +30,8 @@ "addMappingsInstructions": "Ziehe Drag Items auf Drop Locations, um korrekte Zuordnungen zu definieren.", "addDragItemPicture": "Drag Item mit Bild Hinzufügen", "addDragItemText": "Drag Item mit Text Hinzufügen", + "cutImagesFromDropLocation": "Bilder aus Drop Locations ausschneiden", + "cutImagesFromDropLocationTip": "Das Hintergrundbild wird verändert. Dies kann nicht rückgängig gemacht werden.", "studentInstructions": "Drag & Drop: Platziere die passenden Elemente auf die zugehörigen Flächen.", "showingYourAnswer": "Deine Abgabe:", "showingSampleSolution": "Musterlösung:", diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 9409c371c61a..4e493f7d4daf 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -273,7 +273,8 @@ "settingNegative": "Der Testfall {{testCase}} darf keine Einstellungen mit negativen Werten haben." }, "categories": { - "title": "Code-Analyse-Kategorien", + "titleHeader": "Quelltext-Analyse", + "title": "Quelltext-Analyse-Kategorien", "notGraded": "Nicht bewertet.", "noFeedback": "Ohne sichtbares Feedback.", "updated": "Die Kategorien wurden erfolgreich gespeichert.", @@ -319,6 +320,17 @@ "testType": "Type", "passedPercent": "Bestanden %" }, + "feedbackAnalysis": { + "titleHeader": "Feedback Analyse", + "title": "Fehleranalyse für {{exerciseTitle}}", + "occurrence": "Häufigkeit", + "feedback": "Feedback", + "task": "Aufgabe", + "testcase": "Testfall", + "errorCategory": "Fehlerkategorie", + "totalItems": "Insgesamt {{count}} Elemente", + "error": "Beim Laden des Feedback ist ein Fehler aufgetreten." + }, "help": { "name": "Aufgabennamen werden fett geschrieben, während Testnamen normal sind. Ob es ein Aufgabenname oder Testname ist hängt davon ab, ob die Reihe eine Aufgabe oder einen Test darstellt.", "state": "Gibt an, ob Issues in dieser Kategorie den Studierenden angezeigt und bewertet werden sollen.", diff --git a/src/main/webapp/i18n/en/dragAndDropQuestion.json b/src/main/webapp/i18n/en/dragAndDropQuestion.json index 6e7131fc734f..533cbcad634b 100644 --- a/src/main/webapp/i18n/en/dragAndDropQuestion.json +++ b/src/main/webapp/i18n/en/dragAndDropQuestion.json @@ -30,6 +30,8 @@ "addMappingsInstructions": "Drag items onto drop locations to define correct solutions.", "addDragItemPicture": "Add Picture Drag Item", "addDragItemText": "Add Text Drag Item", + "cutImagesFromDropLocation": "Cut Images from Drop Locations", + "cutImagesFromDropLocationTip": "The background image will be modified. This cannot be undone.", "studentInstructions": "Drag & Drop: Place the suitable items on the correct areas.", "showingYourAnswer": "Your Submission:", "showingSampleSolution": "Sample Solution:", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 06f826607ba7..1883d8294abc 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -275,6 +275,7 @@ "settingNegative": "Test case {{testCase}} must not have settings set to negative values." }, "categories": { + "titleHeader": "Code Analysis", "title": "Code Analysis Categories", "notGraded": "Not graded.", "noFeedback": "No visible feedback.", @@ -321,6 +322,17 @@ "testType": "Type", "passedPercent": "Passed %" }, + "feedbackAnalysis": { + "titleHeader": "Feedback Analysis", + "title": "Feedback Analysis for {{exerciseTitle}}", + "occurrence": "Occurrence", + "feedback": "Feedback", + "task": "Task", + "testcase": "Test Case", + "errorCategory": "Error Category", + "totalItems": "In total {{count}} items", + "error": "An error occurred while loading the feedback." + }, "help": { "name": "Task names are written in bold whereas Test names are normal. Task or test name depending on whether the row is a task or test.", "state": "Determines whether issues in this category should be shown to the students and used for grading.", 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 56e3d0339bda..2c8e7c82b389 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 @@ -33,6 +33,7 @@ import de.tum.in.www1.artemis.domain.GradingCriterion; import de.tum.in.www1.artemis.domain.GradingInstruction; import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.ProgrammingExerciseTestCase; import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.Result; import de.tum.in.www1.artemis.domain.Submission; @@ -72,6 +73,7 @@ import de.tum.in.www1.artemis.repository.SubmissionRepository; import de.tum.in.www1.artemis.repository.TextExerciseRepository; import de.tum.in.www1.artemis.web.rest.dto.ResultWithPointsPerGradingCriterionDTO; +import de.tum.in.www1.artemis.web.rest.dto.feedback.FeedbackDetailDTO; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; class ResultServiceIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { @@ -722,4 +724,91 @@ void testGetAssessmentCountByCorrectionRoundForProgrammingExercise() { assertThat(assessments[0].inTime()).isEqualTo(1); // correction round 1 assertThat(assessments[1].inTime()).isEqualTo(1); // correction round 2 } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetAllFeedbackDetailsForExercise() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback = new Feedback(); + feedback.setPositive(false); + feedback.setDetailText("Some feedback"); + feedback.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback, result); + + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).isNotEmpty(); + FeedbackDetailDTO feedbackDetail = response.getFirst(); + assertThat(feedbackDetail.count()).isEqualTo(1); + assertThat(feedbackDetail.relativeCount()).isEqualTo(100.0); + assertThat(feedbackDetail.detailText()).isEqualTo("Some feedback"); + assertThat(feedbackDetail.testCaseName()).isEqualTo("test1"); + assertThat(feedbackDetail.taskNumber()).isEqualTo(1); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetAllFeedbackDetailsForExerciseWithMultipleFeedback() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + StudentParticipation participation = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + StudentParticipation participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); + Result result = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation); + Result result2 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation2); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback1 = new Feedback(); + feedback1.setPositive(false); + feedback1.setDetailText("Some feedback"); + feedback1.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback1, result); + + Feedback feedback2 = new Feedback(); + feedback2.setPositive(false); + feedback2.setDetailText("Some feedback"); + feedback2.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback2, result2); + + Feedback feedback3 = new Feedback(); + feedback3.setPositive(false); + feedback3.setDetailText("Some different feedback"); + feedback3.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback3, result); + + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).hasSize(2); + + FeedbackDetailDTO firstFeedbackDetail = response.stream().filter(feedbackDetail -> "Some feedback".equals(feedbackDetail.detailText())).findFirst().orElseThrow(); + + FeedbackDetailDTO secondFeedbackDetail = response.stream().filter(feedbackDetail -> "Some different feedback".equals(feedbackDetail.detailText())).findFirst() + .orElseThrow(); + + assertThat(firstFeedbackDetail.count()).isEqualTo(2); + assertThat(firstFeedbackDetail.relativeCount()).isEqualTo(100.0); + assertThat(firstFeedbackDetail.detailText()).isEqualTo("Some feedback"); + assertThat(firstFeedbackDetail.testCaseName()).isEqualTo("test1"); + assertThat(firstFeedbackDetail.taskNumber()).isEqualTo(1); + + assertThat(secondFeedbackDetail.count()).isEqualTo(1); + assertThat(secondFeedbackDetail.relativeCount()).isEqualTo(50.0); + assertThat(secondFeedbackDetail.detailText()).isEqualTo("Some different feedback"); + assertThat(secondFeedbackDetail.testCaseName()).isEqualTo("test1"); + assertThat(secondFeedbackDetail.taskNumber()).isEqualTo(1); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetAllFeedbackDetailsForExercise_NoParticipation() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + List response = request.getList("/api/exercises/" + programmingExercise.getId() + "/feedback-details", HttpStatus.OK, FeedbackDetailDTO.class); + + assertThat(response).isEmpty(); + } + } diff --git a/src/test/javascript/spec/component/detail-overview-list.component.spec.ts b/src/test/javascript/spec/component/detail-overview-list.component.spec.ts index cf32c04afa13..61e891b11e35 100644 --- a/src/test/javascript/spec/component/detail-overview-list.component.spec.ts +++ b/src/test/javascript/spec/component/detail-overview-list.component.spec.ts @@ -1,9 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DetailOverviewListComponent, DetailOverviewSection, DetailType } from 'app/detail-overview-list/detail-overview-list.component'; -import { TranslatePipeMock } from '../helpers/mocks/service/mock-translate.service'; -import { MockNgbModalService } from '../helpers/mocks/service/mock-ngb-modal.service'; -import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; import { ModelingExerciseService } from 'app/exercises/modeling/manage/modeling-exercise.service'; import { AlertService } from 'app/core/util/alert.service'; import { MockAlertService } from '../helpers/mocks/service/mock-alert.service'; @@ -16,6 +12,7 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; import { MockRouter } from '../helpers/mocks/mock-router'; import { ExerciseDetailDirective } from 'app/detail-overview-list/exercise-detail.directive'; +import { TranslatePipeMock } from '../helpers/mocks/service/mock-translate.service'; const sections: DetailOverviewSection[] = [ { @@ -34,7 +31,6 @@ const sections: DetailOverviewSection[] = [ describe('DetailOverviewList', () => { let component: DetailOverviewListComponent; let fixture: ComponentFixture; - let modalService: NgbModal; let modelingService: ModelingExerciseService; let alertService: AlertService; @@ -43,7 +39,6 @@ describe('DetailOverviewList', () => { imports: [ExerciseDetailDirective], declarations: [DetailOverviewListComponent, TranslatePipeMock], providers: [ - { provide: NgbModal, useClass: MockNgbModalService }, { provide: AlertService, useClass: MockAlertService }, { provide: Router, useClass: MockRouter }, { provide: ProfileService, useClass: MockProfileService }, @@ -52,7 +47,6 @@ describe('DetailOverviewList', () => { }) .compileComponents() .then(() => { - modalService = fixture.debugElement.injector.get(NgbModal); modelingService = fixture.debugElement.injector.get(ModelingExerciseService); alertService = fixture.debugElement.injector.get(AlertService); }); @@ -99,18 +93,6 @@ describe('DetailOverviewList', () => { expect(titleDetailValue.textContent).toContain('A Title'); }); - it('should open git diff modal', () => { - const modalSpy = jest.spyOn(modalService, 'open'); - component.showGitDiff({} as unknown as ProgrammingExerciseGitDiffReport); - expect(modalSpy).toHaveBeenCalledOnce(); - }); - - it('should not open git diff modal', () => { - const modalSpy = jest.spyOn(modalService, 'open'); - component.showGitDiff(undefined); - expect(modalSpy).not.toHaveBeenCalled(); - }); - it('should download apollon Diagram', () => { const downloadSpy = jest.spyOn(modelingService, 'convertToPdf').mockReturnValue(of(new HttpResponse({ body: new Blob() }))); component.downloadApollonDiagramAsPDf({} as UMLModel, 'title'); diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist-exercisegroup-table.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist-exercisegroup-table.component.spec.ts index 9c934e9c8817..da54d7079565 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist-exercisegroup-table.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist-exercisegroup-table.component.spec.ts @@ -4,7 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { Component } from '@angular/core'; import { HasAnyAuthorityDirective } from 'app/shared/auth/has-any-authority.directive'; -import { ChecklistCheckComponent } from 'app/shared/components/checklist-check.component'; +import { ChecklistCheckComponent } from 'app/shared/components/checklist-check/checklist-check.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ProgressBarComponent } from 'app/shared/dashboards/tutor-participation-graph/progress-bar/progress-bar.component'; diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts index 9d29bcdc9a23..4abd8b00e497 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts @@ -9,7 +9,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { AccountService } from 'app/core/auth/account.service'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam/exam.model'; -import { ChecklistCheckComponent } from 'app/shared/components/checklist-check.component'; +import { ChecklistCheckComponent } from 'app/shared/components/checklist-check/checklist-check.component'; import { ExamChecklistExerciseGroupTableComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist-exercisegroup-table/exam-checklist-exercisegroup-table.component'; import { ExamChecklistComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist.component'; import { ExamDetailComponent } from 'app/exam/manage/exams/exam-detail.component'; diff --git a/src/test/javascript/spec/component/exercise-detail.directive.spec.ts b/src/test/javascript/spec/component/exercise-detail.directive.spec.ts index da435d3b9a84..50ed496b1b34 100644 --- a/src/test/javascript/spec/component/exercise-detail.directive.spec.ts +++ b/src/test/javascript/spec/component/exercise-detail.directive.spec.ts @@ -8,18 +8,23 @@ import type { LinkDetail, NotShownDetail, ProgrammingAuxiliaryRepositoryButtonsDetail, + ProgrammingDiffReportDetail, ProgrammingRepositoryButtonsDetail, + ProgrammingTestStatusDetail, ShownDetail, TextDetail, } from 'app/detail-overview-list/detail.model'; -import { TextDetailComponent } from 'app/detail-overview-list/components/text-detail.component'; -import { MockComponent } from 'ng-mocks'; +import { TextDetailComponent } from 'app/detail-overview-list/components/text-detail/text-detail.component'; +import { MockComponent, MockDirective } from 'ng-mocks'; import { DetailType } from 'app/detail-overview-list/detail-overview-list.component'; -import { DateDetailComponent } from 'app/detail-overview-list/components/date-detail.component'; -import { LinkDetailComponent } from 'app/detail-overview-list/components/link-detail.component'; -import { BooleanDetailComponent } from 'app/detail-overview-list/components/boolean-detail.component'; -import { ProgrammingRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-repository-buttons-detail.component'; -import { ProgrammingAuxiliaryRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail.component'; +import { DateDetailComponent } from 'app/detail-overview-list/components/date-detail/date-detail.component'; +import { LinkDetailComponent } from 'app/detail-overview-list/components/link-detail/link-detail.component'; +import { BooleanDetailComponent } from 'app/detail-overview-list/components/boolean-detail/boolean-detail.component'; +import { ProgrammingRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-repository-buttons-detail/programming-repository-buttons-detail.component'; +import { ProgrammingAuxiliaryRepositoryButtonsDetailComponent } from 'app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component'; +import { ProgrammingTestStatusDetailComponent } from 'app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component'; +import { ProgrammingDiffReportDetailComponent } from 'app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Component({ template: `
`, @@ -35,7 +40,13 @@ describe('ExerciseDetailDirective', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [TestDetailHostComponent, ExerciseDetailDirective, MockComponent(TextDetailComponent)], + declarations: [ + TestDetailHostComponent, + ExerciseDetailDirective, + MockDirective(TranslateDirective), + MockComponent(TextDetailComponent), + MockComponent(ProgrammingDiffReportDetailComponent), + ], }).compileComponents(); fixture = TestBed.createComponent(TestDetailHostComponent); @@ -86,6 +97,14 @@ describe('ExerciseDetailDirective', () => { ProgrammingAuxiliaryRepositoryButtonsDetailComponent, ); }); + + it('should create ProgrammingTestStatusDetail component', () => { + checkComponentForDetailWasCreated({ type: DetailType.ProgrammingTestStatus } as ProgrammingTestStatusDetail, ProgrammingTestStatusDetailComponent); + }); + + it('should create ProgrammingDiffReportDetail component', () => { + checkComponentForDetailWasCreated({ type: DetailType.ProgrammingDiffReport } as ProgrammingDiffReportDetail, ProgrammingDiffReportDetailComponent); + }); }); function checkComponentForDetailWasNotCreated(detailToBeChecked: NotShownDetail) { diff --git a/src/test/javascript/spec/component/forms/form-status-bar.component.spec.ts b/src/test/javascript/spec/component/forms/form-status-bar.component.spec.ts index 102a97560a30..7c7e5dbbdac3 100644 --- a/src/test/javascript/spec/component/forms/form-status-bar.component.spec.ts +++ b/src/test/javascript/spec/component/forms/form-status-bar.component.spec.ts @@ -30,10 +30,6 @@ describe('FormStatusBarComponent', () => { jest.restoreAllMocks(); }); - it('should initializes', () => { - expect(comp).toBeDefined(); - }); - it('should scroll to correct headline', () => { const mockDOMElement = { scrollIntoView: jest.fn(), style: {} }; const getElementSpy = jest.spyOn(document, 'getElementById').mockReturnValue(mockDOMElement as any as HTMLElement); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts index a77d15c0709f..03905c6938a7 100644 --- a/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/components/learning-path-lecture-unit.component.spec.ts @@ -17,20 +17,33 @@ import { AlertService } from 'app/core/util/alert.service'; import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { of } from 'rxjs'; +import { CourseInformationSharingConfiguration } from 'app/entities/course.model'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; +import { MockComponent } from 'ng-mocks'; +import { LearningPathNavigationService } from 'app/course/learning-paths/services/learning-path-navigation.service'; +import { Lecture } from 'app/entities/lecture.model'; +import { LectureUnitCompletionEvent } from 'app/overview/course-lectures/course-lecture-details.component'; describe('LearningPathLectureUnitComponent', () => { let component: LearningPathLectureUnitComponent; let fixture: ComponentFixture; + let learningPathNavigationService: LearningPathNavigationService; let lectureUnitService: LectureUnitService; let getLectureUnitByIdSpy: jest.SpyInstance; + let setLearningObjectCompletionSpy: jest.SpyInstance; const learningPathId = 1; const lectureUnit: VideoUnit = { id: 1, description: 'Example video unit', name: 'Example video', - lecture: { id: 2 }, + lecture: { + id: 2, + course: { + courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING, + }, + }, completed: false, visibleToStudents: true, source: 'https://www.youtube.com/embed/8iU8LPEa4o0', @@ -38,7 +51,7 @@ describe('LearningPathLectureUnitComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LearningPathLectureUnitComponent], + imports: [LearningPathLectureUnitComponent, MockComponent(DiscussionSectionComponent)], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -70,6 +83,8 @@ describe('LearningPathLectureUnitComponent', () => { lectureUnitService = TestBed.inject(LectureUnitService); getLectureUnitByIdSpy = jest.spyOn(lectureUnitService, 'getLectureUnitById').mockReturnValue(of(lectureUnit)); lectureUnitService = TestBed.inject(LectureUnitService); + learningPathNavigationService = TestBed.inject(LearningPathNavigationService); + setLearningObjectCompletionSpy = jest.spyOn(learningPathNavigationService, 'setCurrentLearningObjectCompletion').mockReturnValue(); fixture = TestBed.createComponent(LearningPathLectureUnitComponent); component = fixture.componentInstance; @@ -83,6 +98,7 @@ describe('LearningPathLectureUnitComponent', () => { it('should initialize', () => { expect(component).toBeTruthy(); expect(component.lectureUnitId()).toBe(learningPathId); + expect(component.isCommunicationEnabled()).toBeFalse(); }); it('should get lecture unit', async () => { @@ -101,6 +117,38 @@ describe('LearningPathLectureUnitComponent', () => { expect(component.lectureUnit()).toEqual(lectureUnit); }); + it('should not set current learning object on error', async () => { + const completeLectureUnitSpy = jest.spyOn(lectureUnitService, 'completeLectureUnit').mockImplementationOnce(() => {}); + + fixture.detectChanges(); + await fixture.whenStable(); + + component.setLearningObjectCompletion({ completed: true, lectureUnit: lectureUnit }); + + expect(completeLectureUnitSpy).toHaveBeenCalledExactlyOnceWith(lectureUnit.lecture, { + completed: true, + lectureUnit: lectureUnit, + }); + expect(setLearningObjectCompletionSpy).not.toHaveBeenCalled(); + }); + + it('should set current learning object completion', async () => { + const completeLectureUnitSpy = jest + .spyOn(lectureUnitService, 'completeLectureUnit') + .mockImplementationOnce((lecture: Lecture, completionEvent: LectureUnitCompletionEvent) => (completionEvent.lectureUnit.completed = completionEvent.completed)); + + fixture.detectChanges(); + await fixture.whenStable(); + + component.setLearningObjectCompletion({ completed: true, lectureUnit: lectureUnit }); + + expect(completeLectureUnitSpy).toHaveBeenCalledExactlyOnceWith(lectureUnit.lecture, { + completed: true, + lectureUnit: lectureUnit, + }); + expect(setLearningObjectCompletionSpy).toHaveBeenCalledExactlyOnceWith(true); + }); + it('should set loading state correctly', async () => { const setIsLoadingSpy = jest.spyOn(component.isLoading, 'set'); fixture.detectChanges(); @@ -110,4 +158,27 @@ describe('LearningPathLectureUnitComponent', () => { expect(setIsLoadingSpy).toHaveBeenCalledWith(true); expect(setIsLoadingSpy).toHaveBeenCalledWith(false); }); + + it('should show discussion section when communication is enabled', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeTruthy(); + }); + + it('should not show discussion section when communication is disabled', async () => { + const lecture = { + ...lectureUnit.lecture, + course: { courseInformationSharingConfiguration: CourseInformationSharingConfiguration.DISABLED }, + }; + getLectureUnitByIdSpy.mockReturnValue(of({ ...lectureUnit, lecture })); + + fixture.detectChanges(); + await fixture.whenStable(); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeFalsy(); + }); }); diff --git a/src/test/javascript/spec/component/learning-paths/participate/learning-path-lecture-unit-view.component.spec.ts b/src/test/javascript/spec/component/learning-paths/participate/learning-path-lecture-unit-view.component.spec.ts index 16fa583ba3c9..ad24cc2dd512 100644 --- a/src/test/javascript/spec/component/learning-paths/participate/learning-path-lecture-unit-view.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/participate/learning-path-lecture-unit-view.component.spec.ts @@ -10,7 +10,6 @@ import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-manage import { TextUnit } from 'app/entities/lecture-unit/textUnit.model'; import { OnlineUnit } from 'app/entities/lecture-unit/onlineUnit.model'; import { Course, CourseInformationSharingConfiguration } from 'app/entities/course.model'; -import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; import { AttachmentUnitComponent } from 'app/overview/course-lectures/attachment-unit/attachment-unit.component'; import { VideoUnitComponent } from 'app/overview/course-lectures/video-unit/video-unit.component'; import { OnlineUnitComponent } from 'app/overview/course-lectures/online-unit/online-unit.component'; @@ -105,17 +104,4 @@ describe('LearningPathLectureUnitViewComponent', () => { expect(setCompletionStub).toHaveBeenCalledOnce(); expect(setCompletionStub).toHaveBeenCalledWith(attachment.id, lecture.id, event.completed); }); - - it('should set properties of child on activate', () => { - const attachment = new AttachmentUnit(); - attachment.id = 3; - lecture.lectureUnits = [attachment]; - comp.lecture = lecture; - comp.lectureUnit = attachment; - lecture.course = new Course(); - fixture.detectChanges(); - const instance = { lecture: undefined } as DiscussionSectionComponent; - comp.onChildActivate(instance); - expect(instance.lecture).toEqual(lecture); - }); }); diff --git a/src/test/javascript/spec/component/overview/course-lectures/course-lecture-details.component.spec.ts b/src/test/javascript/spec/component/overview/course-lectures/course-lecture-details.component.spec.ts index f5eecf4d351c..101871555716 100644 --- a/src/test/javascript/spec/component/overview/course-lectures/course-lecture-details.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-lectures/course-lecture-details.component.spec.ts @@ -2,14 +2,13 @@ import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateService } from '@ngx-translate/core'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import dayjs from 'dayjs/esm'; import { AlertService } from 'app/core/util/alert.service'; import { BehaviorSubject, of } from 'rxjs'; -import { CourseLectureDetailsComponent } from '../../../../../../main/webapp/app/overview/course-lectures/course-lecture-details.component'; +import { CourseLectureDetailsComponent } from 'app/overview/course-lectures/course-lecture-details.component'; import { AttachmentUnitComponent } from 'app/overview/course-lectures/attachment-unit/attachment-unit.component'; import { ExerciseUnitComponent } from 'app/overview/course-lectures/exercise-unit/exercise-unit.component'; import { TextUnitComponent } from 'app/overview/course-lectures/text-unit/text-unit.component'; @@ -21,14 +20,14 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { ArtemisTimeAgoPipe } from 'app/shared/pipes/artemis-time-ago.pipe'; import { SidePanelComponent } from 'app/shared/side-panel/side-panel.component'; import { Lecture } from 'app/entities/lecture.model'; -import { Course } from 'app/entities/course.model'; +import { Course, CourseInformationSharingConfiguration } from 'app/entities/course.model'; import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; import { Attachment, AttachmentType } from 'app/entities/attachment.model'; import { TextUnit } from 'app/entities/lecture-unit/textUnit.model'; import { FileService } from 'app/shared/http/file.service'; import { LectureService } from 'app/lecture/lecture.service'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; -import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpHeaders, HttpResponse, provideHttpClient } from '@angular/common/http'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { SubmissionResultStatusComponent } from 'app/overview/submission-result-status.component'; import { ExerciseDetailsStudentActionsComponent } from 'app/overview/exercise-details/exercise-details-student-actions.component'; @@ -39,31 +38,36 @@ import { CourseExerciseRowComponent } from 'app/overview/course-exercises/course import { MockFileService } from '../../../helpers/mocks/service/mock-file.service'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; -import { NgbCollapse, NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { ScienceService } from 'app/shared/science/science.service'; import * as DownloadUtils from 'app/shared/util/download.util'; -import { ProfileService } from '../../../../../../main/webapp/app/shared/layouts/profiles/profile.service'; -import { ProfileInfo } from '../../../../../../main/webapp/app/shared/layouts/profiles/profile-info.model'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; import { MockProfileService } from '../../../helpers/mocks/service/mock-profile.service'; import { OnlineUnitComponent } from 'app/overview/course-lectures/online-unit/online-unit.component'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { NgbCollapse, NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; describe('CourseLectureDetailsComponent', () => { let fixture: ComponentFixture; let courseLecturesDetailsComponent: CourseLectureDetailsComponent; let lecture: Lecture; + let course: Course; let lectureUnit1: AttachmentUnit; let lectureUnit2: AttachmentUnit; let lectureUnit3: TextUnit; let debugElement: DebugElement; let profileService: ProfileService; + let lectureService: LectureService; let getProfileInfoMock: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { const releaseDate = dayjs('18-03-2020', 'DD-MM-YYYY'); - const course = new Course(); + course = new Course(); course.id = 456; + course.courseInformationSharingConfiguration = CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING; lecture = new Lecture(); lecture.id = 1; @@ -88,8 +92,8 @@ describe('CourseLectureDetailsComponent', () => { headers = headers.set('Content-Type', 'application/json; charset=utf-8'); const response = of(new HttpResponse({ body: lecture, headers, status: 200 })); - TestBed.configureTestingModule({ - imports: [RouterTestingModule, MockDirective(NgbTooltip), MockDirective(NgbCollapse), MockDirective(NgbPopover)], + await TestBed.configureTestingModule({ + imports: [MockDirective(NgbTooltip), MockDirective(NgbCollapse), MockDirective(NgbPopover)], declarations: [ CourseLectureDetailsComponent, MockComponent(AttachmentUnitComponent), @@ -112,8 +116,11 @@ describe('CourseLectureDetailsComponent', () => { MockComponent(FaIconComponent), MockDirective(TranslateDirective), MockComponent(SubmissionResultStatusComponent), + MockComponent(DiscussionSectionComponent), ], providers: [ + provideHttpClient(), + provideHttpClientTesting(), MockProvider(LectureService, { find: () => { return response; @@ -136,20 +143,22 @@ describe('CourseLectureDetailsComponent', () => { MockProvider(Router), MockProvider(ScienceService), ], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(CourseLectureDetailsComponent); - courseLecturesDetailsComponent = fixture.componentInstance; - debugElement = fixture.debugElement; - - // mock profileService - profileService = fixture.debugElement.injector.get(ProfileService); - getProfileInfoMock = jest.spyOn(profileService, 'getProfileInfo'); - const profileInfo = { inProduction: false } as ProfileInfo; - const profileInfoSubject = new BehaviorSubject(profileInfo); - getProfileInfoMock.mockReturnValue(profileInfoSubject); - }); + }).compileComponents(); + + lectureService = TestBed.inject(LectureService); + jest.spyOn(lectureService, 'findWithDetails').mockReturnValue(response); + jest.spyOn(lectureService, 'find').mockReturnValue(response); + + fixture = TestBed.createComponent(CourseLectureDetailsComponent); + courseLecturesDetailsComponent = fixture.componentInstance; + debugElement = fixture.debugElement; + + // mock profileService + profileService = fixture.debugElement.injector.get(ProfileService); + getProfileInfoMock = jest.spyOn(profileService, 'getProfileInfo'); + const profileInfo = { inProduction: false } as ProfileInfo; + const profileInfoSubject = new BehaviorSubject(profileInfo); + getProfileInfoMock.mockReturnValue(profileInfoSubject); }); afterEach(() => { @@ -247,6 +256,27 @@ describe('CourseLectureDetailsComponent', () => { expect(courseLecturesDetailsComponent.attachmentExtension(attachment)).toBe('N/A'); })); + it('should show discussion section when communication is enabled', fakeAsync(() => { + fixture.detectChanges(); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeTruthy(); + })); + + it('should not show discussion section when communication is disabled', fakeAsync(() => { + const lecture = { + ...lectureUnit3.lecture, + course: { courseInformationSharingConfiguration: CourseInformationSharingConfiguration.DISABLED }, + }; + const response = of(new HttpResponse({ body: { ...lecture }, status: 200 })); + jest.spyOn(TestBed.inject(LectureService), 'findWithDetails').mockReturnValue(response); + + fixture.detectChanges(); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeFalsy(); + })); + it('should download file for attachment', fakeAsync(() => { const fileService = TestBed.inject(FileService); const downloadFileSpy = jest.spyOn(fileService, 'downloadFile'); diff --git a/src/test/javascript/spec/component/overview/discussion-section/discussion-section.component.spec.ts b/src/test/javascript/spec/component/overview/discussion-section/discussion-section.component.spec.ts index 55a145f49666..f1f6ec890ecf 100644 --- a/src/test/javascript/spec/component/overview/discussion-section/discussion-section.component.spec.ts +++ b/src/test/javascript/spec/component/overview/discussion-section/discussion-section.component.spec.ts @@ -1,8 +1,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { of } from 'rxjs'; import { HttpResponse, provideHttpClient } from '@angular/common/http'; -import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; import { MetisService } from 'app/shared/metis/metis.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { MockExerciseService } from '../../../helpers/mocks/service/mock-exercise.service'; @@ -13,8 +12,6 @@ import { MockPostService } from '../../../helpers/mocks/service/mock-post.servic import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from '../../../helpers/mocks/service/mock-account.service'; import { DiscussionSectionComponent } from 'app/overview/discussion-section/discussion-section.component'; -import { PostingThreadComponent } from 'app/shared/metis/posting-thread/posting-thread.component'; -import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -25,7 +22,6 @@ import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { getElement, getElements } from '../../../helpers/utils/general.utils'; -import { ButtonComponent } from 'app/shared/components/button.component'; import { messagesBetweenUser1User2, metisCourse, @@ -47,6 +43,7 @@ import { MetisConversationService } from 'app/shared/metis/metis-conversation.se import { MockMetisConversationService } from '../../../helpers/mocks/service/mock-metis-conversation.service'; import { NotificationService } from 'app/shared/notification/notification.service'; import { MockNotificationService } from '../../../helpers/mocks/service/mock-notification.service'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector @@ -66,11 +63,12 @@ describe('DiscussionSectionComponent', () => { let getChannelOfLectureSpy: jest.SpyInstance; let getChannelOfExerciseSpy: jest.SpyInstance; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MockModule(FormsModule), MockModule(ReactiveFormsModule), MockModule(NgbTooltipModule)], + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MockModule(FormsModule), MockModule(ReactiveFormsModule), MockModule(NgbTooltipModule), DiscussionSectionComponent], providers: [ provideHttpClient(), + provideHttpClientTesting(), FormBuilder, MockProvider(SessionStorageService), MockProvider(ChannelService), @@ -89,48 +87,36 @@ describe('DiscussionSectionComponent', () => { useValue: new MockActivatedRoute({ postId: metisPostTechSupport.id, courseId: metisCourse.id }), }, ], - declarations: [ - DiscussionSectionComponent, - InfiniteScrollStubDirective, - MockComponent(PostingThreadComponent), - MockComponent(PostCreateEditModalComponent), - MockComponent(FaIconComponent), - MockComponent(ButtonComponent), - MockPipe(ArtemisTranslatePipe), - ], + declarations: [InfiniteScrollStubDirective, MockComponent(FaIconComponent)], }) .overrideComponent(DiscussionSectionComponent, { set: { providers: [{ provide: MetisService, useClass: MetisService }], }, }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(DiscussionSectionComponent); - component = fixture.componentInstance; - metisService = fixture.debugElement.injector.get(MetisService); - channelService = TestBed.inject(ChannelService); - getChannelOfLectureSpy = jest.spyOn(channelService, 'getChannelOfLecture').mockReturnValue( - of( - new HttpResponse({ - body: metisLectureChannelDTO, - status: 200, - }), - ), - ); - getChannelOfExerciseSpy = jest.spyOn(channelService, 'getChannelOfExercise').mockReturnValue( - of( - new HttpResponse({ - body: metisExerciseChannelDTO, - status: 200, - }), - ), - ); - metisServiceGetFilteredPostsSpy = jest.spyOn(metisService, 'getFilteredPosts'); - component.lecture = { ...metisLecture, course: metisCourse }; - component.ngOnInit(); - fixture.detectChanges(); - }); + .compileComponents(); + + fixture = TestBed.createComponent(DiscussionSectionComponent); + component = fixture.componentInstance; + metisService = fixture.debugElement.injector.get(MetisService); + channelService = TestBed.inject(ChannelService); + getChannelOfLectureSpy = jest.spyOn(channelService, 'getChannelOfLecture').mockReturnValue( + of( + new HttpResponse({ + body: metisLectureChannelDTO, + status: 200, + }), + ), + ); + getChannelOfExerciseSpy = jest.spyOn(channelService, 'getChannelOfExercise').mockReturnValue( + of( + new HttpResponse({ + body: metisExerciseChannelDTO, + status: 200, + }), + ), + ); + metisServiceGetFilteredPostsSpy = jest.spyOn(metisService, 'getFilteredPosts'); }); afterEach(() => { @@ -138,8 +124,8 @@ describe('DiscussionSectionComponent', () => { }); it('should set course and messages for lecture with lecture channel on initialization', fakeAsync(() => { - component.lecture = { ...metisLecture, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('lecture', { ...metisLecture, course: metisCourse }); + fixture.detectChanges(); tick(); expect(component.course).toEqual(metisCourse); expect(component.createdPost).toBeDefined(); @@ -149,9 +135,8 @@ describe('DiscussionSectionComponent', () => { })); it('should set course and messages for exercise with exercise channel on initialization', fakeAsync(() => { - component.lecture = undefined; - component.exercise = { ...metisExercise, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); + fixture.detectChanges(); tick(); expect(component.course).toEqual(metisCourse); expect(component.createdPost).toBeDefined(); @@ -161,6 +146,8 @@ describe('DiscussionSectionComponent', () => { })); it('should reset current post', fakeAsync(() => { + fixture.componentRef.setInput('lecture', { ...metisLecture, course: metisCourse }); + fixture.detectChanges(); component.resetCurrentPost(); tick(); expect(component.currentPost).toBeUndefined(); @@ -168,9 +155,8 @@ describe('DiscussionSectionComponent', () => { })); it('should initialize correctly for exercise posts with default settings', fakeAsync(() => { - component.lecture = undefined; - component.exercise = { ...metisExercise, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); + fixture.detectChanges(); tick(); expect(component.formGroup.get('filterToUnresolved')?.value).toBeFalse(); expect(component.formGroup.get('filterToOwn')?.value).toBeFalse(); @@ -182,32 +168,32 @@ describe('DiscussionSectionComponent', () => { })); it('should display one new message button for more then 3 messages in channel', fakeAsync(() => { - component.exercise = { ...metisExercise, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); + fixture.detectChanges(); tick(); fixture.detectChanges(); tick(); component.posts = metisExercisePosts; fixture.detectChanges(); tick(); - const newPostButtons = getElements(fixture.debugElement, '.btn-primary'); + const newPostButtons = getElements(fixture.debugElement, '#new-post'); expect(newPostButtons).not.toBeNull(); expect(newPostButtons).toHaveLength(1); })); it('should display one new message button', fakeAsync(() => { - component.exercise = { ...metisExercise, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); + fixture.detectChanges(); tick(); fixture.detectChanges(); - const newPostButtons = getElements(fixture.debugElement, '.btn-primary'); + const newPostButtons = getElements(fixture.debugElement, '#new-post'); expect(newPostButtons).not.toBeNull(); expect(newPostButtons).toHaveLength(1); })); it('should show search-bar and filters if not focused to a post', fakeAsync(() => { - component.exercise = { ...metisExercise, course: metisCourse }; - component.ngOnInit(); + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); + fixture.detectChanges(); tick(); fixture.detectChanges(); const searchInput = getElement(fixture.debugElement, 'input[name=searchText]'); @@ -222,8 +208,7 @@ describe('DiscussionSectionComponent', () => { })); it('should hide search-bar and filters if focused to a post', fakeAsync(() => { - component.lecture = undefined; - component.ngOnInit(); + fixture.detectChanges(); tick(); fixture.detectChanges(); const searchInput = getElement(fixture.debugElement, 'input[name=searchText]'); @@ -238,9 +223,9 @@ describe('DiscussionSectionComponent', () => { })); it('triggering filters should invoke the metis service', fakeAsync(() => { - component.exercise = { ...metisExercise, course: metisCourse }; + fixture.componentRef.setInput('exercise', { ...metisExercise, course: metisCourse }); metisServiceGetFilteredPostsSpy.mockReset(); - component.ngOnInit(); + fixture.detectChanges(); tick(); fixture.detectChanges(); component.formGroup.patchValue({ @@ -268,8 +253,8 @@ describe('DiscussionSectionComponent', () => { it('loads exercise messages if communication only', fakeAsync(() => { component.course = { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_ONLY } as Course; - component.exercise = { id: 2 } as Exercise; - component.lecture = undefined; + fixture.componentRef.setInput('exercise', { id: 2 } as Exercise); + fixture.detectChanges(); component.setChannel(1); @@ -283,7 +268,8 @@ describe('DiscussionSectionComponent', () => { it('loads lecture messages if communication only', fakeAsync(() => { component.course = { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_ONLY } as Course; - component.lecture = { id: 2 } as Lecture; + fixture.componentRef.setInput('lecture', { id: 2 } as Lecture); + fixture.detectChanges(); component.setChannel(1); @@ -297,7 +283,8 @@ describe('DiscussionSectionComponent', () => { it('collapses sidebar if no channel exists', fakeAsync(() => { component.course = { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_ONLY } as Course; - component.lecture = { id: 2 } as Lecture; + fixture.componentRef.setInput('lecture', { id: 2 } as Lecture); + fixture.detectChanges(); getChannelOfLectureSpy = jest.spyOn(channelService, 'getChannelOfLecture').mockReturnValue( of( new HttpResponse({ @@ -315,6 +302,9 @@ describe('DiscussionSectionComponent', () => { })); it('should react to srcoll up event', fakeAsync(() => { + component.course = { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_ONLY } as Course; + fixture.componentRef.setInput('lecture', { id: 2 } as Lecture); + fixture.detectChanges(); const fetchNextPageSpy = jest.spyOn(component, 'fetchNextPage'); const scrolledUp = new CustomEvent('scrolledUp'); @@ -332,6 +322,7 @@ describe('DiscussionSectionComponent', () => { }); it('should change sort direction', () => { + fixture.detectChanges(); component.currentSortDirection = SortDirection.ASCENDING; component.onChangeSortDir(); expect(component.currentSortDirection).toBe(SortDirection.DESCENDING); @@ -340,6 +331,9 @@ describe('DiscussionSectionComponent', () => { }); it('fetches new messages on scroll up if more messages are available', fakeAsync(() => { + component.course = { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_ONLY } as Course; + fixture.componentRef.setInput('lecture', { id: 2 } as Lecture); + fixture.detectChanges(); component.posts = []; const commandMetisToFetchPostsSpy = jest.spyOn(component, 'fetchNextPage'); diff --git a/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts b/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts index 5fc8684a61cd..efb66d508837 100644 --- a/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts +++ b/src/test/javascript/spec/component/overview/exercise-details/course-exercise-details.component.spec.ts @@ -45,7 +45,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ExtensionPointDirective } from 'app/shared/extension-point/extension-point.directive'; import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { ComplaintsStudentViewComponent } from 'app/complaints/complaints-for-students/complaints-student-view.component'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { MockRouterLinkDirective } from '../../../helpers/mocks/directive/mock-router-link.directive'; import { LtiInitializerComponent } from 'app/overview/exercise-details/lti-initializer.component'; import { ModelingEditorComponent } from 'app/exercises/modeling/shared/modeling-editor.component'; @@ -71,6 +71,8 @@ import { MockScienceService } from '../../../helpers/mocks/service/mock-science- import { ScienceEventType } from 'app/shared/science/science.model'; import { PROFILE_IRIS } from 'app/app.constants'; import { ExerciseHintService } from 'app/exercises/shared/exercise-hint/shared/exercise-hint.service'; +import { CourseInformationSharingConfiguration } from 'app/entities/course.model'; +import { provideHttpClient } from '@angular/common/http'; describe('CourseExerciseDetailsComponent', () => { let comp: CourseExerciseDetailsComponent; @@ -92,7 +94,15 @@ describe('CourseExerciseDetailsComponent', () => { let scienceService: ScienceService; let logEventStub: jest.SpyInstance; - const exercise = { id: 42, type: ExerciseType.TEXT, studentParticipations: [], course: {} } as unknown as Exercise; + const exercise = { + id: 42, + type: ExerciseType.TEXT, + studentParticipations: [], + course: { + id: 1, + courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING, + }, + } as unknown as Exercise; const textExercise = { id: 24, @@ -119,11 +129,15 @@ describe('CourseExerciseDetailsComponent', () => { const parentParams = { courseId: 1 }; const parentRoute = { parent: { parent: { params: of(parentParams) } } } as any as ActivatedRoute; - const route = { params: of({ exerciseId: exercise.id }), parent: parentRoute, queryParams: of({ welcome: '' }) } as any as ActivatedRoute; + const route = { + params: of({ exerciseId: exercise.id }), + parent: parentRoute, + queryParams: of({ welcome: '' }), + } as any as ActivatedRoute; beforeEach(() => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], + imports: [MockComponent(DiscussionSectionComponent)], declarations: [ CourseExerciseDetailsComponent, MockPipe(ArtemisTranslatePipe), @@ -152,6 +166,8 @@ describe('CourseExerciseDetailsComponent', () => { MockComponent(ExerciseInfoComponent), ], providers: [ + provideHttpClient(), + provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }, { provide: Router, useClass: MockRouter }, { provide: ProfileService, useClass: MockProfileService }, @@ -195,7 +211,11 @@ describe('CourseExerciseDetailsComponent', () => { // mock teamService, needed for team assignment teamService = fixture.debugElement.injector.get(TeamService); - const teamAssignmentPayload = { exerciseId: 2, teamId: 2, studentParticipations: [] } as TeamAssignmentPayload; + const teamAssignmentPayload = { + exerciseId: 2, + teamId: 2, + studentParticipations: [], + } as TeamAssignmentPayload; jest.spyOn(teamService, 'teamAssignmentUpdates', 'get').mockReturnValue(Promise.resolve(of(teamAssignmentPayload))); // mock participationService, needed for team assignment @@ -313,14 +333,6 @@ describe('CourseExerciseDetailsComponent', () => { expect(comp.exampleSolutionCollapsed).toBeFalse(); }); - it('should store a reference to child component', () => { - comp.exercise = exercise; - - const childComponent = {} as DiscussionSectionComponent; - comp.onChildActivate(childComponent); - expect(childComponent.exercise).toEqual(exercise); - }); - it('should activate hint', () => { comp.availableExerciseHints = [{ id: 1 }, { id: 2 }]; comp.activatedExerciseHints = []; @@ -331,10 +343,47 @@ describe('CourseExerciseDetailsComponent', () => { expect(comp.activatedExerciseHints).toContain(activatedHint); }); - it('should handle new programming exercise', () => { - const childComponent = {} as DiscussionSectionComponent; - comp.onChildActivate(childComponent); + it('should sort results by completion date in ascending order', () => { + const result1 = { completionDate: dayjs().subtract(2, 'days') } as Result; + const result2 = { completionDate: dayjs().subtract(1, 'day') } as Result; + const result3 = { completionDate: dayjs() } as Result; + + const results = [result3, result1, result2]; + results.sort((a, b) => comp['resultSortFunction'](a, b)); + + expect(results).toEqual([result1, result2, result3]); + }); + + it('should handle results with undefined completion dates', () => { + const result1 = { completionDate: dayjs().subtract(2, 'days') } as Result; + const result2 = { completionDate: undefined } as Result; + const result3 = { completionDate: dayjs() } as Result; + + const results = [result3, result1, result2]; + results.sort((a, b) => comp['resultSortFunction'](a, b)); + + expect(results).toEqual([result1, result3, result2]); + }); + + it('should handle empty results array', () => { + const results: Result[] = []; + results.sort((a, b) => comp['resultSortFunction'](a, b)); + + expect(results).toEqual([]); + }); + + it('should handle results with same completion dates', () => { + const date = dayjs(); + const result1 = { completionDate: date } as Result; + const result2 = { completionDate: date } as Result; + + const results = [result2, result1]; + results.sort((a, b) => comp['resultSortFunction'](a, b)); + + expect(results).toEqual([result2, result1]); + }); + it('should handle new programming exercise', () => { const courseId = programmingExercise.course!.id!; comp.courseId = courseId; @@ -343,7 +392,6 @@ describe('CourseExerciseDetailsComponent', () => { expect(comp.baseResource).toBe(`/course-management/${courseId}/${programmingExercise.type}-exercises/${programmingExercise.id}/`); expect(comp.allowComplaintsForAutomaticAssessments).toBeTrue(); expect(comp.submissionPolicy).toEqual(submissionPolicy); - expect(childComponent.exercise).toEqual(programmingExercise); }); it('should handle error when getting latest rated result', fakeAsync(() => { @@ -432,4 +480,25 @@ describe('CourseExerciseDetailsComponent', () => { fixture.detectChanges(); expect(logEventStub).toHaveBeenCalledExactlyOnceWith(ScienceEventType.EXERCISE__OPEN, exercise.id); }); + + it('should not show discussion section when communication is disabled', fakeAsync(() => { + const newExercise = { + ...exercise, + course: { id: 1, courseInformationSharingConfiguration: CourseInformationSharingConfiguration.DISABLED }, + }; + getExerciseDetailsMock.mockReturnValue(of({ body: newExercise })); + + comp.handleNewExercise({ exercise }); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeFalsy(); + })); + + it('should show discussion section when communication is enabled', fakeAsync(() => { + fixture.detectChanges(); + tick(500); + + const discussionSection = fixture.nativeElement.querySelector('jhi-discussion-section'); + expect(discussionSection).toBeTruthy(); + })); }); diff --git a/src/test/javascript/spec/component/programming-diff-report-detail.component.spec.ts b/src/test/javascript/spec/component/programming-diff-report-detail.component.spec.ts new file mode 100644 index 000000000000..9f1940740640 --- /dev/null +++ b/src/test/javascript/spec/component/programming-diff-report-detail.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockNgbModalService } from '../helpers/mocks/service/mock-ngb-modal.service'; +import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; +import { ProgrammingDiffReportDetailComponent } from 'app/detail-overview-list/components/programming-diff-report-detail/programming-diff-report-detail.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../helpers/mocks/service/mock-translate.service'; +import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; +import { MockProgrammingExerciseService } from '../helpers/mocks/service/mock-programming-exercise.service'; +import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; +import { MockProgrammingExerciseParticipationService } from '../helpers/mocks/service/mock-programming-exercise-participation.service'; + +describe('ProgrammingDiffReportDetailComponent', () => { + let component: ProgrammingDiffReportDetailComponent; + let fixture: ComponentFixture; + let modalService: NgbModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProgrammingDiffReportDetailComponent], + providers: [ + { provide: NgbModal, useClass: MockNgbModalService }, + { provide: TranslateService, useClass: MockTranslateService }, + { provide: ProgrammingExerciseService, useClass: MockProgrammingExerciseService }, + { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(ProgrammingDiffReportDetailComponent); + + modalService = fixture.debugElement.injector.get(NgbModal); + component = fixture.componentInstance; + }); + + it('should open git diff modal', () => { + const modalSpy = jest.spyOn(modalService, 'open'); + component.showGitDiff({} as unknown as ProgrammingExerciseGitDiffReport); + expect(modalSpy).toHaveBeenCalledOnce(); + }); + + it('should not open git diff modal', () => { + const modalSpy = jest.spyOn(modalService, 'open'); + component.showGitDiff(undefined); + expect(modalSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts new file mode 100644 index 000000000000..0e9387b93e5b --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts @@ -0,0 +1,65 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { ArtemisTestModule } from '../../../test.module'; +import { FeedbackAnalysisComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component'; +import { FeedbackAnalysisService } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; + +describe('FeedbackAnalysisComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackAnalysisComponent; + let feedbackAnalysisService: FeedbackAnalysisService; + let getFeedbackDetailsSpy: jest.SpyInstance; + + const feedbackMock: FeedbackDetail[] = [ + { detailText: 'Test feedback 1 detail', testCaseName: 'test1', count: 10, relativeCount: 50, taskNumber: 1 }, + { detailText: 'Test feedback 2 detail', testCaseName: 'test2', count: 5, relativeCount: 25, taskNumber: 2 }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, TranslateModule.forRoot(), FeedbackAnalysisComponent], + declarations: [], + providers: [ + { + provide: TranslateService, + useClass: MockTranslateService, + }, + FeedbackAnalysisService, + ], + }).compileComponents(); + fixture = TestBed.createComponent(FeedbackAnalysisComponent); + component = fixture.componentInstance; + component.exerciseId = 1; + feedbackAnalysisService = fixture.debugElement.injector.get(FeedbackAnalysisService); + getFeedbackDetailsSpy = jest.spyOn(feedbackAnalysisService, 'getFeedbackDetailsForExercise').mockResolvedValue(feedbackMock); + }); + + describe('ngOnInit', () => { + it('should call loadFeedbackDetails when exerciseId is provided', async () => { + component.ngOnInit(); + await fixture.whenStable(); + + expect(getFeedbackDetailsSpy).toHaveBeenCalledWith(1); + expect(component.feedbackDetails).toEqual(feedbackMock); + }); + }); + + describe('loadFeedbackDetails', () => { + it('should load feedback details and update the component state', async () => { + await component.loadFeedbackDetails(1); + expect(component.feedbackDetails).toEqual(feedbackMock); + }); + + it('should handle error while loading feedback details', async () => { + getFeedbackDetailsSpy.mockRejectedValue(new Error('Error loading feedback details')); + + try { + await component.loadFeedbackDetails(1); + } catch { + expect(component.feedbackDetails).toEqual([]); + } + }); + }); +}); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts new file mode 100644 index 000000000000..a4f9a2a3ee42 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; + +describe('FeedbackAnalysisService', () => { + let service: FeedbackAnalysisService; + let httpMock: HttpTestingController; + + const feedbackDetailsMock: FeedbackDetail[] = [ + { detailText: 'Feedback 1', testCaseName: 'test1', count: 5, relativeCount: 25.0, taskNumber: 1 }, + { detailText: 'Feedback 2', testCaseName: 'test2', count: 3, relativeCount: 15.0, taskNumber: 2 }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [FeedbackAnalysisService], + }); + + service = TestBed.inject(FeedbackAnalysisService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('getFeedbackDetailsForExercise', () => { + it('should retrieve feedback details for a given exercise', async () => { + const responsePromise = service.getFeedbackDetailsForExercise(1); + + const req = httpMock.expectOne('api/exercises/1/feedback-details'); + expect(req.request.method).toBe('GET'); + req.flush(feedbackDetailsMock); + + const result = await responsePromise; + expect(result).toEqual(feedbackDetailsMock); + }); + + it('should handle errors while retrieving feedback details', async () => { + const responsePromise = service.getFeedbackDetailsForExercise(1); + + const req = httpMock.expectOne('api/exercises/1/feedback-details'); + expect(req.request.method).toBe('GET'); + req.flush('Something went wrong', { status: 500, statusText: 'Server Error' }); + + await expect(responsePromise).rejects.toThrow('Internal server error'); + }); + }); +}); diff --git a/src/test/javascript/spec/component/shared/confirm-entity-name.component.spec.ts b/src/test/javascript/spec/component/shared/confirm-entity-name.component.spec.ts index 2e37c1da4eeb..17625d2a357b 100644 --- a/src/test/javascript/spec/component/shared/confirm-entity-name.component.spec.ts +++ b/src/test/javascript/spec/component/shared/confirm-entity-name.component.spec.ts @@ -65,8 +65,8 @@ describe('ConfirmEntityNameComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule], - declarations: [ConfirmEntityNameComponent, TranslateDirective], + imports: [FormsModule, ReactiveFormsModule, TranslateDirective], + declarations: [ConfirmEntityNameComponent], providers: [{ provide: TranslateService, useClass: MockTranslateService }], }).compileComponents(); fixture = TestBed.createComponent(ConfirmEntityNameComponent); diff --git a/src/test/javascript/spec/component/tutorial-groups/tutorial-groups-management/tutorial-groups/tutorial-groups-management/tutorial-groups-checklist.component.spec.ts b/src/test/javascript/spec/component/tutorial-groups/tutorial-groups-management/tutorial-groups/tutorial-groups-management/tutorial-groups-checklist.component.spec.ts index e71f5029fb9b..b8e4462c8074 100644 --- a/src/test/javascript/spec/component/tutorial-groups/tutorial-groups-management/tutorial-groups/tutorial-groups-management/tutorial-groups-checklist.component.spec.ts +++ b/src/test/javascript/spec/component/tutorial-groups/tutorial-groups-management/tutorial-groups/tutorial-groups-management/tutorial-groups-checklist.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TutorialGroupsChecklistComponent } from 'app/course/tutorial-groups/tutorial-groups-management/tutorial-groups-checklist/tutorial-groups-checklist.component'; import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { ChecklistCheckComponent } from 'app/shared/components/checklist-check.component'; +import { ChecklistCheckComponent } from 'app/shared/components/checklist-check/checklist-check.component'; import { MockRouter } from '../../../../../helpers/mocks/mock-router'; import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; diff --git a/src/test/javascript/spec/component/utils/item-count.component.spec.ts b/src/test/javascript/spec/component/utils/item-count.component.spec.ts index ce6eb7b7dbab..7d04df08102d 100644 --- a/src/test/javascript/spec/component/utils/item-count.component.spec.ts +++ b/src/test/javascript/spec/component/utils/item-count.component.spec.ts @@ -9,8 +9,8 @@ describe('ItemCountComponent test', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [ItemCountComponent, TranslateDirective], + imports: [TranslateModule.forRoot(), TranslateDirective], + declarations: [ItemCountComponent], }) .compileComponents() .then(() => { diff --git a/src/test/javascript/spec/util/shared/translate.directive.spec.ts b/src/test/javascript/spec/util/shared/translate.directive.spec.ts index 7181c3fa07cb..c40004dc7410 100644 --- a/src/test/javascript/spec/util/shared/translate.directive.spec.ts +++ b/src/test/javascript/spec/util/shared/translate.directive.spec.ts @@ -8,14 +8,14 @@ import { TranslateDirective } from 'app/shared/language/translate.directive'; }) class TestTranslateDirectiveComponent {} -describe('TranslateDirective Tests', () => { +describe('TranslateDirective', () => { let fixture: ComponentFixture; let translateService: TranslateService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [TranslateDirective, TestTranslateDirectiveComponent], + imports: [TranslateModule.forRoot(), TranslateDirective], + declarations: [TestTranslateDirectiveComponent], }) .compileComponents() .then(() => { diff --git a/src/test/playwright/e2e/course/CourseManagement.spec.ts b/src/test/playwright/e2e/course/CourseManagement.spec.ts index 3e308d1b4431..8a7eacab4135 100644 --- a/src/test/playwright/e2e/course/CourseManagement.spec.ts +++ b/src/test/playwright/e2e/course/CourseManagement.spec.ts @@ -2,10 +2,9 @@ import { test } from '../../support/fixtures'; import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { admin, studentOne } from '../../support/users'; -import { convertBooleanToCheckIconClass, dayjsToString, generateUUID, trimDate } from '../../support/utils'; +import { base64StringToBlob, convertBooleanToCheckIconClass, dayjsToString, generateUUID, trimDate } from '../../support/utils'; import { expect } from '@playwright/test'; import { Fixtures } from '../../fixtures/fixtures'; -import { base64StringToBlob } from '../../support/utils'; // Common primitives const courseData = { diff --git a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseManagement.spec.ts b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseManagement.spec.ts index e3a69adabc20..9bea097033d6 100644 --- a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseManagement.spec.ts +++ b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseManagement.spec.ts @@ -33,6 +33,36 @@ test.describe('Programming Exercise Management', () => { await expect(courseManagementExercises.getExerciseTitle(exerciseTitle)).toBeVisible(); await page.waitForURL(`**/programming-exercises/${exercise.id}**`); }); + + test('FormStatusBar scrolls to the correct section with headline visible after scroll', async ({ + login, + page, + navigationBar, + courseManagement, + courseManagementExercises, + programmingExerciseCreation, + }) => { + await login(admin, '/'); + await navigationBar.openCourseManagement(); + await courseManagement.openExercisesOfCourse(course.id!); + await courseManagementExercises.createProgrammingExercise(); + await page.waitForURL('**/programming-exercises/new**'); + + const firstSectionHeadline = 'General'; + const firstSectionStatusBarId = 0; + const fourthSectionHeadline = 'Problem'; + const fourthSectionStatusBarId = 3; + + // scroll down + await programmingExerciseCreation.clickFormStatusBarSection(fourthSectionStatusBarId); + await programmingExerciseCreation.checkIsHeadlineVisibleToUser(firstSectionHeadline, false); + await programmingExerciseCreation.checkIsHeadlineVisibleToUser(fourthSectionHeadline, true); + + // scroll up + await programmingExerciseCreation.clickFormStatusBarSection(firstSectionStatusBarId); + await programmingExerciseCreation.checkIsHeadlineVisibleToUser(firstSectionHeadline, true); + await programmingExerciseCreation.checkIsHeadlineVisibleToUser(fourthSectionHeadline, false); + }); }); test.describe('Programming exercise deletion', () => { diff --git a/src/test/playwright/support/pageobjects/LoginPage.ts b/src/test/playwright/support/pageobjects/LoginPage.ts index 24045742e765..5cdd42efc652 100644 --- a/src/test/playwright/support/pageobjects/LoginPage.ts +++ b/src/test/playwright/support/pageobjects/LoginPage.ts @@ -12,19 +12,33 @@ export class LoginPage { } /** - * Enters the specified username into the username input field. + * Enters the specified value into the input field with the specified selector, then blurs the input field. + * This is necessary because the username and password fields are only validated on blur, e.g. when the user clicks + * elsewhere on the page or presses the Tab key. + * @param selector The selector of the input field. + * @param value The value to be entered. + * @private + */ + private async fillThenBlurInput(selector: string, value: string) { + const locator = this.page.locator(selector); + await locator.fill(value); + await locator.blur(); + } + + /** + * Enters the specified username into the username input field, then blurs the input field. * @param name - The username to be entered. */ async enterUsername(name: string) { - await this.page.fill('#username', name); + await this.fillThenBlurInput('#username', name); } /** - * Enters the specified password into the password input field. + * Enters the specified password into the password input field, then blurs the input field. * @param password - The password to be entered. */ async enterPassword(password: string) { - await this.page.fill('#password', password); + await this.fillThenBlurInput('#password', password); } /** diff --git a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts index a3e2cf7b0553..a59342550bbf 100644 --- a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts @@ -1,4 +1,4 @@ -import { Page, expect } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; import { PROGRAMMING_EXERCISE_BASE, ProgrammingLanguage } from '../../../constants'; import { Dayjs } from 'dayjs'; @@ -73,4 +73,62 @@ export class ProgrammingExerciseCreationPage { } await expect(this.page.locator('.owl-dt-popup')).not.toBeVisible(); } + + /** + * Uses an element that is affected by the scrolling of the page as reference to determine if the page is still scrolling + * + * Here we are using the headline of the page as reference + */ + async waitForPageToFinishScrolling(maxTimeout: number = 5000) { + const elementOnPageAffectedByScroll = this.page.locator('h2'); + let isScrolling = true; + const startTime = Date.now(); + + while (isScrolling) { + const initialPosition = await elementOnPageAffectedByScroll.boundingBox(); + await this.page.waitForTimeout(100); // give the page a short time to scroll + const newPosition = await elementOnPageAffectedByScroll.boundingBox(); + + isScrolling = initialPosition?.y !== newPosition?.y; + + const isWaitingForScrollExceedingTimeout = Date.now() - startTime > maxTimeout; + if (isWaitingForScrollExceedingTimeout) { + throw new Error(`Aborting waiting for scroll end - page is still scrolling after ${maxTimeout}ms`); + } + } + } + + async clickFormStatusBarSection(sectionId: number) { + const searchedSectionId = `#status-bar-section-item-${sectionId}`; + const sectionStatusBarLocator: Locator = this.page.locator(searchedSectionId); + expect(await sectionStatusBarLocator.isVisible()).toBeTruthy(); + await sectionStatusBarLocator.click(); + await this.waitForPageToFinishScrolling(); + } + + /** + * Verifies that the locator is visible in the viewport and not hidden by another element + * (e.g. could be hidden by StatusBar / Navbar) + * + * {@link toBeHidden} and {@link toBeVisible} do not solve this problem + * @param locator + */ + private async verifyLocatorIsVisible(locator: Locator) { + const initialPosition = await locator.boundingBox(); + await locator.click(); // scrolls to the locator if needed (e.g. if hidden by another element) + const newPosition = await locator.boundingBox(); + expect(initialPosition).toEqual(newPosition); + } + + async checkIsHeadlineVisibleToUser(searchedHeadlineDisplayText: string, expected: boolean) { + const headlineLocator = this.page.getByRole('heading', { name: searchedHeadlineDisplayText }).first(); + + if (expected) { + await expect(headlineLocator).toBeInViewport({ ratio: 1 }); + /** additional check because {@link toBeInViewport} is too inaccurate */ + await this.verifyLocatorIsVisible(headlineLocator); + } else { + await expect(headlineLocator).not.toBeInViewport(); + } + } } diff --git a/src/test/playwright/support/utils.ts b/src/test/playwright/support/utils.ts index 9d135bbc7282..bc464f447717 100644 --- a/src/test/playwright/support/utils.ts +++ b/src/test/playwright/support/utils.ts @@ -87,10 +87,12 @@ export function titleLowercase(title: string) { /** * Converts a boolean value to its related icon class. * @param boolean - The boolean value to be converted. - * @returns The corresponding ".checked" or ".unchecked" string. + * @returns The corresponding icon class */ export function convertBooleanToCheckIconClass(boolean: boolean) { - return boolean ? '.checked' : '.unchecked'; + const sectionInvalidIcon = '.fa-xmark'; + const sectionValidIcon = '.fa-circle-check'; + return boolean ? sectionValidIcon : sectionInvalidIcon; } /** diff --git a/supporting_scripts/course-scripts/quick-course-setup/.gitignore b/supporting_scripts/course-scripts/quick-course-setup/.gitignore new file mode 100644 index 000000000000..bbc8f396a755 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.env +venv +.idea/ diff --git a/supporting_scripts/course-scripts/quick-course-setup/README.md b/supporting_scripts/course-scripts/quick-course-setup/README.md new file mode 100644 index 000000000000..2cc327c4c520 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/README.md @@ -0,0 +1,189 @@ +# Artemis Course Setup Scripts + +This project contains Python scripts that automate the setup and management of courses and users in an Artemis instance. The scripts help you create a course, generate users, enroll them into courses, and manage programming exercises, including participations and commits. The project is useful for setting up test environments or quickly initializing a course with a large number of students. + +# Setup + +### 1. Install Python and the Python Plugin for IntelliJ + +- Ensure that Python (preferably version 3.12) is installed on your system. +```shell +python3 --version +``` +- Install the [Python Plugin for IntelliJ](https://plugins.jetbrains.com/plugin/631-python). +- Enable Python support in IntelliJ: + - Go to `File > Project Structure > Facets > Add - Python`. + - Add a Python environment by configuring the Python interpreter. + + ![IntelliJ Python Facet Configuration](./images/facets-config.png) + - Add a module in IntelliJ by navigating to `File > Project Structure > Modules > Add - Python`. + + ![IntelliJ Module Configuration](./images/module-config.png) + +### 2. **(Optional)**: Setting Up a Virtual Environment + +It is recommended to use a virtual environment to manage dependencies in isolation from the global Python environment. This approach can prevent version conflicts and keep your system environment clean. + +- Install `virtualenv` if it's not already installed: +```shell +python3 -m pip install virtualenv +``` + +- Create a virtual environment in your project folder: +```shell +python3 -m virtualenv venv +``` + +- Activate the virtual environment: + +- On **Windows**: +```shell +venv\Scripts\activate +``` + +- On **macOS/Linux**: +```shell +source venv/bin/activate +``` + +Once the virtual environment is activated, you will see the `(venv)` prefix in your terminal prompt. All dependencies will now be installed locally to this environment. + +To install any packages into this virtual environment, proceed with the same steps as described below. +### 3. Configure the Environment + +- Start your local Artemis instance. +- Configure the [config.ini](./config.ini) file according to your local or test server setup. +- If packages are missing when running the script, install the necessary Python packages using the following command (replace `` with the actual package name and the python version with your used python version): + +```shell +python3 -m pip install +``` +# Usage + +## Section 1: Create Local Course and Users + +These scripts help you configure and set up your first Artemis course quickly. + +1. Start your local Artemis instance. + +2. Configure the values in [config.ini](./config.ini) according to your setup. + +3. Install the missing packages of the Python scripts that you want to execute (if not done already). You can check the [requirements.txt](./requirements.txt) file for the required packages. + +4. Run the main script using IntelliJ: + + - Use the play button within IntelliJ (if Python is configured properly). + - Alternatively, you can run the script directly from the terminal using the commands provided in this README. + +### Creating a Course with Standard User Groups + +Creates a course for which the users are registered according to their user groups (students, tutors, editors, instructors). + +```shell +python3 create_course.py +``` + +### Create Users + +Creates users 1-20 (students, tutors, editors, instructors - 5 for each group). + +```shell +python3 create_users.py +``` + +### Authenticate Users + +If the users have already been created, they still need to be logged in order to be added to a course (without a first login, Artemis does not know that the users exist). + +```shell +python3 authenticate_all_users.py +``` + +## Test Servers + +### To configure the scripts for use with Artemis test servers: + +1. Adjust server_url and client_url according to the test server. +2. Update admin_user and admin_password to valid values for the test server. +3. Set is_local_course in [config.ini](./config.ini) to False. + +Create a Course and Assign the Default Users + +1-5: students +6-10: tutors +11-15: instructors +16-20: editors + +Define the name of your course in the config.ini as course_name. + +```shell +python3 create_course.py +``` + +### Add Users to Existing Course + +Define the course_id in the [config.ini](./config.ini). + +```shell +python3 add_users_to_course.py +``` + +## Notes + +1. Ensure that the config.ini file is correctly configured before running any scripts. +2. Always test the scripts on a local setup before running them on a production or test server. + +## Section 2: Artemis Large Course with Submissions on Programming Exercise Automation + +This section details how to use the large_course_main script that orchestrates the entire process, from user creation to course setup, and finally user participation in programming exercises. + +### Running the large_course_main Script + +The large_course_main script performs all necessary steps to set up a large course with multiple students and submissions. + +### Steps to Run: + +1. Open the project in IntelliJ. +2. Locate the large_course_main.py file in the project directory. +3. Update the [config.ini](./config.ini) to your needs + 1. To change the number of students created, modify the students variable in the config.ini file. + 2. To change the number of commits each student should perform in the example exercise, modify the commits variable in the config.ini file. + 3. To change the number of programming exercises created, modify the exercises variable in the config.ini file. + 4. To change the name of programming exercises created, modify the exercise_name variable in the config.ini file. + 5. To use an existing course, modify the create_course variable to False and provide a valid course_id in the config.ini file. + 6. To use an existing programming exercise, modify the create_exercises variable to False and provide a valid exercise_Ids in the config.ini file. +4. You can use the play button within IntelliJ (if Python is configured properly) to run the script. +```shell +python3 large_course_main.py +``` + +The script will automatically perform all the necessary steps: + +1. Authenticate as admin. +2. Create students. +3. Create a course or use an existing one. +4. Add students to the course. +5. Create a programming exercise or use an existing one. +6. Add participation and commit for each student. + +### Optional: Deleting All Created Students + +If you want to delete all the students created by the script: + +1. Run the main in delete_students.py. +```shell +python3 delete_students.py +``` + +### Dependency management + +Find outdated dependencies using the following command: +``` +pip list --outdated +``` + +Find unused dependencies using the following command: +``` +pip install deptry +deptry . +``` diff --git a/supporting_scripts/course-scripts/quick-course-setup/add_users_to_course.py b/supporting_scripts/course-scripts/quick-course-setup/add_users_to_course.py new file mode 100644 index 000000000000..68319b23fa39 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/add_users_to_course.py @@ -0,0 +1,42 @@ +import requests +import configparser +from logging_config import logging +from requests import Session + +from utils import login_as_admin, get_user_details_by_index, add_user_to_course, get_student_details_by_index + +# Load configuration +config = configparser.ConfigParser() +config.read('config.ini') + +# Constants from config file +COURSE_ID: str = config.get('CourseSettings', 'course_id') + +def add_users_to_groups_of_course(session: Session, course_id: str) -> None: + print(f"Adding users to course with id {course_id}") + for userIndex in range(1, 21): + user_details = get_user_details_by_index(userIndex) + add_user_to_course(session, course_id, user_details["groups"][0], user_details["login"]) + +def add_students_to_groups_of_course(session: Session, course_id: str, server_url: str, students_to_create: int) -> None: + for user_index in range(1, students_to_create): + user_details = get_student_details_by_index(user_index) + add_students_to_course(session, course_id, user_details["groups"][0], user_details["login"], server_url) + +def add_students_to_course(session: Session, course_id: str, user_group: str, user_name: str, server_url: str) -> None: + url = f"{server_url}/courses/{course_id}/{user_group}/{user_name}" + response = session.post(url) + if response.status_code == 200: + logging.info(f"Added user {user_name} to group {user_group}") + else: + logging.error(f"Could not add user {user_name} to group {user_group}") + +def main() -> None: + session = requests.session() + login_as_admin(session) + add_users_to_groups_of_course(session, COURSE_ID) + +if __name__ == "__main__": + # DO ONLY USE FOR LOCAL COURSE SETUP! + # (Otherwise users will be created for whom the credentials are public in the repository!) + main() diff --git a/supporting_scripts/course-setup-quickstart/authenticate_all_users.py b/supporting_scripts/course-scripts/quick-course-setup/authenticate_all_users.py similarity index 61% rename from supporting_scripts/course-setup-quickstart/authenticate_all_users.py rename to supporting_scripts/course-scripts/quick-course-setup/authenticate_all_users.py index 260a4224b4e2..c7aa3571d700 100644 --- a/supporting_scripts/course-setup-quickstart/authenticate_all_users.py +++ b/supporting_scripts/course-scripts/quick-course-setup/authenticate_all_users.py @@ -1,10 +1,8 @@ -from utils import authenticate_user -from utils import get_user_details_by_index +from utils import authenticate_user, get_user_details_by_index - -def authenticate_all_generated_users(): +def authenticate_all_generated_users() -> None: """ - Users are only visible in the course once they have logged in for the first time + Users are only visible in the course once they have logged in for the first time. This method logs in the users that are generated in Jira with the script for the Atlassian Setup: See https://github.com/ls1intum/Artemis/blob/develop/docker/atlassian/atlassian-setup.sh @@ -15,15 +13,8 @@ def authenticate_all_generated_users(): user_details = get_user_details_by_index(user_index) authenticate_user(user_details['login'], user_details['password']) - # login cypress users - for user_index in range(100, 107): - user_details = get_user_details_by_index(user_index) - authenticate_user(user_details['login'], user_details['password']) - - -def main(): +def main() -> None: authenticate_all_generated_users() - if __name__ == "__main__": main() diff --git a/supporting_scripts/course-scripts/quick-course-setup/config.ini b/supporting_scripts/course-scripts/quick-course-setup/config.ini new file mode 100644 index 000000000000..1cae1c6d43d8 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/config.ini @@ -0,0 +1,38 @@ +[Settings] +students = 2000 +commits = 2 +exercises = 1 +exercise_name = Example Programming Exercise + +# should be set to true if the script should create programming exercises otherwise set to false and provide exercise_ids on which the participations and commits should be created +create_exercises = True +exercise_Ids = 1,2 + +server_url = http://localhost:8080/api +client_url = http://localhost:9000/api + +# example urls for test server +;server_url = https://artemis-test1.artemis.cit.tum.de/api +;client_url = https://artemis-test1.artemis.cit.tum.de/api + +special_character_regex = r'[^a-zA-Z0-9_]' + +# make sure to adjust as well for the test server +admin_user = artemis_admin +admin_password = artemis_admin + +# set the amount of concurrent requests to the server for creating users or deleting them +max_threads = 5 + +[CourseSettings] +# should be set to true if the script should create a course +create_course = True +# only needed when adding users to a course after the course was already created +course_id = 1234 + +# Modify if you want to change the name of the course created with create_course +# (e.g. when creating a course on a test server) +course_name = Local Course + +# set to false if creating course on a test server (has an effect on the user groups) +is_local_course = True diff --git a/supporting_scripts/course-setup-quickstart/create_course.py b/supporting_scripts/course-scripts/quick-course-setup/create_course.py similarity index 55% rename from supporting_scripts/course-setup-quickstart/create_course.py rename to supporting_scripts/course-scripts/quick-course-setup/create_course.py index edece49e9fa1..7ec0f270e5ad 100644 --- a/supporting_scripts/course-setup-quickstart/create_course.py +++ b/supporting_scripts/course-scripts/quick-course-setup/create_course.py @@ -1,43 +1,42 @@ +import sys import requests import configparser import json import urllib3 import re -import logging - +from logging_config import logging +from requests import Session from utils import login_as_admin from add_users_to_course import add_users_to_groups_of_course +# Load configuration config = configparser.ConfigParser() config.read('config.ini') -server_url = config.get('Settings', 'server_url') -is_local_course = config.get('CourseSettings', 'is_local_course') -is_local_course = is_local_course.lower() == 'true' # convert to boolean -course_name = config.get('CourseSettings', 'course_name') - -special_characters_reg_ex = r'[^a-zA-Z0-9_]' - +# Constants from config file +SERVER_URL: str = config.get('Settings', 'server_url') +IS_LOCAL_COURSE: bool = config.get('CourseSettings', 'is_local_course').lower() == 'true' # Convert to boolean +COURSE_NAME: str = config.get('CourseSettings', 'course_name') +SPECIAL_CHARACTERS_REGEX: str = config.get('Settings', 'special_character_regex') -def parse_course_name_to_short_name(course_name): - short_name = course_name.strip() - short_name = short_name.replace(' ', '') - short_name = re.sub(special_characters_reg_ex, '', short_name) +def parse_course_name_to_short_name() -> str: + """Parse course name to create a short name, removing special characters.""" + short_name = COURSE_NAME.strip() + short_name = re.sub(SPECIAL_CHARACTERS_REGEX, '', short_name.replace(' ', '')) - short_name_does_not_start_with_letter = len(short_name) > 0 and not short_name[0].isalpha() - if short_name_does_not_start_with_letter: + if len(short_name) > 0 and not short_name[0].isalpha(): short_name = 'a' + short_name return short_name +def create_course(session: Session) -> requests.Response: + """Create a course using the given session.""" + url = f"{SERVER_URL}/admin/courses" + course_short_name = parse_course_name_to_short_name() -def create_course(session): - url = f"{server_url}/api/admin/courses" - - course_short_name = parse_course_name_to_short_name(course_name) default_course = { "id": None, - "title": str(course_name), + "title": str(COURSE_NAME), "shortName": str(course_short_name), "customizeGroupNames": False, "studentGroupName": None, @@ -68,9 +67,8 @@ def create_course(session): "enrollmentEnabled": False } - if is_local_course: + if IS_LOCAL_COURSE: default_course["customizeGroupNames"] = True - # If it's a local course, use the original group names without the prefix default_course["studentGroupName"] = "students" default_course["teachingAssistantGroupName"] = "tutors" default_course["editorGroupName"] = "editors" @@ -88,25 +86,29 @@ def create_course(session): response = session.post(url, data=body, headers=headers) if response.status_code == 201: - logging.info(f"Created course {course_name} with shortName {course_short_name} \n {response.json()}") + logging.info(f"Created course {COURSE_NAME} with shortName {course_short_name} \n {response.json()}") + elif response.status_code == 400: + logging.info(f"Course with shortName {course_short_name} already exists. Please provide the course ID in the config file and set create_course to FALSE if you intend to add programming exercises to this course.") + sys.exit(0) else: - logging.error("Problem with the group 'students' and interacting with a test server? Is 'is_local_course' in " - "'config.ini' set to 'False'?") + logging.error("Problem with the group 'students' and interacting with a test server? " + "Is 'is_local_course' in 'config.ini' set to 'False'?") raise Exception( - f"Could not create course {course_name}; Status code: {response.status_code}\n Double check whether the courseShortName {course_short_name} is not already used for another course!\nResponse content: {response.text}") - return response + f"Could not create course {COURSE_NAME}; Status code: {response.status_code}\n" + f"Double check whether the courseShortName {course_short_name} is valid (e.g. no special characters such as '-')!\n" + f"Response content: {response.text}") + return response.json() -def main(): +def main() -> None: + """Main function to create a course and add users.""" session = requests.session() login_as_admin(session) - created_course_response = create_course(session) - response_data = created_course_response.json() # This will parse the response content as JSON + response_data = create_course(session) course_id = response_data["id"] add_users_to_groups_of_course(session, course_id) - if __name__ == "__main__": main() diff --git a/supporting_scripts/course-scripts/quick-course-setup/create_users.py b/supporting_scripts/course-scripts/quick-course-setup/create_users.py new file mode 100644 index 000000000000..2e702db2e92a --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/create_users.py @@ -0,0 +1,74 @@ +import requests +import configparser +from logging_config import logging +from requests import Session +from typing import List, Tuple +from concurrent.futures import ThreadPoolExecutor + +from authenticate_all_users import authenticate_all_generated_users +from utils import login_as_admin, get_user_details_by_index, get_student_details_by_index + +# Load configuration +config = configparser.ConfigParser() +config.read('config.ini') + +# Constants from config file +CLIENT_URL: str = config.get('Settings', 'client_url') +MAX_THREADS: int = int(config.get('Settings', 'max_threads')) + +# Store user credentials +user_credentials: List[Tuple[str, str]] = [] + +def make_create_user_post_request(session: Session, user_details: dict) -> None: + """Send a POST request to create a user.""" + url = f"{CLIENT_URL}/admin/users" + headers = {"Content-Type": "application/json"} + response = session.post(url, json=user_details, headers=headers) + + if response.status_code == 201: + logging.info(f"{user_details['login']} was created successfully") + user_credentials.append((user_details['login'], user_details['password'])) + elif response.status_code == 400 and "userExists" in response.json().get("errorKey", ""): + logging.info(f"User {user_details['login']} already exists.") + user_credentials.append((user_details['login'], user_details['password'])) + else: + raise Exception( + f"Creating {user_details['login']} failed. Status code: {response.status_code}\n" + f"Response content: {response.text}" + ) + +def create_course_users(session: Session) -> None: + """Create a predefined set of course users using multithreading.""" + with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: + for userIndex in range(1, 21): + user_details = get_user_details_by_index(userIndex) + executor.submit(make_create_user_post_request, session, user_details) + +def create_students(session: Session, students_to_create: int) -> None: + """Create multiple students using multithreading and store their credentials.""" + with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: + for user_index in range(1, students_to_create): + user_details = get_student_details_by_index(user_index) + executor.submit(make_create_user_post_request, session, user_details) + +def main() -> None: + """Main function to create users and authenticate them.""" + session = requests.session() + login_as_admin(session) + + try: + create_course_users(session) + except Exception as exception: + error_message = str(exception) + if "Login name already used!" in error_message: + print("Users already created. Continuing ...") + else: + raise + + create_course_users(session) + authenticate_all_generated_users() + +if __name__ == "__main__": + # DO ONLY USE FOR LOCAL COURSE SETUP! + # (Otherwise users will be created for whom the credentials are public in the repository!) + main() diff --git a/supporting_scripts/course-scripts/quick-course-setup/delete_students.py b/supporting_scripts/course-scripts/quick-course-setup/delete_students.py new file mode 100644 index 000000000000..5b3c4c70b7c7 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/delete_students.py @@ -0,0 +1,47 @@ +import configparser +import requests +from requests import Session +from logging_config import logging +from utils import authenticate_user, get_student_details_by_index +from concurrent.futures import ThreadPoolExecutor # Add multithreading support + +# Load configuration +config = configparser.ConfigParser() +config.read('config.ini') + +# Constants from config file +CLIENT_URL: str = config.get('Settings', 'client_url') +ADMIN_USER: str = config.get('Settings', 'admin_user') +ADMIN_PASSWORD: str = config.get('Settings', 'admin_password') +STUDENTS_TO_CREATE: int = int(config.get('Settings', 'students')) + 1 +MAX_THREADS: int = int(config.get('Settings', 'max_threads')) + +def delete_all_created_students(session: Session) -> None: + authenticate_user(ADMIN_USER, ADMIN_PASSWORD, session) + delete_students(session) + logging.info(f"Deleted all created students successfully") + +def delete_students(session: Session) -> None: + """Delete students using multithreading.""" + with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: + for user_index in range(1, STUDENTS_TO_CREATE): + user_details = get_student_details_by_index(user_index) + executor.submit(delete_student, session, user_details['login']) + +def delete_student(session: Session, username: str) -> None: + url = f"{CLIENT_URL}/admin/users/{username}" + response = session.delete(url) + + if response.status_code == 200: + logging.info(f"User {username} was deleted successfully") + elif response.status_code == 404: + logging.info(f"User {username} does not exist.") + else: + logging.error(f"Deleting {username} failed. Status code: {response.status_code}\nResponse content: {response.text}") + +def main() -> None: + session: Session = requests.session() + delete_all_created_students(session) + +if __name__ == "__main__": + main() diff --git a/supporting_scripts/course-scripts/quick-course-setup/images/facets-config.png b/supporting_scripts/course-scripts/quick-course-setup/images/facets-config.png new file mode 100644 index 000000000000..a3708e778e45 Binary files /dev/null and b/supporting_scripts/course-scripts/quick-course-setup/images/facets-config.png differ diff --git a/supporting_scripts/course-scripts/quick-course-setup/images/module-config.png b/supporting_scripts/course-scripts/quick-course-setup/images/module-config.png new file mode 100644 index 000000000000..1efd5b70dd9b Binary files /dev/null and b/supporting_scripts/course-scripts/quick-course-setup/images/module-config.png differ diff --git a/supporting_scripts/course-scripts/quick-course-setup/large_course_main.py b/supporting_scripts/course-scripts/quick-course-setup/large_course_main.py new file mode 100644 index 000000000000..37f419b95025 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/large_course_main.py @@ -0,0 +1,72 @@ +import requests +import configparser +from logging_config import logging +from requests import Session +from utils import authenticate_user +from create_course import create_course +from create_users import create_students, user_credentials +from add_users_to_course import add_students_to_groups_of_course +from manage_programming_exercise import create_programming_exercise, add_participation, commit, exercise_Ids + +# Load configuration and constants +config = configparser.ConfigParser() +config.read('config.ini') + +# Constants +STUDENTS_TO_CREATE: int = int(config.get('Settings', 'students')) + 1 +COMMITS_PER_STUDENT: int = int(config.get('Settings', 'commits')) +EXERCISES_TO_CREATE: int = int(config.get('Settings', 'exercises')) +EXERCISES_NAME: str = str(config.get('Settings', 'exercise_name')) +CREATE_EXERCISES: bool = config.get('Settings', 'create_exercises').lower() == 'true' +EXERCISE_IDS: list[int] = list(map(int, config.get('Settings', 'exercise_Ids').split(','))) + +CLIENT_URL: str = config.get('Settings', 'client_url') +SERVER_URL: str = config.get('Settings', 'server_url') +ADMIN_USER: str = config.get('Settings', 'admin_user') +ADMIN_PASSWORD: str = config.get('Settings', 'admin_password') +COURSE_NAME: str = config.get('CourseSettings', 'course_name') +COURSE_ID: str = config.get('CourseSettings', 'course_id') +CREATE_COURSE: bool = config.get('CourseSettings', 'create_course').lower() == 'true' +IS_LOCAL_COURSE: bool = config.get('CourseSettings', 'is_local_course').lower() == 'true' + +def main() -> None: + # Step 1: Authenticate as admin + session: Session = requests.session() + authenticate_user(ADMIN_USER, ADMIN_PASSWORD, session) + + # Step 2: Create users + create_students(session, STUDENTS_TO_CREATE) + + # Step 3: Create a course or use an existing one + if CREATE_COURSE: + response_data = create_course(session) + course_id: str = response_data["id"] + else: + course_id: str = str(COURSE_ID) + + # Step 4: Add users to the course + add_students_to_groups_of_course(session, course_id, SERVER_URL, STUDENTS_TO_CREATE) + + # Step 5: Create programming exercises or use existing ones + if CREATE_EXERCISES: + create_programming_exercise(session, course_id, SERVER_URL, EXERCISES_TO_CREATE, EXERCISES_NAME) + else: + exercise_Ids.extend(EXERCISE_IDS) + + # Step 6: Add participation and commit for each user + logging.info("Created users and their credentials:") + + for username, password in user_credentials: + user_session: Session = requests.Session() + authenticate_user(username, password, user_session) + + for exercise_Id in exercise_Ids: + participation_response = add_participation(user_session, exercise_Id, CLIENT_URL) + logging.info(f"Added participation for {username} in the programming exercise {exercise_Id} successfully") + participation_id: int = participation_response.get('id') + + commit(user_session, participation_id, CLIENT_URL, COMMITS_PER_STUDENT) + logging.info(f"Added commit for {username} in the programming exercise {exercise_Id} successfully") + +if __name__ == "__main__": + main() diff --git a/supporting_scripts/course-scripts/quick-course-setup/logging_config.py b/supporting_scripts/course-scripts/quick-course-setup/logging_config.py new file mode 100644 index 000000000000..10f8bd863ac6 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/logging_config.py @@ -0,0 +1,4 @@ +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') diff --git a/supporting_scripts/course-scripts/quick-course-setup/manage_programming_exercise.py b/supporting_scripts/course-scripts/quick-course-setup/manage_programming_exercise.py new file mode 100644 index 000000000000..49b722e8a4d0 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/manage_programming_exercise.py @@ -0,0 +1,68 @@ +import sys +from logging_config import logging +from typing import Dict, Any +from requests import Session + +exercise_Ids: list[int] = [] + +def create_programming_exercise(session: Session, course_id: int, server_url: str, exercises_to_create: int, exercise_name: str) -> None: + """Create multiple programming exercises for the course.""" + for i in range(exercises_to_create): + url: str = f"{server_url}/programming-exercises/setup" + headers: Dict[str, str] = {"Content-Type": "application/json"} + short_name_index: int = i + 1 + + default_programming_exercise: Dict[str, Any] = { + "type": "programming", + "title": f"{exercise_name} {short_name_index}", + "shortName": f"ExProgEx{short_name_index}", + "course": {"id": course_id}, + "programmingLanguage": "JAVA", + "projectType": "PLAIN_GRADLE", + "allowOnlineEditor": True, + "allowOfflineIde": True, + "maxPoints": 100, + "assessmentType": "AUTOMATIC", + "packageName": "de.tum.in.www1.example", + "staticCodeAnalysisEnabled": False, + "buildConfig": { + "buildScript": "#!/usr/bin/env bash\nset -e\n\ngradle () {\n echo '⚙️ executing gradle'\n chmod +x ./gradlew\n ./gradlew clean test\n}\n\nmain () {\n gradle\n}\n\nmain \"${@}\"\n", + "checkoutSolutionRepository": False, + "testwiseCoverageEnabled": False + }, + } + + response = session.post(url, json=default_programming_exercise, headers=headers) + + if response.status_code == 201: + logging.info(f"Created programming exercise {default_programming_exercise['title']} successfully") + exercise_Ids.append(response.json().get('id')) + elif response.status_code == 400: + logging.info(f"Programming exercise with shortName {default_programming_exercise['shortName']} already exists. Please provide the exercise IDs in the config file and set create_exercises to FALSE.") + sys.exit(0) + else: + raise Exception(f"Could not create programming exercise; Status code: {response.status_code}\nResponse content: {response.text}") + +def add_participation(session: Session, exercise_id: int, client_url: str) -> Dict[str, Any]: + """Add a participation for the exercise.""" + url: str = f"{client_url}/exercises/{exercise_id}/participations" + headers: Dict[str, str] = {"Content-Type": "application/json"} + + response = session.post(url, headers=headers) + if response.status_code == 201: + return response.json() + elif response.status_code == 403: + logging.info(f"Not allowed to push to following programming exercise with following id: {exercise_id}. Please double check if the exercise is part of the Course and update the exercise_Ids in the config file.") + sys.exit(0) + else: + response.raise_for_status() + +def commit(session: Session, participation_id: int, client_url: str, commits_per_student: int) -> None: + """Commit the participation to the repository multiple times.""" + for _ in range(commits_per_student): + url: str = f"{client_url}/repository/{participation_id}/commit" + headers: Dict[str, str] = {"Content-Type": "application/json"} + + response = session.post(url, headers=headers) + if response.status_code != 201: + response.raise_for_status() diff --git a/supporting_scripts/course-scripts/quick-course-setup/requirements.txt b/supporting_scripts/course-scripts/quick-course-setup/requirements.txt new file mode 100644 index 000000000000..55c9f479fae2 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/requirements.txt @@ -0,0 +1,2 @@ +requests==2.32.3 +urllib3==2.2.2 diff --git a/supporting_scripts/course-scripts/quick-course-setup/utils.py b/supporting_scripts/course-scripts/quick-course-setup/utils.py new file mode 100644 index 000000000000..f6fd5f81d776 --- /dev/null +++ b/supporting_scripts/course-scripts/quick-course-setup/utils.py @@ -0,0 +1,103 @@ +import requests +import configparser +from logging_config import logging +from typing import Dict, Any, List + +config = configparser.ConfigParser() +config.read('config.ini') + +SERVER_URL: str = config.get('Settings', 'server_url') +ADMIN_USER: str = config.get('Settings', 'admin_user') +ADMIN_PASSWORD: str = config.get('Settings', 'admin_password') + +def login_as_admin(session: requests.Session) -> None: + """Authenticate as an admin using the provided session.""" + authenticate_user(ADMIN_USER, ADMIN_PASSWORD, session) + +def add_user_to_course(session: requests.Session, course_id: int, user_group: str, user_name: str) -> None: + """Add a user to a specified course and group.""" + url: str = f"{SERVER_URL}/courses/{course_id}/{user_group}/{user_name}" + response: requests.Response = session.post(url) + if response.status_code == 200: + logging.info(f"Added user {user_name} to group {user_group}") + else: + logging.error(f"Could not add user {user_name} to group {user_group}") + +def authenticate_user(username: str, password: str, session: requests.Session = requests.Session()) -> requests.Response: + """Authenticate a user and return the session response.""" + url: str = f"{SERVER_URL}/public/authenticate" + headers: Dict[str, str] = { + "Content-Type": "application/json" + } + + payload: Dict[str, Any] = { + "username": username, + "password": password, + "rememberMe": True + } + + response: requests.Response = session.post(url, json=payload, headers=headers) + + if response.status_code == 200: + logging.info(f"Authentication successful for user {username}") + else: + raise Exception( + f"Authentication failed for user {username}. Status code: {response.status_code}\n Response content: {response.text}") + + return response + +def get_user_details_by_index(user_index: int) -> Dict[str, Any]: + """Generate user details based on the index for predefined users.""" + username: str = f"artemis_test_user_{user_index}" + password: str = username + authorities: List[str] = [] + groups: List[str] = [] + + user_role: str = "ROLE_USER" + + if 1 <= user_index <= 5 or user_index in {100, 102, 104, 105, 106}: + authorities = [user_role] + groups = ["students"] + elif 6 <= user_index <= 10 or user_index == 101: + authorities = [user_role, "ROLE_TA"] + groups = ["tutors"] + elif 11 <= user_index <= 15: + authorities = [user_role, "ROLE_EDITOR"] + groups = ["editors"] + elif 16 <= user_index <= 20 or user_index == 103: + authorities = [user_role, "ROLE_INSTRUCTOR"] + groups = ["instructors"] + + return { + "activated": True, + "authorities": authorities, + "login": username, + "email": f"{username}@artemis.local", + "firstName": username, + "lastName": username, + "langKey": "en", + "guidedTourSettings": [], + "groups": groups, + "password": password + } + +def get_student_details_by_index(user_index: int) -> Dict[str, Any]: + """Generate user details based on the index for students.""" + username: str = f"student{user_index}" + password: str = "Password123!" + user_role: str = "ROLE_USER" + authorities: List[str] = [user_role] + groups: List[str] = ["students"] + + return { + "activated": True, + "authorities": authorities, + "login": username, + "email": f"{username}@example.com", + "firstName": "Test", + "lastName": f"User{user_index}", + "langKey": "en", + "guidedTourSettings": [], + "groups": groups, + "password": password + } diff --git a/supporting_scripts/course-setup-quickstart/README.md b/supporting_scripts/course-setup-quickstart/README.md deleted file mode 100644 index e336de857d4a..000000000000 --- a/supporting_scripts/course-setup-quickstart/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# Create local course and users - -Scripts in this folder help to configure and setup your first Artemis course to get started quickly. - -## Setup - -1. Install the [Python Plugin for IntelliJ](https://plugins.jetbrains.com/plugin/631-python) - -2. Enable Python support in your IntelliJ - - `File > Project Structure > Facets > Add - Python` (press the + and add a Python environment there, - make sure that you have configured the Python interpreter) - - ![IntelliJ Python Facet Configuration](./images/facets-config.png) - - If not added automatically by IntelliJ, add a module: - `File > Project Structure > Modules > Add - Python` - - ![IntelliJ Module Configuration](./images/module-config.png) - -_Tested on Python 3.7 & 3.9, other versions might work as well._ - -## Usage - -1. Start your local Artemis instance -2. Configure the values in `config.ini` according to your setup -3. Install the missing packages of the Python scripts that you want to execute - - _Make sure to install it for the Python version that was configured in [Setup](#setup). - Installing packages for a certain Python version can be done with_ - ```shell - python3.9 -m pip install - ``` - -4. Either use the play button within IntelliJ _(which should be displayed if Python was configured properly within - IntelliJ)_ to run the scripts or follow the following descriptions - -### Local Setup - -#### Create a course with standard user groups - -Creates a course for which the users from the previous section [Create users](#create-users) are registered as they have -the same user -groups (students, tutors, editors, instructors) - -```shell -python3 create_course.py -``` - -You can also execute the following scripts on their own (not needed if `create_course.py` was executed) - -##### Create users - -Creates users 1-20 (students, tutors, editors, instructors - 5 for each group) and users needed for Cypress E2E -testing (100-106) - -```shell -python3 create_users.py -``` - -##### Authenticate users - -If the users have already been created, they still need to be logged in order to be added to a -course _(without a first login, Artemis does not know that the users exist)_ - -```shell -python3 authenticate_all_users.py -``` - -### Test Servers - -Re-Configure the `config.ini` - -1. Adjust `server_url` and `client_url` according to the test server -2. Make sure to upate `admin_user` and `admin_password` to valid values for the test server -3. As we are interacting with the test servers, set `is_local_course` in `config.ini` to `False` - -#### Create a course and assign the default users - -1-5: students -6-10: tutors -11-15: instructors -16-20: editors - -Define the name of your course in the `config.ini` as `course_name` - -```shell -python3 create_course.py -``` - -#### Add users to existing course - -Define the `course_id` in the `config.ini` - -```shell -python3 add_users_to_course.py -``` diff --git a/supporting_scripts/course-setup-quickstart/add_users_to_course.py b/supporting_scripts/course-setup-quickstart/add_users_to_course.py deleted file mode 100644 index 8ad9f0544b41..000000000000 --- a/supporting_scripts/course-setup-quickstart/add_users_to_course.py +++ /dev/null @@ -1,30 +0,0 @@ -import requests -import configparser - -from utils import login_as_admin -from utils import get_user_details_by_index -from utils import add_user_to_course - -config = configparser.ConfigParser() -config.read('config.ini') - -course_id = config.get('CourseSettings', 'course_id') - - -def add_users_to_groups_of_course(session, course_id): - print(f"Adding users to course with id {course_id}") - for userIndex in range(1, 21): - user_details = get_user_details_by_index(userIndex) - add_user_to_course(session, course_id, user_details["groups"][0], user_details["login"]) - - -def main(): - session = requests.session() - login_as_admin(session) - add_users_to_groups_of_course(session, course_id) - - -if __name__ == "__main__": - # DO ONLY USE FOR LOCAL COURSE SETUP! - # (Otherwise users will be created for whom the credentials are public in the repository!) - main() diff --git a/supporting_scripts/course-setup-quickstart/config.ini b/supporting_scripts/course-setup-quickstart/config.ini deleted file mode 100644 index d52abdfbef39..000000000000 --- a/supporting_scripts/course-setup-quickstart/config.ini +++ /dev/null @@ -1,23 +0,0 @@ -[Settings] -server_url = http://localhost:8080 -client_url = http://localhost:9000 - -# example urls for test server -;server_url = https://artemis-test1.artemis.cit.tum.de -;client_url = https://artemis-test1.artemis.cit.tum.de - -# make sure to adjust aswell for the test server -admin_user = artemis_admin -admin_password = artemis_admin - - -[CourseSettings] -# only needed when adding users to a course after the course was already created -course_id = 1234 - -# Modify if you want to change the name of the course created with create_course -# (e.g. when creating a course on a test server) -course_name = Local Course - -# set to false if creating course on a test server (has an effect on the user groups) -is_local_course = True diff --git a/supporting_scripts/course-setup-quickstart/create_users.py b/supporting_scripts/course-setup-quickstart/create_users.py deleted file mode 100644 index 42d909de7603..000000000000 --- a/supporting_scripts/course-setup-quickstart/create_users.py +++ /dev/null @@ -1,70 +0,0 @@ -import requests -import configparser -import logging - -from authenticate_all_users import authenticate_all_generated_users -from utils import login_as_admin -from utils import get_user_details_by_index - -config = configparser.ConfigParser() -config.read('config.ini') - -client_url = config.get('Settings', 'client_url') - - -def make_create_user_post_request(session, user_details): - url = f"{client_url}/api/admin/users" - headers = { - "Content-Type": "application/json" - } - payload = user_details - response = session.post(url, json=payload, headers=headers) - - if response.status_code == 201: - logging.info(f"{user_details['login']} was created successfully") - elif response.status_code == 400 and "userExists" in response.json().get("errorKey", ""): - logging.info(f"User {user_details['login']} already exists.") - else: - raise Exception( - f"Creating {user_details['login']} failed. Status code: {response.status_code}\nResponse content: {response.text}") - - -def create_course_users(session): - for userIndex in range(1, 21): - user_details = get_user_details_by_index(userIndex) - make_create_user_post_request(session, user_details) - - -def create_cypress_users(session): - for userIndex in range(100, 107): - user_details = get_user_details_by_index(userIndex) - make_create_user_post_request(session, user_details) - - -def create_users(session): - create_course_users(session) - create_cypress_users(session) - - -def main(): - session = requests.session() - login_as_admin(session) - - try: - create_users(session) - except Exception as exception: - error_message = str(exception) - - if "Login name already used!" in error_message: - print("Users already created. Continuing ...") - else: - raise - - create_users(session) - authenticate_all_generated_users() - - -if __name__ == "__main__": - # DO ONLY USE FOR LOCAL COURSE SETUP! - # (Otherwise users will be created for whom the credentials are public in the repository!) - main() diff --git a/supporting_scripts/course-setup-quickstart/images/facets-config.png b/supporting_scripts/course-setup-quickstart/images/facets-config.png deleted file mode 100644 index 5f5ffbd0bbc5..000000000000 Binary files a/supporting_scripts/course-setup-quickstart/images/facets-config.png and /dev/null differ diff --git a/supporting_scripts/course-setup-quickstart/images/module-config.png b/supporting_scripts/course-setup-quickstart/images/module-config.png deleted file mode 100644 index ed0bd3193938..000000000000 Binary files a/supporting_scripts/course-setup-quickstart/images/module-config.png and /dev/null differ diff --git a/supporting_scripts/course-setup-quickstart/utils.py b/supporting_scripts/course-setup-quickstart/utils.py deleted file mode 100644 index 88e8f6144d4b..000000000000 --- a/supporting_scripts/course-setup-quickstart/utils.py +++ /dev/null @@ -1,81 +0,0 @@ -import requests -import configparser -import logging - -config = configparser.ConfigParser() -config.read('config.ini') - -server_url = config.get('Settings', 'server_url') -admin_user = config.get('Settings', 'admin_user') -admin_password = config.get('Settings', 'admin_password') - - -def login_as_admin(session): - authenticate_user(admin_user, admin_password, session) - - -def add_user_to_course(session, course_id, user_group, user_name): - url = f"{server_url}/api/courses/{course_id}/{user_group}/{user_name}" - response = session.post(url) - if response.status_code == 200: - logging.info(f"Added user {user_name} to group {user_group}") - else: - logging.error(f"Could not add user {user_name} to group {user_group}") - - -def authenticate_user(username, password, session=requests.Session()): - url = f"{server_url}/api/public/authenticate" - headers = { - "Content-Type": "application/json" - } - - payload = { - "username": username, - "password": password, - "rememberMe": True - } - - response = session.post(url, json=payload, headers=headers) - - if response.status_code == 200: - logging.info(f"Authentication successful for user {username}") - else: - raise Exception( - f"Authentication failed for user {username}. Status code: {response.status_code}\n Response content: {response.text}") - - return response - - -def get_user_details_by_index(user_index): - username = f"artemis_test_user_{user_index}" - password = username - authorities = [] - groups = [] - - user_role = "ROLE_USER" - - if 1 <= user_index <= 5 or user_index in {100, 102, 104, 105, 106}: - authorities = [user_role] - groups = ["students"] - elif 6 <= user_index <= 10 or user_index == 101: - authorities = [user_role, "ROLE_TA"] - groups = ["tutors"] - elif 11 <= user_index <= 15: - authorities = [user_role, "ROLE_EDITOR"] - groups = ["editors"] - elif 16 <= user_index <= 20 or user_index == 103: - authorities = [user_role, "ROLE_INSTRUCTOR"] - groups = ["instructors"] - - return { - "activated": True, - "authorities": authorities, - "login": username, - "email": username + "@artemis.local", - "firstName": username, - "lastName": username, - "langKey": "en", - "guidedTourSettings": [], - "groups": groups, - "password": password - } diff --git a/supporting_scripts/generate_code_cov_table/README.md b/supporting_scripts/generate_code_cov_table/README.md index 1953c39aef5a..a5495c41fe53 100644 --- a/supporting_scripts/generate_code_cov_table/README.md +++ b/supporting_scripts/generate_code_cov_table/README.md @@ -100,3 +100,17 @@ Alternatively, the Python modules `qtpy` or `PyQT5` have to be present. If no option to insert text into the clipboard is found, the script falls back to printing to stdout. **You will have to manually adjust the confirmation column for each file!** + + +### Dependency management + +Find outdated dependencies using the following command: +``` +pip list --outdated +``` + +Find unused dependencies using the following command: +``` +pip install deptry +deptry . +```